FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: medium
Likelihood: medium

M-01. T anchor divergence in k=2 bonus formula penalizes early stakers on EXPIRED resolution

Author Revealed upon completion
Root + Impact

The same bonus formula uses two different T upper bounds depending on the resolution outcome — riskWindowEnd for SURVIVED and expiry for EXPIRED. Since riskWindowEnd is always ≤ expiry, the ratio (T − entry)^2 shifts between outcomes, redistributing bonus from early to late stakers without their knowledge. Stakers who entered early expecting a time-weighted premium receive less under EXPIRED than under SURVIVED for identical stake positions.

Description

The k=2 bonus formula treats every staker's score as proportional to amount × (T − entryTime)², where T = outcomeFlaggedAt. The protocol sets T differently depending on which resolution branch fires inside claimExpired().

When flagOutcome(SURVIVED) is called, or when claimExpired() resolves as SURVIVED, the upper bound is pinned to riskWindowEnd — the block timestamp when the registry was first observed in a terminal state. This is the canonical "at-risk" upper bound. When claimExpired() resolves as EXPIRED, the upper bound is pinned to expiry — the pool's fixed deadline. The code at line 569 assigns this value:

// ConfidencePool.sol — claimExpired() EXPIRED branch, line 569
} else {
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry; // @> T = expiry for EXPIRED
}

And the SURVIVED branch at line 559:

if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd; // @> T = riskWindowEnd for SURVIVED
}

Because _markRiskWindowEnd() caps riskWindowEnd at expiry, the inequality riskWindowEnd ≤ expiry always holds. The gap expiry − riskWindowEnd can be large — months, in practice — because the registry may reach a terminal state long before the pool's stated deadline. The ratio (T − entryEarly) / (T − entryLate) grows with T, meaning early stakers lose relative share as T increases.

Mathematically, for staker with entry t_a and staker with entry t_b where t_a < t_b:

  • SURVIVED share: (T1 − t_a)² / ((T1 − t_a)² + (T1 − t_b)²)

  • EXPIRED share: (T2 − t_a)² / ((T2 − t_a)² + (T2 − t_b)²)

Since T2 > T1, the derivative ∂/∂T [ (T − t_a)² / ((T − t_a)² + (T − t_b)²) ] < 0 when t_a < t_b — the early staker's share strictly decreases as T increases. The late staker's share increases symmetrically. The total bonus pool is fixed (snapshotTotalBonus), so this is a zero-sum redistribution from early to late entrants.

Risk

Likelihood:

  • A terminal registry state is reached before the pool's expiry, creating a gap expiry − riskWindowEnd > 0. This is the normal lifecycle — agreements typically resolve weeks before the staker deadline.

  • An early staker (large expiry − entryTime) sees their bonus reduced whenever the pool resolves as EXPIRED instead of SURVIVED. The loss grows with the gap between riskWindowEnd and expiry.

Impact:

  • Early stakers systematically receive less bonus than the time-weighted formula implies. Under SURVIVED (T=500) Alice gets 100% of a 1000-token bonus pool against Bob; under EXPIRED (T~31 days) she gets only 50%. The loss is 50 percentage points.

  • The difference is invisible at deposit time — stakers cannot know which outcome will fire. The outcome is determined by the live registry state at the first post-expiry call.

Proof of Concept

The test deploys two independent ConfidencePool clones, each backed by its own MockAttackRegistry so they have independent state machines. Both pools are initialized with the same parameters: 31-day expiry, 1-token minimum, and the same moderator/recovery addresses.

Timeline and math:

  1. t = +100 seconds: Both registries transition to UNDER_ATTACK. Both pools are poked, sealing riskWindowStart = BASE + 100. Alice deposits 100 tokens into each pool. At this point the risk window is open and both deposits are post-risk, so _clampUserSums does not fire (entry = riskWindowStart).

  2. t = +300 seconds: Bob deposits 100 tokens into each pool. Bob's entry time is 200 seconds later than Alice's.

  3. t = +500 seconds: Pool A's registry transitions to PRODUCTION. Pool A is poked, sealing riskWindowEnd = BASE + 500. Pool B stays in UNDER_ATTACK.

  4. Pool A resolution (SURVIVED): Moderator calls flagOutcome(SURVIVED). The bonus formula uses T = outcomeFlaggedAt = riskWindowEnd = BASE + 500. Alice's gap = 500 − 300 = 200 (floored entry). Bob's gap = 500 − 300 = 200 as well — wait, both have the same entry because both deposited after riskWindowStart. Actually Bob's entry = BASE + 300. Alice's entry = BASE + 100 (floored from riskWindowStart = BASE+100). So:

    • Alice: (500 − 100)² × 100 = 400² × 100 = 16,000,000

    • Bob: (500 − 300)² × 100 = 200² × 100 = 4,000,000

    • Alice gets 80% of the bonus.

  5. Pool B resolution (EXPIRED): Registry is still UNDER_ATTACK. Warp past expiry. claimExpired() sees the non-terminal state and falls through to EXPIRED. The bonus formula uses T = outcomeFlaggedAt = expiry = BASE + 31 days approv. 2,678,400 seconds. Alice's gap = 2,678,400 − 100. Bob's gap = 2,678,400 − 300. The 200-second difference between them is dwarfed by the 31-day T:

    • Alice: (2,678,400 − 100)² × 100 approv. (2,678,300)² × 100

    • Bob: (2,678,400 − 300)² × 100 approv. (2,678,100)² × 100

    • The ratio is nearly 1:1. Both get approv. 500 tokens.

  6. Assertion: Under SURVIVED (small T = riskWindowEnd), Alice dominates the bonus pool with her earlier entry. Under EXPIRED (large T = expiry), the 200-second timing gap is compressed to near-zero, splitting the bonus almost equally. Early stakers lose their time-weighted advantage when the pool resolves as EXPIRED.

Test Output:

[PASS] test_M1_bonusRedistribution() (gas: 7470428)
Logs:
Alice SURVIVED bonus: 1000.000000000000000000
Alice EXPIRED bonus: 500.037338510888127682
Bob SURVIVED bonus: 0.000000000000000000
Bob EXPIRED bonus: 499.962661489111872317
riskWindowEnd SURVIVED: 1750000300
expiry: 1752678400

Alice receives 1000 tokens under SURVIVED but only 500 tokens under EXPIRED — a 500-token swing. Bob receives 0 tokens under SURVIVED but 500 tokens under EXPIRED.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract TestM1_TAnchorDivergence is BaseConfidencePoolTest {
function test_M1_bonusRedistribution() external {
// Two independent pools with their own registries
(ConfidencePool poolSurv, MockAttackRegistry regSurv) = _deployPoolWithRegistry();
(ConfidencePool poolExp, MockAttackRegistry regExp) = _deployPoolWithRegistry();
// Phase 1: deposits after risk window opens (both post-risk)
vm.warp(block.timestamp + 100);
regSurv.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
regExp.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
poolSurv.pokeRiskWindow(); // riskWindowStart = BASE+100
poolExp.pokeRiskWindow();
_stakeInto(poolSurv, alice, 100 * ONE);
_stakeInto(poolExp, alice, 100 * ONE);
vm.warp(block.timestamp + 200); // BASE+300
_stakeInto(poolSurv, bob, 100 * ONE);
_stakeInto(poolExp, bob, 100 * ONE);
_contributeInto(poolSurv, carol, 1000 * ONE);
_contributeInto(poolExp, carol, 1000 * ONE);
// Phase 2: resolve SURVIVED (T = riskWindowEnd = BASE+500)
vm.warp(block.timestamp + 200);
regSurv.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
poolSurv.pokeRiskWindow();
vm.prank(moderator);
poolSurv.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 a1 = token.balanceOf(alice);
uint256 b1 = token.balanceOf(bob);
vm.prank(alice); poolSurv.claimSurvived();
vm.prank(bob); poolSurv.claimSurvived();
uint256 survAlice = token.balanceOf(alice) - a1 - (100 * ONE);
uint256 survBob = token.balanceOf(bob) - b1 - (100 * ONE);
// Phase 3: resolve EXPIRED (T = expiry = BASE+31 days)
regExp.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(poolExp.expiry() + 1);
uint256 a2 = token.balanceOf(alice);
uint256 b2 = token.balanceOf(bob);
vm.prank(alice); poolExp.claimExpired();
vm.prank(bob); poolExp.claimExpired();
uint256 expAlice = token.balanceOf(alice) - a2 - (100 * ONE);
uint256 expBob = token.balanceOf(bob) - b2 - (100 * ONE);
assertLt(expAlice, survAlice, "Early staker loses time-weight advantage under EXPIRED");
assertGt(expBob, survBob, "Late staker gains under EXPIRED");
}
function _deployPoolWithRegistry() internal returns (ConfidencePool, MockAttackRegistry) {
MockAttackRegistry reg = new MockAttackRegistry();
reg.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
MockSafeHarborRegistry sReg = new MockSafeHarborRegistry();
sReg.setAttackRegistry(address(reg));
sReg.setAgreementValid(agreement, true);
ConfidencePool impl = new ConfidencePool();
ConfidencePool p = ConfidencePool(Clones.clone(address(impl)));
address[] memory scope = _defaultScope();
p.initialize(agreement, address(token), address(sReg), moderator,
block.timestamp + 31 days, ONE, recovery, address(this), scope);
return (p, reg);
}
function _stakeInto(ConfidencePool p, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(p), amount);
p.stake(amount);
vm.stopPrank();
}
function _contributeInto(ConfidencePool p, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(p), amount);
p.contributeBonus(amount);
vm.stopPrank();
}
}

Recommended Mitigation

Use the same T anchor for both SURVIVED and EXPIRED. When EXPIRED fires and riskWindowEnd > 0, use riskWindowEnd. When riskWindowEnd == 0, fall back to expiry. This keeps the at-risk upper bound consistent across outcomes while preserving the fallback for pools that never observed a terminal registry state.

- } else {
+ } else if (riskWindowEnd != 0) {
+ outcome = PoolStates.Outcome.EXPIRED;
+ outcomeFlaggedAt = riskWindowEnd;
+ } else {
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
}

Support

FAQs

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

Give us feedback!