FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: high
Likelihood: low

A late first observer can capture an amount-weighted bonus after bearing almost no risk

Author Revealed upon completion

Root + Impact

Description

The protocol allows deposits while the agreement is UNDER_ATTACK because the design expects the quadratic bonus formula to make a late deposit economically unattractive.

Section 3 of docs/DESIGN.md states:

“Their bonus share is crushed to ~zero by the k=2 weighting — entry is floored at riskWindowStart ≈ now, so (T − entry)² ≈ 0.”

It consequently concludes:

“There is no late-join advantage to gate against...”

That conclusion holds only when riskWindowStart was already sealed before the late depositor entered. The pool does not read the registry continuously. Instead, riskWindowStart remains zero until a pool interaction observes an active-risk state.

When stake() is the first call to observe UNDER_ATTACK, _observePoolState() invokes _markRiskWindowStart() before accounting for the new deposit:

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
_assertDepositsAllowed(_observePoolState());
// ...
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
uint256 contribTime = received * newEntry;
uint256 contribTimeSq = received * newEntry * newEntry;
eligibleStake[msg.sender] += received;
userSumStakeTime[msg.sender] += contribTime;
userSumStakeTimeSq[msg.sender] += contribTimeSq;
totalEligibleStake += received;
sumStakeTime += contribTime;
sumStakeTimeSq += contribTimeSq;
}

_markRiskWindowStart() rewrites the global score inputs for all existing stake as though it entered at the observation timestamp:

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
// @> Every existing stake is moved to the late observation timestamp
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
}

The new depositor is then recorded with that same timestamp. Therefore, if Bob is the first observer one second before expiry, both Alice’s old stake and Bob’s new stake receive an effective entry time of expiry - 1.

At expiry, both positions have the same score per token:

Alice score = Alice stake × (expiry − (expiry − 1))²
Bob score = Bob stake × (expiry − (expiry − 1))²

The bonus consequently becomes amount-weighted. If Alice has 100 tokens and Bob deposits 900 tokens, Bob receives 90% of the bonus despite bearing only one second of risk.

Section 7 of the design documentation acknowledges the underlying timestamp behavior:

“Every deposit made before the seal (including the one that triggers it) floors to riskWindowStart and is amount-weighted among that cohort. Intentional...”

However, this directly conflicts with Section 3’s broader assurance that an UNDER_ATTACK depositor’s bonus is crushed to approximately zero and that there is no late-join advantage. A deposit that triggers the first observation is not crushed relative to existing stakers; it receives exactly the same per-token weight.

The documented safety argument therefore depends on an unstated operational assumption that an economically interested participant will call pokeRiskWindow() promptly after the registry enters UNDER_ATTACK. The contract does not enforce that assumption.

Risk

Likelihood:

The agreement must remain UNDER_ATTACK without any pool interaction until shortly before expiry. Any earlier call that seals riskWindowStart prevents the attack.

Impact:

A late depositor can capture an amount-weighted portion of the bonus while bearing almost no risk, diluting the bonus owed to earlier capital providers.

Proof of Concept

Add the following test to test/unit/LateUnderAttackBonus.t.sol and run:

forge test --offline --match-contract LateUnderAttackBonusTest -vv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract LateUnderAttackBonusTest is BaseConfidencePoolTest {
function testPoC_firstObserverDepositsOneSecondBeforeExpiryAndCapturesAmountWeightedBonus()
external
{
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
// The agreement becomes attackable, but nobody touches the pool.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
assertEq(pool.riskWindowStart(), 0, "risk window remains locally unobserved");
// Bob becomes the first observer immediately before expiry.
vm.warp(pool.expiry() - 1);
_stake(bob, 900 * ONE);
assertEq(
pool.riskWindowStart(),
pool.expiry() - 1,
"Bob's deposit seals the risk window"
);
vm.warp(pool.expiry());
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
uint256 aliceBonus =
token.balanceOf(alice) - aliceBefore - 100 * ONE;
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimExpired();
uint256 bobBonus =
token.balanceOf(bob) - bobBefore - 900 * ONE;
assertEq(aliceBonus, 10 * ONE, "Alice receives only her amount-weighted share");
assertEq(bobBonus, 90 * ONE, "late first observer captures 90% of the bonus");
}
}

The test passes and shows that Bob receives 90% of the bonus after depositing only one second before resolution.

Recommended Mitigation

Maybe require the risk window to be observed before any UNDER_ATTACK deposit is accepted, and reject deposits that would enter too close to expiry. This stops a late first observer from sealing the window and immediately capturing an amount-weighted bonus share.

uint256 public constant MIN_UNDER_ATTACK_REMAINING = 7 days;
function _assertDepositsAllowed(IAttackRegistry.ContractState state) private view {
if (
state == IAttackRegistry.ContractState.PROMOTION_REQUESTED
|| state == IAttackRegistry.ContractState.PRODUCTION
|| state == IAttackRegistry.ContractState.CORRUPTED
) {
revert StakingClosed();
}
if (state == IAttackRegistry.ContractState.UNDER_ATTACK) {
if (riskWindowStart == 0) revert RiskWindowNotObserved();
if (block.timestamp + MIN_UNDER_ATTACK_REMAINING > expiry) {
revert InsufficientRiskPeriod();
}
}
}
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!