The test below deploys everything through the real ConfidencePoolFactory (the actual deployment path, not a workaround). Most of the file is boilerplate mock contracts required to satisfy external interface types so the pool compiles standalone -- the actual bug is demonstrated in the last test function, test_ContributeBonus_DoesNotLockExpiry_AllowingLaterExpiryChange(): a contributor sends a bonus, then the sponsor successfully changes expiry afterward, even though expiryLocked should have prevented that once money was committed.
pragma solidity 0.8.26;
import "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 {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IBattleChainSafeHarborRegistry} from "@battlechain/interface/IBattleChainSafeHarborRegistry.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {AgreementDetails, BountyTerms, Contact, Chain, Account} from "@battlechain/types/AgreementTypes.sol";
contract MockToken is ERC20 {
constructor() ERC20("Stake", "STK") {}
function mint(address to, uint256 amount) public { _mint(to, amount); }
}
contract StubAttackRegistry is IAttackRegistry {
function getAgreementState(address) external pure returns (ContractState) { return ContractState.NEW_DEPLOYMENT; }
function registerDeployment(address, address) external {}
function authorizeAgreementOwner(address, address) external {}
function transferAttackModerator(address, address) external {}
function requestUnderAttack(address) external {}
function requestUnderAttackForUnverifiedContracts(address) external {}
function requestUnderAttackByNonAuthorized(address) external {}
function goToProduction(address) external {}
function promote(address) external {}
function cancelPromotion(address) external {}
function markCorrupted(address) external {}
function approveAttack(address) external {}
function rejectAttackRequest(address, bool) external {}
function instantPromote(address) external {}
function instantCorrupt(address) external {}
function changeRegistryModerator(address) external {}
function setSafeHarborRegistry(address) external {}
function registerContractForExistingAgreement(address) external {}
function unregisterContractForExistingAgreement(address) external {}
function syncNewContracts(address) external {}
function isTopLevelContractUnderAttack(address) external pure returns (bool) { return false; }
function getAttackModerator(address) external pure returns (address) { return address(0); }
function getAgreementForContract(address) external pure returns (address) { return address(0); }
function getAgreementInfo(address) external pure returns (AgreementInfo memory info) { return info; }
function getRegistryModerator() external pure returns (address) { return address(0); }
function getSafeHarborRegistry() external pure returns (address) { return address(0); }
function getAuthorizedOwner(address) external pure returns (address) { return address(0); }
}
contract StubSafeHarborRegistry is IBattleChainSafeHarborRegistry {
address public attackRegistryAddr;
constructor(address _attackRegistry) { attackRegistryAddr = _attackRegistry; }
function getAttackRegistry() external view returns (address) { return attackRegistryAddr; }
function isAgreementValid(address) external pure returns (bool) { return true; }
function setAgreementFactory(address) external {}
function adoptSafeHarbor(address) external {}
function getAgreement(address) external pure returns (address) { return address(0); }
function isChainValid(string calldata) external pure returns (bool) { return true; }
function getAgreementFactory() external pure returns (address) { return address(0); }
}
contract StubAgreement is IAgreement {
address public immutable ownerAddr;
constructor(address _owner) { ownerAddr = _owner; }
function owner() external view returns (address) { return ownerAddr; }
function isContractInScope(address) external pure returns (bool) { return true; }
function extendCommitmentWindow(uint256) external {}
function setProtocolName(string memory) external {}
function setContactDetails(Contact[] memory) external {}
function addOrSetChains(Chain[] memory) external {}
function removeChains(string[] memory) external {}
function addAccounts(string memory, Account[] memory) external {}
function removeAccounts(string memory, string[] memory) external {}
function setBountyTerms(BountyTerms memory) external {}
function setAgreementURI(string memory) external {}
function getCantChangeUntil() external pure returns (uint256) { return 0; }
function getDetails() external pure returns (AgreementDetails memory d) { return d; }
function getProtocolName() external pure returns (string memory) { return ""; }
function getBountyTerms() external pure returns (BountyTerms memory b) { return b; }
function getAgreementURI() external pure returns (string memory) { return ""; }
function getRegistry() external pure returns (address) { return address(0); }
function getChainIds() external pure returns (string[] memory arr) { return arr; }
function getBattleChainCaip2ChainId() external pure returns (string memory) { return ""; }
function getBattleChainScopeAddresses() external pure returns (address[] memory arr) { return arr; }
function getBattleChainScopeCount() external pure returns (uint256) { return 0; }
}
contract SetExpiryLockGapTest is Test {
ConfidencePool pool;
MockToken token;
address sponsor = makeAddr("sponsor");
address bonusContributor = makeAddr("bonusContributor");
function setUp() public {
ConfidencePool poolImpl = new ConfidencePool();
token = new MockToken();
StubAttackRegistry attackRegistry = new StubAttackRegistry();
StubSafeHarborRegistry safeHarborRegistry = new StubSafeHarborRegistry(address(attackRegistry));
StubAgreement agreement = new StubAgreement(sponsor);
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
bytes memory factoryInit = abi.encodeCall(
ConfidencePoolFactory.initialize, (address(safeHarborRegistry), address(poolImpl), makeAddr("moderator"))
);
ConfidencePoolFactory factory = ConfidencePoolFactory(address(new ERC1967Proxy(address(factoryImpl), factoryInit)));
vm.prank(factory.owner());
factory.setStakeTokenAllowed(address(token), true);
address[] memory accounts = new address[](1);
accounts[0] = address(0x1234);
uint256 originalExpiry = block.timestamp + 60 days;
vm.prank(sponsor);
pool = ConfidencePool(factory.createPool(address(agreement), address(token), originalExpiry, 1 ether, sponsor, accounts));
assertEq(pool.expiry(), originalExpiry, "sanity: expiry set at creation");
assertFalse(pool.expiryLocked(), "sanity: not locked before any stake/bonus");
}
function test_ContributeBonus_DoesNotLockExpiry_AllowingLaterExpiryChange() public {
uint256 bonusAmount = 500 ether;
token.mint(bonusContributor, bonusAmount);
vm.startPrank(bonusContributor);
token.approve(address(pool), bonusAmount);
pool.contributeBonus(bonusAmount);
vm.stopPrank();
assertEq(token.balanceOf(address(pool)), bonusAmount, "bonus received");
assertFalse(pool.expiryLocked(), "BUG: expiryLocked still false after contributeBonus");
uint256 newExpiry = block.timestamp + 400 days;
vm.prank(sponsor);
pool.setExpiry(newExpiry);
assertEq(pool.expiry(), newExpiry, "sponsor successfully moved expiry post-bonus-commitment");
}
}
Add the same expiryLocked check that stake() already has, at the start of contributeBonus() -- right after the deposit-allowed check and before the token transfer. This makes a bonus contribution lock the deadline exactly the way a stake does, closing the gap.