stake(uint256 amount) can execute after the owner changes the pool's expiry or the registry enters active risk, committing the depositor under materially different terms without invalidating their pending transaction.
Stakers are expected to inspect the pool's on-chain parameters and live BattleChain registry state before depositing. The reviewed expiry defines the maximum underwriting term, while the registry state determines whether the depositor retains the pre-risk withdrawal option or immediately commits principal to resolution.
Deposits during UNDER_ATTACK are intentionally supported. A depositor who knowingly submits in that state accepts that stake() will seal riskWindowStart and withdraw() will be disabled. Similarly, the owner is intentionally allowed to edit expiry until the first successful stake, after which expiryLocked makes the deadline immutable.
The signed stake calldata contains only amount, however. It carries no expected expiry, expected registry state, active-risk consent, or transaction deadline. Both material conditions are therefore checked only at execution:
A transaction prepared in ATTACK_REQUESTED, where withdrawal is available, still succeeds if the ordinary DAO approveAttack() transition to UNDER_ATTACK executes first. The same call seals the risk window, and the depositor cannot withdraw after execution. A later valid CORRUPTED outcome can forfeit 100% of principal even though the submitted transaction did not authorize an active-risk deposit.
Before the first stake executes, the owner can order setExpiry() first and replace the displayed deadline with any valid value up to type(uint32).max. The pending stake still succeeds and permanently locks that substituted expiry. When it is also made during active risk, the depositor has neither withdrawal nor the expiry backstop at the deadline they reviewed.
Setting expiryLocked = true before safeTransferFrom() only prevents changes during the execution of stake() itself. It cannot protect against a separate owner transaction included immediately before the stake.
The documented instruction to read the live registry before staking does not mitigate the issue. A read occurs before the transaction is signed or submitted; the relevant transition can be included before that pending transaction. Only a calldata-bound execution condition can preserve the value the depositor reviewed.
Likelihood:
ATTACK_REQUESTED -> UNDER_ATTACK is an ordinary BattleChain transition performed by the registry moderator. A pending stake only needs to be included after that transition; a compromised registry or malicious validator is not required.
The expiry substitution applies only to the first successful stake and requires a malicious or compromised pool owner to order setExpiry() before it. Public transaction visibility makes that ordering practical whenever the first stake is submitted through a public mempool.
Both cases use the normal stake(amount) interface and a standard ERC-20. No unusual token behavior, reentrancy, arithmetic edge case, or privileged action by the depositor is required.
Impact:
A stale pre-risk stake immediately loses its withdrawal right and becomes exposed to the complete CORRUPTED distribution. The PoC demonstrates the depositor's full principal being swept to recoveryAddress.
The owner can replace a reviewed 31-day term with the uint32 timestamp ceiling near February 2106. At the original maturity, claimExpired() still reverts PoolNotExpired, while an active-risk position remains non-withdrawable.
The first stake itself makes the substituted expiry permanent, so neither the depositor nor a later honest owner can restore the reviewed term after execution.
Create test/audit/CP003StakeTermsNotBound.t.sol with the following contents:
Run the PoC from the repository root:
Execute forge test --offline --match-path test/audit/CP003StakeTermsNotBound.t.sol -vv.
Confirm that both tests pass and the suite reports two tests passed with zero failures.
Replace the unguarded stake entrypoint with one that binds all material execution conditions chosen by the depositor. Exact equality for expiry and registry state provides the clearest semantics; a deadline bounds how long the authorization remains live.
The reverted state comparison also rolls back any lifecycle markers written by _observePoolState(), so a mismatch cannot accidentally commit the deposit or its observations. Update all integrations to pass values obtained during quote/review, and do not retain an unguarded public wrapper that silently supplies execution-time values.
If exact state equality is too restrictive for integrations, replace expectedState with an explicit caller policy such as acceptActiveRisk; the critical property is that a transition which removes withdrawal must require affirmative calldata consent. A pre-launch lockExpiry() requirement can additionally remove the owner-ordering race, but it does not replace the need to bind registry risk state.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.