FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

Independent risk-window latches + resurrected and inverted bonus entitlement

Author Revealed upon completion

Description

  • Normal behavior: riskWindowStart should represent the first observed active-risk moment, and riskWindowEnd should represent the first observed terminal moment. The k=2 bonus formula is intended to reward earlier at-risk capital more heavily and to pay zero bonus when no active-risk window was ever observed.

  • Specific issue: _observePoolState() writes riskWindowStart and riskWindowEnd independently and never enforces riskWindowStart <= riskWindowEnd. A non-continuous trusted registry cutover can make the pool observe terminal state first and active-risk state later. Once the later active observation writes riskWindowStart > riskWindowEnd, the pool exits the zero-bonus no-risk branch and computes bonus using a stale terminal T, allowing bonus resurrection and later-staker weighting inversion.

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
// @> no guard prevents setting start after riskWindowEnd was already set
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
// @> terminal can be recorded first
_markRiskWindowEnd();
}
}
function _bonusShare(address u, uint256 userEligible) internal view returns (uint256) {
if (snapshotTotalBonus == 0) return 0;
if (riskWindowStart == 0) return 0;
// @> once a later active observation sets riskWindowStart, this branch pays bonus
uint256 T = outcomeFlaggedAt;
uint256 userPlus = T * T * userEligible + userSumStakeTimeSq[u];
uint256 userMinus = 2 * T * userSumStakeTime[u];
uint256 userScore = userPlus > userMinus ? userPlus - userMinus : 0;
uint256 plus = T * T * snapshotTotalStaked + snapshotSumStakeTimeSq;
uint256 minus = 2 * T * snapshotSumStakeTime;
uint256 globalScore = plus > minus ? plus - minus : 0;
if (globalScore == 0) {
return Math.mulDiv(userEligible, snapshotTotalBonus, snapshotTotalStaked);
}
return Math.mulDiv(userScore, snapshotTotalBonus, globalScore);
}

Risk

Likelihood:

  • A trusted DAO / Safe Harbor registry owner performs a non-continuous registry cutover after a pool has already observed terminal state from the previous registry.

  • The replacement registry reports the same agreement as active-risk before terminal-equivalent migration continuity has been established.

Impact:

  • Bonus that would have remained unowed and sweepable to recoveryAddress under terminal-first no-risk semantics becomes payable to stakers.

  • In the stronger case, a later staker receives more bonus than the incumbent because the stale terminal timestamp is earlier than the later active-risk start.

Proof of Concept

Bonus resurrection:

function test_DF_LBP_2_4_1_replacementActiveObservationResurrectsBonusEntitlement() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
uint256 terminalObservedAt = pool.riskWindowEnd();
assertEq(pool.riskWindowStart(), 0, "terminal-first path starts in no-risk bonus branch");
assertGt(terminalObservedAt, 0, "terminal observation is locally recorded");
vm.warp(terminalObservedAt + 100);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertGt(pool.riskWindowStart(), terminalObservedAt, "replacement active observation starts risk after terminal");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
assertEq(
token.balanceOf(alice) - aliceBefore,
200 * ONE,
"later active observation resurrects full bonus entitlement"
);
}

Later-staker inversion:

function test_F9_reversedRiskChronologyRewardsLaterStaker() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
uint256 terminalObservedAt = pool.riskWindowEnd();
vm.warp(terminalObservedAt + 100);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 startObservedAt = pool.riskWindowStart();
assertGt(startObservedAt, terminalObservedAt, "migration created start after end");
vm.warp(terminalObservedAt + 200);
_stake(bob, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 alicePayout = token.balanceOf(alice) - aliceBefore;
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobPayout = token.balanceOf(bob) - bobBefore;
assertGt(bobPayout, alicePayout, "later staker receives larger payout under reversed chronology");
}

Command run:

forge test --match-path test/unit/KiraMigrationFindings.t.sol --match-test 'test_F9_reversedRiskChronologyRewardsLaterStaker|test_DF_LBP_2_4_1_replacementActiveObservationResurrectsBonusEntitlement' -vv

Exact output:

Missing dependencies found. Installing now...
fatal: destination path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/battlechain-safe-harbor-contracts' already exists and is not an empty directory.
fatal: clone of 'https://github.com/Cyfrin/battlechain-safe-harbor-contracts' into submodule path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/battlechain-safe-harbor-contracts' failed
Failed to clone 'lib/battlechain-safe-harbor-contracts'. Retry scheduled
fatal: destination path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/forge-std' already exists and is not an empty directory.
fatal: clone of 'https://github.com/foundry-rs/forge-std' into submodule path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/forge-std' failed
Failed to clone 'lib/forge-std'. Retry scheduled
fatal: destination path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/openzeppelin-contracts' already exists and is not an empty directory.
fatal: clone of 'https://github.com/OpenZeppelin/openzeppelin-contracts' into submodule path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/openzeppelin-contracts' failed
Failed to clone 'lib/openzeppelin-contracts'. Retry scheduled
fatal: destination path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/openzeppelin-contracts-upgradeable' already exists and is not an empty directory.
fatal: clone of 'https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable' into submodule path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/openzeppelin-contracts-upgradeable' failed
Failed to clone 'lib/openzeppelin-contracts-upgradeable'. Retry scheduled
fatal: destination path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/battlechain-safe-harbor-contracts' already exists and is not an empty directory.
fatal: clone of 'https://github.com/Cyfrin/battlechain-safe-harbor-contracts' into submodule path '/home/nate/Cyfrin/BattleChainCP/Final/2026-07-bc-confidence-pools/lib/battlechain-safe-harbor-contracts' failed
Failed to clone 'lib/battlechain-safe-harbor-contracts' a second time, aborting
Warning: Your project has missing dependencies that could not be installed.
No files changed, compilation skipped
Ran 2 tests for test/unit/KiraMigrationFindings.t.sol:KiraMigrationFindingsTest
[PASS] test_DF_LBP_2_4_1_replacementActiveObservationResurrectsBonusEntitlement() (gas: 531634)
[PASS] test_F9_reversedRiskChronologyRewardsLaterStaker() (gas: 683347)
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.85ms (765.39µs CPU time)
Ran 1 test suite in 11.69ms (1.85ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests)

Recommended Mitigation

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
- if (riskWindowStart == 0 && _isActiveRiskState(state)) {
+ if (riskWindowEnd != 0 && _isActiveRiskState(state)) {
+ revert InvalidRiskWindowChronology();
+ }
+ if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}

Also reject new deposits after any local terminal observation has been recorded, and require registry migration tooling to preserve per-agreement state continuity before activating a new AttackRegistry pointer.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Missing riskWindowEnd rewind-latch in _assertDepositsAllowed lets late stakes/bonus theft via k=2 quadratic scoring

Impact - Medium Bonus pool only, principal always returns under SURVIVED/EXPIRED, but the quadratic advantage is unbounded and a tiny late stake can take nearly all of it. Likelihood - Low It needs a DAO registry migration to land inside the window where a pool is unresolved with riskWindowEnd already sealed, and the attacker still has to be watching. No attacker action can create the precondition.

Support

FAQs

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

Give us feedback!