From 78e32ec688abe4b32de2e90c70bb02377a43be0a Mon Sep 17 00:00:00 2001 From: Jemil Ezzet Date: Fri, 9 Jan 2026 11:09:50 -0800 Subject: [PATCH 1/8] Add minTokensPerBidder, remove maxBid from permit struct --- .claude/commands | 1 + .gitmodules | 3 + .mcp.json | 20 +++ foundry.lock | 3 + src/Permitter.sol | 40 ++--- src/PermitterFactory.sol | 16 +- src/interfaces/IPermitter.sol | 11 +- src/interfaces/IPermitterFactory.sol | 8 +- test/ExploitTests.t.sol | 29 ++-- test/Fuzz.t.sol | 123 ++++++++------ test/Integration.t.sol | 95 +++++------ test/Permitter.t.sol | 201 ++++++++++++++--------- test/PermitterFactory.t.sol | 233 +++++++++++++++++++++++---- 13 files changed, 539 insertions(+), 244 deletions(-) create mode 160000 .claude/commands create mode 100644 .mcp.json diff --git a/.claude/commands b/.claude/commands new file mode 160000 index 0000000..8d9bfb8 --- /dev/null +++ b/.claude/commands @@ -0,0 +1 @@ +Subproject commit 8d9bfb8d1b8c029d9984cada2fa834cece331469 diff --git a/.gitmodules b/.gitmodules index 690924b..997f42b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule ".claude/commands"] + path = .claude/commands + url = https://github.com/withtally/claude-commands diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..25cd367 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,20 @@ +{ + "mcpServers": { + "linear": { + "type": "stdio", + "command": "npx", + "args": [ + "mcp-remote", + "https://mcp.linear.app/sse" + ], + "env": {} + }, + "context7": { + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp@latest" + ] + } + } +} diff --git a/foundry.lock b/foundry.lock index dc035b1..f9d6bb5 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,4 +1,7 @@ { + ".claude/commands": { + "rev": "8d9bfb8d1b8c029d9984cada2fa834cece331469" + }, "lib/forge-std": { "tag": { "name": "v1.11.0", diff --git a/src/Permitter.sol b/src/Permitter.sol index eef6a49..8200036 100644 --- a/src/Permitter.sol +++ b/src/Permitter.sol @@ -12,8 +12,7 @@ import {IPermitter} from "./interfaces/IPermitter.sol"; /// chainId and verifyingContract to prevent cross-chain and cross-auction replay attacks. contract Permitter is IPermitter, EIP712 { /// @notice EIP-712 typehash for the Permit struct. - bytes32 public constant PERMIT_TYPEHASH = - keccak256("Permit(address bidder,uint256 maxBidAmount,uint256 expiry)"); + bytes32 public constant PERMIT_TYPEHASH = keccak256("Permit(address bidder,uint256 expiry)"); /// @notice Timelock delay for parameter updates (1 hour). uint256 public constant UPDATE_DELAY = 1 hours; @@ -27,6 +26,9 @@ contract Permitter is IPermitter, EIP712 { /// @notice Maximum tokens any single bidder can purchase. uint256 public maxTokensPerBidder; + /// @notice Minimum tokens any single bidder must purchase per bid. + uint256 public minTokensPerBidder; + /// @notice Cumulative bid amounts per address. mapping(address bidder => uint256 amount) public cumulativeBids; @@ -70,12 +72,14 @@ contract Permitter is IPermitter, EIP712 { /// @param _trustedSigner Address authorized to sign permits. /// @param _maxTotalEth Maximum total ETH that can be raised. /// @param _maxTokensPerBidder Maximum tokens any single bidder can purchase. + /// @param _minTokensPerBidder Minimum tokens any single bidder must purchase per bid. /// @param _owner Address that can update caps and pause. /// @param _authorizedCaller CCA contract authorized to call validateBid. constructor( address _trustedSigner, uint256 _maxTotalEth, uint256 _maxTokensPerBidder, + uint256 _minTokensPerBidder, address _owner, address _authorizedCaller ) EIP712("Permitter", "1") { @@ -87,6 +91,7 @@ contract Permitter is IPermitter, EIP712 { trustedSigner = _trustedSigner; maxTotalEth = _maxTotalEth; maxTokensPerBidder = _maxTokensPerBidder; + minTokensPerBidder = _minTokensPerBidder; owner = _owner; authorizedCaller = _authorizedCaller; } @@ -104,45 +109,45 @@ contract Permitter is IPermitter, EIP712 { // 1. CHEAPEST: Check if paused if (paused) revert ContractPaused(); - // 2. Decode permit data + // 2. CHEAP: Check minimum bid amount + if (bidAmount < minTokensPerBidder) { + revert BidBelowMinimum(bidAmount, minTokensPerBidder); + } + + // 3. Decode permit data (Permit memory permit, bytes memory signature) = abi.decode(permitData, (Permit, bytes)); - // 3. CHEAP: Check time window + // 4. CHEAP: Check time window if (block.timestamp > permit.expiry) { revert SignatureExpired(permit.expiry, block.timestamp); } - // 4. MODERATE: Verify EIP-712 signature + // 5. MODERATE: Verify EIP-712 signature address recovered = _recoverSigner(permit, signature); if (recovered != trustedSigner) revert InvalidSignature(trustedSigner, recovered); - // 5. Check permit is for this bidder + // 6. Check permit is for this bidder if (permit.bidder != bidder) revert InvalidSignature(bidder, permit.bidder); - // 6. STORAGE READ: Check individual cap + // 7. STORAGE READ: Check individual cap using maxTokensPerBidder uint256 alreadyBid = cumulativeBids[bidder]; uint256 newCumulative = alreadyBid + bidAmount; - if (newCumulative > permit.maxBidAmount) { - revert ExceedsPersonalCap(bidAmount, permit.maxBidAmount, alreadyBid); - } - - // Also check against global maxTokensPerBidder if it's lower if (newCumulative > maxTokensPerBidder) { revert ExceedsPersonalCap(bidAmount, maxTokensPerBidder, alreadyBid); } - // 7. STORAGE READ: Check global cap + // 8. STORAGE READ: Check global cap uint256 alreadyRaised = totalEthRaised; uint256 newTotalEth = alreadyRaised + ethValue; if (newTotalEth > maxTotalEth) revert ExceedsTotalCap(ethValue, maxTotalEth, alreadyRaised); - // 8. STORAGE WRITE: Update state + // 9. STORAGE WRITE: Update state cumulativeBids[bidder] = newCumulative; totalEthRaised = newTotalEth; - // 9. Emit event for monitoring + // 10. Emit event for monitoring emit PermitVerified( - bidder, bidAmount, permit.maxBidAmount - newCumulative, maxTotalEth - newTotalEth + bidder, bidAmount, maxTokensPerBidder - newCumulative, maxTotalEth - newTotalEth ); return true; @@ -279,8 +284,7 @@ contract Permitter is IPermitter, EIP712 { view returns (address) { - bytes32 structHash = - keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.expiry)); bytes32 digest = _hashTypedDataV4(structHash); return ECDSA.recover(digest, signature); } diff --git a/src/PermitterFactory.sol b/src/PermitterFactory.sol index abb94a9..4b3025a 100644 --- a/src/PermitterFactory.sol +++ b/src/PermitterFactory.sol @@ -15,6 +15,7 @@ contract PermitterFactory is IPermitterFactory { address trustedSigner, uint256 maxTotalEth, uint256 maxTokensPerBidder, + uint256 minTokensPerBidder, address owner, address authorizedCaller, bytes32 salt @@ -25,12 +26,18 @@ contract PermitterFactory is IPermitterFactory { // Deploy the Permitter using CREATE2 permitter = address( new Permitter{salt: finalSalt}( - trustedSigner, maxTotalEth, maxTokensPerBidder, owner, authorizedCaller + trustedSigner, maxTotalEth, maxTokensPerBidder, minTokensPerBidder, owner, authorizedCaller ) ); emit PermitterCreated( - permitter, owner, trustedSigner, authorizedCaller, maxTotalEth, maxTokensPerBidder + permitter, + owner, + trustedSigner, + authorizedCaller, + maxTotalEth, + maxTokensPerBidder, + minTokensPerBidder ); } @@ -39,6 +46,7 @@ contract PermitterFactory is IPermitterFactory { address trustedSigner, uint256 maxTotalEth, uint256 maxTokensPerBidder, + uint256 minTokensPerBidder, address owner, address authorizedCaller, bytes32 salt @@ -49,7 +57,9 @@ contract PermitterFactory is IPermitterFactory { // Compute the init code hash bytes memory initCode = abi.encodePacked( type(Permitter).creationCode, - abi.encode(trustedSigner, maxTotalEth, maxTokensPerBidder, owner, authorizedCaller) + abi.encode( + trustedSigner, maxTotalEth, maxTokensPerBidder, minTokensPerBidder, owner, authorizedCaller + ) ); bytes32 initCodeHash = keccak256(initCode); diff --git a/src/interfaces/IPermitter.sol b/src/interfaces/IPermitter.sol index 188adbd..71daba4 100644 --- a/src/interfaces/IPermitter.sol +++ b/src/interfaces/IPermitter.sol @@ -13,11 +13,9 @@ interface IPermitter { /// @notice The permit structure containing bidder authorization data. /// @param bidder Address authorized to bid. - /// @param maxBidAmount Maximum tokens this bidder can purchase (cumulative). /// @param expiry Timestamp when permit expires. struct Permit { address bidder; - uint256 maxBidAmount; uint256 expiry; } @@ -46,6 +44,11 @@ interface IPermitter { /// @param alreadyRaised The amount already raised. error ExceedsTotalCap(uint256 requested, uint256 cap, uint256 alreadyRaised); + /// @notice Emitted when a bid is below the minimum amount. + /// @param bidAmount The bid amount that was attempted. + /// @param minRequired The minimum required bid amount. + error BidBelowMinimum(uint256 bidAmount, uint256 minRequired); + /// @notice Emitted when the caller is not authorized. error Unauthorized(); @@ -194,6 +197,10 @@ interface IPermitter { /// @return The maximum tokens per bidder cap. function maxTokensPerBidder() external view returns (uint256); + /// @notice Get the minimum tokens per bidder. + /// @return The minimum tokens per bidder. + function minTokensPerBidder() external view returns (uint256); + /// @notice Get the owner address. /// @return The owner address. function owner() external view returns (address); diff --git a/src/interfaces/IPermitterFactory.sol b/src/interfaces/IPermitterFactory.sol index b89f511..7a6fb4d 100644 --- a/src/interfaces/IPermitterFactory.sol +++ b/src/interfaces/IPermitterFactory.sol @@ -12,19 +12,22 @@ interface IPermitterFactory { /// @param authorizedCaller The CCA contract authorized to call validateBid. /// @param maxTotalEth The maximum total ETH that can be raised. /// @param maxTokensPerBidder The maximum tokens any single bidder can purchase. + /// @param minTokensPerBidder The minimum tokens any single bidder must purchase per bid. event PermitterCreated( address indexed permitter, address indexed owner, address indexed trustedSigner, address authorizedCaller, uint256 maxTotalEth, - uint256 maxTokensPerBidder + uint256 maxTokensPerBidder, + uint256 minTokensPerBidder ); /// @notice Create a new Permitter instance for an auction. /// @param trustedSigner Address authorized to sign permits (Tally backend). /// @param maxTotalEth Maximum total ETH that can be raised in the auction. /// @param maxTokensPerBidder Maximum tokens any single bidder can purchase. + /// @param minTokensPerBidder Minimum tokens any single bidder must purchase per bid. /// @param owner Address that can update caps and pause (auction creator). /// @param authorizedCaller CCA contract address authorized to call validateBid. /// @param salt Salt for CREATE2 deployment to enable deterministic addresses. @@ -33,6 +36,7 @@ interface IPermitterFactory { address trustedSigner, uint256 maxTotalEth, uint256 maxTokensPerBidder, + uint256 minTokensPerBidder, address owner, address authorizedCaller, bytes32 salt @@ -42,6 +46,7 @@ interface IPermitterFactory { /// @param trustedSigner Address authorized to sign permits. /// @param maxTotalEth Maximum total ETH that can be raised. /// @param maxTokensPerBidder Maximum tokens any single bidder can purchase. + /// @param minTokensPerBidder Minimum tokens any single bidder must purchase per bid. /// @param owner Address that can update caps and pause. /// @param authorizedCaller CCA contract address authorized to call validateBid. /// @param salt Salt for CREATE2 deployment. @@ -50,6 +55,7 @@ interface IPermitterFactory { address trustedSigner, uint256 maxTotalEth, uint256 maxTokensPerBidder, + uint256 minTokensPerBidder, address owner, address authorizedCaller, bytes32 salt diff --git a/test/ExploitTests.t.sol b/test/ExploitTests.t.sol index 9042ba1..224f0cf 100644 --- a/test/ExploitTests.t.sol +++ b/test/ExploitTests.t.sol @@ -19,16 +19,21 @@ contract ExploitTests is Test { uint256 public constant INITIAL_MAX_TOTAL_ETH = 100 ether; uint256 public constant INITIAL_MAX_TOKENS_PER_BIDDER = 1000 ether; + uint256 public constant INITIAL_MIN_TOKENS_PER_BIDDER = 10 ether; - bytes32 public constant PERMIT_TYPEHASH = - keccak256("Permit(address bidder,uint256 maxBidAmount,uint256 expiry)"); + bytes32 public constant PERMIT_TYPEHASH = keccak256("Permit(address bidder,uint256 expiry)"); function setUp() public { signerPrivateKey = 0x1234; trustedSigner = vm.addr(signerPrivateKey); target = new Permitter( - trustedSigner, INITIAL_MAX_TOTAL_ETH, INITIAL_MAX_TOKENS_PER_BIDDER, owner, authorizedCaller + trustedSigner, + INITIAL_MAX_TOTAL_ETH, + INITIAL_MAX_TOKENS_PER_BIDDER, + INITIAL_MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller ); vm.label(address(target), "Permitter"); @@ -37,16 +42,14 @@ contract ExploitTests is Test { vm.label(authorizedCaller, "AuthorizedCaller"); } - function _createPermitSignature(address _bidder, uint256 _maxBidAmount, uint256 _expiry) + function _createPermitSignature(address _bidder, uint256 _expiry) internal view returns (bytes memory permitData) { - IPermitter.Permit memory permit = - IPermitter.Permit({bidder: _bidder, maxBidAmount: _maxBidAmount, expiry: _expiry}); + IPermitter.Permit memory permit = IPermitter.Permit({bidder: _bidder, expiry: _expiry}); - bytes32 structHash = - keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.expiry)); bytes32 domainSeparator = target.domainSeparator(); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); @@ -71,6 +74,7 @@ contract ExploitTests is Test { trustedSigner, 0, // maxTotalEth = 0 - REJECTED INITIAL_MAX_TOKENS_PER_BIDDER, + INITIAL_MIN_TOKENS_PER_BIDDER, owner, authorizedCaller ); @@ -82,6 +86,7 @@ contract ExploitTests is Test { trustedSigner, INITIAL_MAX_TOTAL_ETH, 0, // maxTokensPerBidder = 0 - REJECTED + INITIAL_MIN_TOKENS_PER_BIDDER, owner, authorizedCaller ); @@ -124,7 +129,7 @@ contract ExploitTests is Test { console.log("Fixes: H-01 (High)"); uint256 expiry = block.timestamp + 2 hours; - bytes memory permitData = _createPermitSignature(bidder, 500 ether, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); // First bid succeeds vm.prank(authorizedCaller); @@ -159,7 +164,7 @@ contract ExploitTests is Test { console.log("Fixes: H-02 (High)"); uint256 expiry = block.timestamp + 2 hours; - bytes memory permitData = _createPermitSignature(bidder, 500 ether, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); // Raise 50 ETH vm.prank(authorizedCaller); @@ -201,7 +206,7 @@ contract ExploitTests is Test { console.log("Fixes: M-01 (Medium/High)"); uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, 500 ether, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); address attacker = makeAddr("attacker"); console.log("Attacker (not authorized):", attacker); @@ -229,7 +234,7 @@ contract ExploitTests is Test { console.log("Fixes: M-02 (Medium)"); uint256 expiry = block.timestamp + 2 hours; - bytes memory permitData = _createPermitSignature(bidder, 500 ether, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); console.log("Bidder has valid permit signed by:", trustedSigner); diff --git a/test/Fuzz.t.sol b/test/Fuzz.t.sol index 7f88e7c..c93be4f 100644 --- a/test/Fuzz.t.sol +++ b/test/Fuzz.t.sol @@ -18,29 +18,33 @@ contract FuzzTest is Test { uint256 public constant MAX_TOTAL_ETH = 100 ether; uint256 public constant MAX_TOKENS_PER_BIDDER = 1000 ether; + uint256 public constant MIN_TOKENS_PER_BIDDER = 10 ether; - bytes32 public constant PERMIT_TYPEHASH = - keccak256("Permit(address bidder,uint256 maxBidAmount,uint256 expiry)"); + bytes32 public constant PERMIT_TYPEHASH = keccak256("Permit(address bidder,uint256 expiry)"); function setUp() public { signerPrivateKey = 0x1234; trustedSigner = vm.addr(signerPrivateKey); factory = new PermitterFactory(); - permitter = - new Permitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller); + permitter = new Permitter( + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller + ); } - function _createPermitSignature(address _bidder, uint256 _maxBidAmount, uint256 _expiry) + function _createPermitSignature(address _bidder, uint256 _expiry) internal view returns (bytes memory permitData) { - IPermitter.Permit memory permit = - IPermitter.Permit({bidder: _bidder, maxBidAmount: _maxBidAmount, expiry: _expiry}); + IPermitter.Permit memory permit = IPermitter.Permit({bidder: _bidder, expiry: _expiry}); - bytes32 structHash = - keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.expiry)); bytes32 domainSeparator = permitter.domainSeparator(); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); @@ -51,18 +55,14 @@ contract FuzzTest is Test { permitData = abi.encode(permit, signature); } - function _createPermitSignatureWithKey( - address _bidder, - uint256 _maxBidAmount, - uint256 _expiry, - uint256 _privateKey - ) internal view returns (bytes memory permitData) { - IPermitter.Permit memory permit = IPermitter.Permit({ - bidder: _bidder, maxBidAmount: _maxBidAmount, expiry: _expiry - }); + function _createPermitSignatureWithKey(address _bidder, uint256 _expiry, uint256 _privateKey) + internal + view + returns (bytes memory permitData) + { + IPermitter.Permit memory permit = IPermitter.Permit({bidder: _bidder, expiry: _expiry}); - bytes32 structHash = - keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.expiry)); bytes32 domainSeparator = permitter.domainSeparator(); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); @@ -84,12 +84,11 @@ contract SignatureVerificationFuzz is FuzzTest { bytes memory randomSignature ) public { vm.assume(bidder != address(0)); - vm.assume(bidAmount > 0 && bidAmount <= MAX_TOKENS_PER_BIDDER); + vm.assume(bidAmount >= MIN_TOKENS_PER_BIDDER && bidAmount <= MAX_TOKENS_PER_BIDDER); vm.assume(expiry > block.timestamp); // Create a permit struct without a valid signature - IPermitter.Permit memory permit = - IPermitter.Permit({bidder: bidder, maxBidAmount: MAX_TOKENS_PER_BIDDER, expiry: expiry}); + IPermitter.Permit memory permit = IPermitter.Permit({bidder: bidder, expiry: expiry}); bytes memory permitData = abi.encode(permit, randomSignature); @@ -107,8 +106,7 @@ contract SignatureVerificationFuzz is FuzzTest { vm.assume(vm.addr(wrongSignerKey) != trustedSigner); uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = - _createPermitSignatureWithKey(bidder, MAX_TOKENS_PER_BIDDER, expiry, wrongSignerKey); + bytes memory permitData = _createPermitSignatureWithKey(bidder, expiry, wrongSignerKey); address recoveredSigner = vm.addr(wrongSignerKey); @@ -127,12 +125,12 @@ contract SignatureVerificationFuzz is FuzzTest { uint256 expiryOffset ) public { vm.assume(bidder != address(0)); - vm.assume(bidAmount > 0 && bidAmount <= MAX_TOKENS_PER_BIDDER); - vm.assume(ethValue > 0 && ethValue <= MAX_TOTAL_ETH); - vm.assume(expiryOffset > 0 && expiryOffset < 365 days); + bidAmount = bound(bidAmount, MIN_TOKENS_PER_BIDDER, MAX_TOKENS_PER_BIDDER); + ethValue = bound(ethValue, 1, MAX_TOTAL_ETH); + expiryOffset = bound(expiryOffset, 1, 365 days - 1); uint256 expiry = block.timestamp + expiryOffset; - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); vm.prank(authorizedCaller); bool result = permitter.validateBid(bidder, bidAmount, ethValue, permitData); @@ -145,26 +143,24 @@ contract SignatureVerificationFuzz is FuzzTest { /// @notice Fuzz tests for cap enforcement. contract CapEnforcementFuzz is FuzzTest { - /// @notice Fuzz test that cumulative bids never exceed permit max. - function testFuzz_CumulativeBidsNeverExceedPermitMax( - uint256 permitMax, - uint256 numBids, - uint256 seed - ) public { - permitMax = bound(permitMax, 1, MAX_TOKENS_PER_BIDDER); + /// @notice Fuzz test that cumulative bids never exceed maxTokensPerBidder. + function testFuzz_CumulativeBidsNeverExceedMaxTokensPerBidder(uint256 numBids, uint256 seed) + public + { numBids = bound(numBids, 1, 10); address bidder = makeAddr("fuzzBidder"); uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, permitMax, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); uint256 totalBid = 0; for (uint256 i = 0; i < numBids; i++) { - // Generate deterministic random bid amounts from seed - uint256 bidAmount = bound(uint256(keccak256(abi.encode(seed, i))), 1, permitMax); + // Generate deterministic random bid amounts from seed, at least MIN_TOKENS_PER_BIDDER + uint256 bidAmount = + bound(uint256(keccak256(abi.encode(seed, i))), MIN_TOKENS_PER_BIDDER, MAX_TOKENS_PER_BIDDER); - if (totalBid + bidAmount <= permitMax) { + if (totalBid + bidAmount <= MAX_TOKENS_PER_BIDDER) { // Bid should succeed vm.prank(authorizedCaller); permitter.validateBid(bidder, bidAmount, 0, permitData); @@ -174,7 +170,7 @@ contract CapEnforcementFuzz is FuzzTest { // Bid should fail vm.expectRevert( abi.encodeWithSelector( - IPermitter.ExceedsPersonalCap.selector, bidAmount, permitMax, totalBid + IPermitter.ExceedsPersonalCap.selector, bidAmount, MAX_TOKENS_PER_BIDDER, totalBid ) ); vm.prank(authorizedCaller); @@ -182,8 +178,8 @@ contract CapEnforcementFuzz is FuzzTest { } } - // Invariant: cumulative bids should never exceed permit max - assertLe(permitter.getBidAmount(bidder), permitMax); + // Invariant: cumulative bids should never exceed maxTokensPerBidder + assertLe(permitter.getBidAmount(bidder), MAX_TOKENS_PER_BIDDER); } /// @notice Fuzz test that total ETH raised never exceeds max. @@ -199,7 +195,7 @@ contract CapEnforcementFuzz is FuzzTest { // Generate deterministic random ETH values from seed uint256 ethValue = bound(uint256(keccak256(abi.encode(seed, i))), 1, MAX_TOTAL_ETH); - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); if (totalEth + ethValue <= MAX_TOTAL_ETH) { // Should succeed @@ -232,7 +228,7 @@ contract ExpiryEnforcementFuzz is FuzzTest { vm.assume(timePastExpiry > 0 && timePastExpiry < 365 days); uint256 expiry = block.timestamp; - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); // Warp past expiry vm.warp(expiry + timePastExpiry); @@ -250,7 +246,7 @@ contract ExpiryEnforcementFuzz is FuzzTest { vm.assume(timeBeforeExpiry > 0 && timeBeforeExpiry < 365 days); uint256 expiry = block.timestamp + timeBeforeExpiry; - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); // Warp to just before expiry vm.warp(expiry - 1); @@ -340,6 +336,7 @@ contract FactoryFuzz is FuzzTest { address fuzzedTrustedSigner, uint256 maxTotalEth, uint256 maxTokensPerBidder, + uint256 minTokensPerBidder, address fuzzedOwner, address fuzzedAuthorizedCaller, bytes32 salt @@ -357,6 +354,7 @@ contract FactoryFuzz is FuzzTest { fuzzedTrustedSigner, maxTotalEth, maxTokensPerBidder, + minTokensPerBidder, fuzzedOwner, fuzzedAuthorizedCaller, salt @@ -366,6 +364,7 @@ contract FactoryFuzz is FuzzTest { fuzzedTrustedSigner, maxTotalEth, maxTokensPerBidder, + minTokensPerBidder, fuzzedOwner, fuzzedAuthorizedCaller, salt @@ -385,11 +384,23 @@ contract FactoryFuzz is FuzzTest { vm.startPrank(deployer); address addr1 = factory.predictPermitterAddress( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, salt1 + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + salt1 ); address addr2 = factory.predictPermitterAddress( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, salt2 + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + salt2 ); vm.stopPrank(); @@ -409,12 +420,24 @@ contract FactoryFuzz is FuzzTest { vm.prank(deployer1); address addr1 = factory.predictPermitterAddress( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, salt + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + salt ); vm.prank(deployer2); address addr2 = factory.predictPermitterAddress( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, salt + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + salt ); assertTrue(addr1 != addr2); diff --git a/test/Integration.t.sol b/test/Integration.t.sol index 740789b..37ddf4a 100644 --- a/test/Integration.t.sol +++ b/test/Integration.t.sol @@ -25,11 +25,11 @@ contract IntegrationTest is Test { // Auction configuration uint256 public constant MAX_TOTAL_ETH = 100 ether; uint256 public constant MAX_TOKENS_PER_BIDDER = 1000 ether; + uint256 public constant MIN_TOKENS_PER_BIDDER = 10 ether; bytes32 public constant AUCTION_SALT = bytes32(uint256(1)); // EIP-712 constants - bytes32 public constant PERMIT_TYPEHASH = - keccak256("Permit(address bidder,uint256 maxBidAmount,uint256 expiry)"); + bytes32 public constant PERMIT_TYPEHASH = keccak256("Permit(address bidder,uint256 expiry)"); function setUp() public virtual { // Create a trusted signer with a known private key @@ -45,6 +45,7 @@ contract IntegrationTest is Test { trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, auctionOwner, authorizedCaller, AUCTION_SALT @@ -53,16 +54,14 @@ contract IntegrationTest is Test { } /// @notice Helper function to create a valid permit signature. - function _createPermitSignature(address _bidder, uint256 _maxBidAmount, uint256 _expiry) + function _createPermitSignature(address _bidder, uint256 _expiry) internal view returns (bytes memory permitData) { - IPermitter.Permit memory permit = - IPermitter.Permit({bidder: _bidder, maxBidAmount: _maxBidAmount, expiry: _expiry}); + IPermitter.Permit memory permit = IPermitter.Permit({bidder: _bidder, expiry: _expiry}); - bytes32 structHash = - keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.expiry)); bytes32 domainSeparator = permitter.domainSeparator(); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); @@ -80,9 +79,9 @@ contract FullAuctionLifecycle is IntegrationTest { uint256 expiry = block.timestamp + 24 hours; // Create permits for all bidders - bytes memory permit1 = _createPermitSignature(bidder1, 400 ether, expiry); - bytes memory permit2 = _createPermitSignature(bidder2, 300 ether, expiry); - bytes memory permit3 = _createPermitSignature(bidder3, 500 ether, expiry); + bytes memory permit1 = _createPermitSignature(bidder1, expiry); + bytes memory permit2 = _createPermitSignature(bidder2, expiry); + bytes memory permit3 = _createPermitSignature(bidder3, expiry); // Bidder 1 places multiple bids (via authorized caller) vm.startPrank(authorizedCaller); @@ -122,8 +121,8 @@ contract FullAuctionLifecycle is IntegrationTest { function test_AuctionReachesTotalCap() public { uint256 expiry = block.timestamp + 24 hours; - bytes memory permit1 = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry); - bytes memory permit2 = _createPermitSignature(bidder2, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permit1 = _createPermitSignature(bidder1, expiry); + bytes memory permit2 = _createPermitSignature(bidder2, expiry); vm.startPrank(authorizedCaller); @@ -138,36 +137,35 @@ contract FullAuctionLifecycle is IntegrationTest { assertEq(permitter.getTotalEthRaised(), 100 ether); - // Any more bids should fail + // Any more bids should fail (bid amount must be >= MIN_TOKENS_PER_BIDDER) vm.expectRevert( - abi.encodeWithSelector(IPermitter.ExceedsTotalCap.selector, 1, MAX_TOTAL_ETH, 100 ether) + abi.encodeWithSelector(IPermitter.ExceedsTotalCap.selector, 1 ether, MAX_TOTAL_ETH, 100 ether) ); - permitter.validateBid(bidder2, 1 ether, 1, permit2); + permitter.validateBid(bidder2, 10 ether, 1 ether, permit2); vm.stopPrank(); } function test_BidderReachesPersonalCap() public { uint256 expiry = block.timestamp + 24 hours; - uint256 personalCap = 500 ether; - bytes memory permit = _createPermitSignature(bidder1, personalCap, expiry); + bytes memory permit = _createPermitSignature(bidder1, expiry); vm.startPrank(authorizedCaller); - // Place bids up to the personal cap - permitter.validateBid(bidder1, 200 ether, 2 ether, permit); + // Place bids up to the personal cap (MAX_TOKENS_PER_BIDDER = 1000 ether) + permitter.validateBid(bidder1, 400 ether, 4 ether, permit); + permitter.validateBid(bidder1, 400 ether, 4 ether, permit); permitter.validateBid(bidder1, 200 ether, 2 ether, permit); - permitter.validateBid(bidder1, 100 ether, 1 ether, permit); - assertEq(permitter.getBidAmount(bidder1), 500 ether); + assertEq(permitter.getBidAmount(bidder1), 1000 ether); // Next bid should fail vm.expectRevert( abi.encodeWithSelector( - IPermitter.ExceedsPersonalCap.selector, 1 ether, personalCap, 500 ether + IPermitter.ExceedsPersonalCap.selector, 10 ether, MAX_TOKENS_PER_BIDDER, 1000 ether ) ); - permitter.validateBid(bidder1, 1 ether, 0.01 ether, permit); + permitter.validateBid(bidder1, 10 ether, 0.1 ether, permit); vm.stopPrank(); } } @@ -176,7 +174,7 @@ contract FullAuctionLifecycle is IntegrationTest { contract EmergencyScenarios is IntegrationTest { function test_OwnerPausesAndResumesAuction() public { uint256 expiry = block.timestamp + 24 hours; - bytes memory permit = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permit = _createPermitSignature(bidder1, expiry); // Place a bid successfully vm.prank(authorizedCaller); @@ -206,7 +204,7 @@ contract EmergencyScenarios is IntegrationTest { uint256 expiry = block.timestamp + 2 hours; // Place a bid with the original signer - bytes memory permit = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permit = _createPermitSignature(bidder1, expiry); vm.prank(authorizedCaller); permitter.validateBid(bidder1, 100 ether, 1 ether, permit); @@ -238,16 +236,10 @@ contract EmergencyScenarios is IntegrationTest { // Create a new permit with the new signer IPermitter.Permit memory newPermitStruct = - IPermitter.Permit({bidder: bidder1, maxBidAmount: MAX_TOKENS_PER_BIDDER, expiry: expiry}); - - bytes32 structHash = keccak256( - abi.encode( - PERMIT_TYPEHASH, - newPermitStruct.bidder, - newPermitStruct.maxBidAmount, - newPermitStruct.expiry - ) - ); + IPermitter.Permit({bidder: bidder1, expiry: expiry}); + + bytes32 structHash = + keccak256(abi.encode(PERMIT_TYPEHASH, newPermitStruct.bidder, newPermitStruct.expiry)); bytes32 domainSeparator = permitter.domainSeparator(); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); @@ -265,7 +257,7 @@ contract EmergencyScenarios is IntegrationTest { function test_OwnerAdjustsCapsWithTimelock() public { uint256 expiry = block.timestamp + 2 hours; - bytes memory permit = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permit = _createPermitSignature(bidder1, expiry); // Place initial bids vm.prank(authorizedCaller); @@ -302,7 +294,7 @@ contract EmergencyScenarios is IntegrationTest { function test_OwnerRaisesCapWithTimelock() public { uint256 expiry = block.timestamp + 3 hours; - bytes memory permit = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permit = _createPermitSignature(bidder1, expiry); // Schedule a low initial cap vm.prank(auctionOwner); @@ -345,7 +337,7 @@ contract EmergencyScenarios is IntegrationTest { contract PermitExpiry is IntegrationTest { function test_PermitExpiresAfterTimestamp() public { uint256 expiry = block.timestamp + 1 hours; - bytes memory permit = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permit = _createPermitSignature(bidder1, expiry); // Bid succeeds before expiry vm.prank(authorizedCaller); @@ -364,7 +356,7 @@ contract PermitExpiry is IntegrationTest { function test_BidderCanGetNewPermitAfterExpiry() public { uint256 expiry1 = block.timestamp + 1 hours; - bytes memory permit1 = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry1); + bytes memory permit1 = _createPermitSignature(bidder1, expiry1); // Use first permit vm.prank(authorizedCaller); @@ -382,7 +374,7 @@ contract PermitExpiry is IntegrationTest { // Get a new permit with new expiry uint256 expiry2 = block.timestamp + 24 hours; - bytes memory permit2 = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry2); + bytes memory permit2 = _createPermitSignature(bidder1, expiry2); // New permit works vm.prank(authorizedCaller); @@ -405,10 +397,10 @@ contract MultipleAuctions is IntegrationTest { // Deploy additional permitters for different auctions vm.startPrank(deployer); address permitter2Address = factory.createPermitter( - trustedSigner, 50 ether, 500 ether, auctionOwner, authorizedCaller2, bytes32(uint256(2)) + trustedSigner, 50 ether, 500 ether, 5 ether, auctionOwner, authorizedCaller2, bytes32(uint256(2)) ); address permitter3Address = factory.createPermitter( - trustedSigner, 200 ether, 2000 ether, auctionOwner, authorizedCaller3, bytes32(uint256(3)) + trustedSigner, 200 ether, 2000 ether, 20 ether, auctionOwner, authorizedCaller3, bytes32(uint256(3)) ); vm.stopPrank(); @@ -420,11 +412,9 @@ contract MultipleAuctions is IntegrationTest { uint256 expiry = block.timestamp + 24 hours; // Create permits for each auction (each has its own domain separator) - bytes memory permit1 = _createPermitSignatureForPermitter(bidder1, 400 ether, expiry, permitter); - bytes memory permit2 = - _createPermitSignatureForPermitter(bidder1, 300 ether, expiry, permitter2); - bytes memory permit3 = - _createPermitSignatureForPermitter(bidder1, 1000 ether, expiry, permitter3); + bytes memory permit1 = _createPermitSignatureForPermitter(bidder1, expiry, permitter); + bytes memory permit2 = _createPermitSignatureForPermitter(bidder1, expiry, permitter2); + bytes memory permit3 = _createPermitSignatureForPermitter(bidder1, expiry, permitter3); // Bid in all auctions (each via their respective authorized caller) vm.prank(authorizedCaller); @@ -450,8 +440,7 @@ contract MultipleAuctions is IntegrationTest { uint256 expiry = block.timestamp + 24 hours; // Create permit for auction 1 - bytes memory permit1 = - _createPermitSignatureForPermitter(bidder1, MAX_TOKENS_PER_BIDDER, expiry, permitter); + bytes memory permit1 = _createPermitSignatureForPermitter(bidder1, expiry, permitter); // Try to use it in auction 2 - should fail because domain separator is different vm.expectRevert(); @@ -461,16 +450,12 @@ contract MultipleAuctions is IntegrationTest { function _createPermitSignatureForPermitter( address _bidder, - uint256 _maxBidAmount, uint256 _expiry, Permitter _permitter ) internal view returns (bytes memory permitData) { - IPermitter.Permit memory permit = IPermitter.Permit({ - bidder: _bidder, maxBidAmount: _maxBidAmount, expiry: _expiry - }); + IPermitter.Permit memory permit = IPermitter.Permit({bidder: _bidder, expiry: _expiry}); - bytes32 structHash = - keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.expiry)); bytes32 domainSeparator = _permitter.domainSeparator(); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); diff --git a/test/Permitter.t.sol b/test/Permitter.t.sol index 29eb4cb..1545422 100644 --- a/test/Permitter.t.sol +++ b/test/Permitter.t.sol @@ -20,10 +20,10 @@ contract PermitterTest is Test { // Default configuration uint256 public constant MAX_TOTAL_ETH = 100 ether; uint256 public constant MAX_TOKENS_PER_BIDDER = 1000 ether; + uint256 public constant MIN_TOKENS_PER_BIDDER = 10 ether; // EIP-712 constants - bytes32 public constant PERMIT_TYPEHASH = - keccak256("Permit(address bidder,uint256 maxBidAmount,uint256 expiry)"); + bytes32 public constant PERMIT_TYPEHASH = keccak256("Permit(address bidder,uint256 expiry)"); function setUp() public virtual { // Create a trusted signer with a known private key @@ -31,32 +31,34 @@ contract PermitterTest is Test { trustedSigner = vm.addr(signerPrivateKey); // Deploy the Permitter with authorized caller - permitter = - new Permitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller); + permitter = new Permitter( + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller + ); } /// @notice Helper function to create a valid permit signature. - function _createPermitSignature(address _bidder, uint256 _maxBidAmount, uint256 _expiry) + function _createPermitSignature(address _bidder, uint256 _expiry) internal view returns (bytes memory permitData) { - return _createPermitSignatureWithKey(_bidder, _maxBidAmount, _expiry, signerPrivateKey); + return _createPermitSignatureWithKey(_bidder, _expiry, signerPrivateKey); } /// @notice Helper function to create a permit signature with a specific private key. - function _createPermitSignatureWithKey( - address _bidder, - uint256 _maxBidAmount, - uint256 _expiry, - uint256 _privateKey - ) internal view returns (bytes memory permitData) { - IPermitter.Permit memory permit = IPermitter.Permit({ - bidder: _bidder, maxBidAmount: _maxBidAmount, expiry: _expiry - }); - - bytes32 structHash = - keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); + function _createPermitSignatureWithKey(address _bidder, uint256 _expiry, uint256 _privateKey) + internal + view + returns (bytes memory permitData) + { + IPermitter.Permit memory permit = IPermitter.Permit({bidder: _bidder, expiry: _expiry}); + + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.expiry)); bytes32 domainSeparator = permitter.domainSeparator(); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); @@ -74,6 +76,7 @@ contract Constructor is PermitterTest { assertEq(permitter.trustedSigner(), trustedSigner); assertEq(permitter.maxTotalEth(), MAX_TOTAL_ETH); assertEq(permitter.maxTokensPerBidder(), MAX_TOKENS_PER_BIDDER); + assertEq(permitter.minTokensPerBidder(), MIN_TOKENS_PER_BIDDER); assertEq(permitter.owner(), owner); assertEq(permitter.paused(), false); assertEq(permitter.totalEthRaised(), 0); @@ -82,22 +85,39 @@ contract Constructor is PermitterTest { function test_RevertIf_TrustedSignerIsZero() public { vm.expectRevert(IPermitter.InvalidTrustedSigner.selector); - new Permitter(address(0), MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller); + new Permitter( + address(0), MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, MIN_TOKENS_PER_BIDDER, owner, authorizedCaller + ); } function test_RevertIf_OwnerIsZero() public { vm.expectRevert(IPermitter.InvalidOwner.selector); - new Permitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, address(0), authorizedCaller); + new Permitter( + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + address(0), + authorizedCaller + ); } function test_RevertIf_MaxTotalEthIsZero() public { vm.expectRevert(IPermitter.InvalidCap.selector); - new Permitter(trustedSigner, 0, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller); + new Permitter( + trustedSigner, 0, MAX_TOKENS_PER_BIDDER, MIN_TOKENS_PER_BIDDER, owner, authorizedCaller + ); } function test_RevertIf_MaxTokensPerBidderIsZero() public { vm.expectRevert(IPermitter.InvalidCap.selector); - new Permitter(trustedSigner, MAX_TOTAL_ETH, 0, owner, authorizedCaller); + new Permitter(trustedSigner, MAX_TOTAL_ETH, 0, MIN_TOKENS_PER_BIDDER, owner, authorizedCaller); + } + + function test_AllowsZeroMinTokensPerBidder() public { + // minTokensPerBidder can be 0 (no minimum requirement) + Permitter p = new Permitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, 0, owner, authorizedCaller); + assertEq(p.minTokensPerBidder(), 0); } } @@ -108,7 +128,7 @@ contract ValidateBidSuccess is PermitterTest { uint256 ethValue = 1 ether; uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); vm.prank(authorizedCaller); bool result = permitter.validateBid(bidder, bidAmount, ethValue, permitData); @@ -120,7 +140,7 @@ contract ValidateBidSuccess is PermitterTest { function test_MultipleBidsFromSameBidder() public { uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); // First bid vm.prank(authorizedCaller); @@ -142,8 +162,8 @@ contract ValidateBidSuccess is PermitterTest { function test_DifferentBiddersCanBid() public { uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData1 = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); - bytes memory permitData2 = _createPermitSignature(otherBidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData1 = _createPermitSignature(bidder, expiry); + bytes memory permitData2 = _createPermitSignature(otherBidder, expiry); vm.prank(authorizedCaller); permitter.validateBid(bidder, 100 ether, 1 ether, permitData1); @@ -159,7 +179,7 @@ contract ValidateBidSuccess is PermitterTest { uint256 bidAmount = 100 ether; uint256 ethValue = 1 ether; uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); vm.expectEmit(true, false, false, true); emit IPermitter.PermitVerified( @@ -175,7 +195,7 @@ contract ValidateBidSuccess is PermitterTest { contract ValidateBidRevert is PermitterTest { function test_RevertIf_CallerNotAuthorized() public { uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); // Try to call from non-authorized address vm.expectRevert(IPermitter.UnauthorizedCaller.selector); @@ -184,7 +204,7 @@ contract ValidateBidRevert is PermitterTest { function test_RevertIf_Paused() public { uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); vm.prank(owner); permitter.pause(); @@ -194,9 +214,22 @@ contract ValidateBidRevert is PermitterTest { permitter.validateBid(bidder, 100 ether, 1 ether, permitData); } + function test_RevertIf_BidBelowMinimum() public { + uint256 expiry = block.timestamp + 1 hours; + bytes memory permitData = _createPermitSignature(bidder, expiry); + + // Try to bid less than minimum (MIN_TOKENS_PER_BIDDER = 10 ether) + uint256 lowBid = 5 ether; + vm.expectRevert( + abi.encodeWithSelector(IPermitter.BidBelowMinimum.selector, lowBid, MIN_TOKENS_PER_BIDDER) + ); + vm.prank(authorizedCaller); + permitter.validateBid(bidder, lowBid, 0.05 ether, permitData); + } + function test_RevertIf_SignatureExpired() public { uint256 expiry = block.timestamp - 1; // Already expired - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); vm.expectRevert( abi.encodeWithSelector(IPermitter.SignatureExpired.selector, expiry, block.timestamp) @@ -210,8 +243,7 @@ contract ValidateBidRevert is PermitterTest { address wrongSigner = vm.addr(wrongSignerKey); uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = - _createPermitSignatureWithKey(bidder, MAX_TOKENS_PER_BIDDER, expiry, wrongSignerKey); + bytes memory permitData = _createPermitSignatureWithKey(bidder, expiry, wrongSignerKey); vm.expectRevert( abi.encodeWithSelector(IPermitter.InvalidSignature.selector, trustedSigner, wrongSigner) @@ -223,7 +255,7 @@ contract ValidateBidRevert is PermitterTest { function test_RevertIf_BidderMismatch() public { uint256 expiry = block.timestamp + 1 hours; // Create permit for otherBidder but try to use it for bidder - bytes memory permitData = _createPermitSignature(otherBidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(otherBidder, expiry); vm.expectRevert( abi.encodeWithSelector(IPermitter.InvalidSignature.selector, bidder, otherBidder) @@ -232,43 +264,20 @@ contract ValidateBidRevert is PermitterTest { permitter.validateBid(bidder, 100 ether, 1 ether, permitData); } - function test_RevertIf_ExceedsPermitMaxBidAmount() public { - uint256 permitMax = 500 ether; - uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, permitMax, expiry); - - // First bid succeeds - vm.prank(authorizedCaller); - permitter.validateBid(bidder, 400 ether, 4 ether, permitData); - - // Second bid exceeds permit max - vm.expectRevert( - abi.encodeWithSelector( - IPermitter.ExceedsPersonalCap.selector, 200 ether, permitMax, 400 ether - ) - ); - vm.prank(authorizedCaller); - permitter.validateBid(bidder, 200 ether, 2 ether, permitData); - } - - function test_RevertIf_ExceedsGlobalMaxTokensPerBidder() public { - // Create a permit with a maxBidAmount higher than global maxTokensPerBidder - // This tests the check at line 130-131 in Permitter.sol - uint256 permitMax = MAX_TOKENS_PER_BIDDER + 500 ether; // Higher than global cap + function test_RevertIf_ExceedsMaxTokensPerBidder() public { uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, permitMax, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); - // First bid that brings us close to the global cap + // First bid that brings us close to the cap vm.prank(authorizedCaller); permitter.validateBid(bidder, 900 ether, 9 ether, permitData); - // Second bid that exceeds global maxTokensPerBidder (1000 ether) but not permit max - // This should revert with ExceedsPersonalCap using maxTokensPerBidder as the cap + // Second bid that exceeds maxTokensPerBidder (1000 ether) vm.expectRevert( abi.encodeWithSelector( IPermitter.ExceedsPersonalCap.selector, 200 ether, // requested - MAX_TOKENS_PER_BIDDER, // cap (global, not permit) + MAX_TOKENS_PER_BIDDER, // cap 900 ether // already bid ) ); @@ -278,7 +287,7 @@ contract ValidateBidRevert is PermitterTest { function test_RevertIf_ExceedsTotalEthCap() public { uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); // Bid that brings us close to the cap vm.prank(authorizedCaller); @@ -289,7 +298,56 @@ contract ValidateBidRevert is PermitterTest { abi.encodeWithSelector(IPermitter.ExceedsTotalCap.selector, 2 ether, MAX_TOTAL_ETH, 99 ether) ); vm.prank(authorizedCaller); - permitter.validateBid(bidder, 10 ether, 2 ether, permitData); + permitter.validateBid(bidder, 100 ether, 2 ether, permitData); + } +} + +/// @notice Tests for minimum bid enforcement. +contract MinTokensPerBidderTests is PermitterTest { + function test_BidAtExactMinimumSucceeds() public { + uint256 expiry = block.timestamp + 1 hours; + bytes memory permitData = _createPermitSignature(bidder, expiry); + + vm.prank(authorizedCaller); + bool result = permitter.validateBid(bidder, MIN_TOKENS_PER_BIDDER, 0.1 ether, permitData); + + assertTrue(result); + assertEq(permitter.getBidAmount(bidder), MIN_TOKENS_PER_BIDDER); + } + + function test_BidAboveMinimumSucceeds() public { + uint256 expiry = block.timestamp + 1 hours; + bytes memory permitData = _createPermitSignature(bidder, expiry); + + uint256 aboveMinBid = MIN_TOKENS_PER_BIDDER + 1 ether; + vm.prank(authorizedCaller); + bool result = permitter.validateBid(bidder, aboveMinBid, 0.11 ether, permitData); + + assertTrue(result); + assertEq(permitter.getBidAmount(bidder), aboveMinBid); + } + + function test_ZeroMinTokensPerBidderAllowsAnyBid() public { + // Deploy a new permitter with zero minimum + Permitter zeroMinPermitter = new Permitter( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, 0, owner, authorizedCaller + ); + + uint256 expiry = block.timestamp + 1 hours; + IPermitter.Permit memory permit = IPermitter.Permit({bidder: bidder, expiry: expiry}); + bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.expiry)); + bytes32 domainSeparator = zeroMinPermitter.domainSeparator(); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + bytes memory permitData = abi.encode(permit, signature); + + // Even a 1 wei bid should work + vm.prank(authorizedCaller); + bool result = zeroMinPermitter.validateBid(bidder, 1, 1, permitData); + + assertTrue(result); + assertEq(zeroMinPermitter.getBidAmount(bidder), 1); } } @@ -361,7 +419,7 @@ contract TimelockCapUpdates is PermitterTest { function test_RevertIf_NewCapBelowTotalRaised() public { // First, raise some ETH uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); vm.prank(authorizedCaller); permitter.validateBid(bidder, 100 ether, 50 ether, permitData); @@ -477,7 +535,7 @@ contract TimelockSignerUpdates is PermitterTest { uint256 expiry = block.timestamp + 2 hours; // Create a permit with the old signer - bytes memory oldPermitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory oldPermitData = _createPermitSignature(bidder, expiry); // Schedule rotation to new signer uint256 newSignerKey = 0x5678; @@ -513,8 +571,7 @@ contract TimelockSignerUpdates is PermitterTest { // Create a permit with the new signer uint256 expiry = block.timestamp + 1 hours; - bytes memory newPermitData = - _createPermitSignatureWithKey(bidder, MAX_TOKENS_PER_BIDDER, expiry, newSignerKey); + bytes memory newPermitData = _createPermitSignatureWithKey(bidder, expiry, newSignerKey); // New permit should work vm.prank(authorizedCaller); @@ -550,7 +607,7 @@ contract AuthorizedCallerTests is PermitterTest { permitter.updateAuthorizedCaller(newCaller); uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); // New caller can validate vm.prank(newCaller); @@ -569,7 +626,7 @@ contract AuthorizedCallerTests is PermitterTest { permitter.updateAuthorizedCaller(address(0)); uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); // Any caller will fail vm.expectRevert(IPermitter.UnauthorizedCaller.selector); @@ -623,7 +680,7 @@ contract PauseTests is PermitterTest { contract ViewFunctions is PermitterTest { function test_GetBidAmount() public { uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData = _createPermitSignature(bidder, expiry); assertEq(permitter.getBidAmount(bidder), 0); @@ -638,8 +695,8 @@ contract ViewFunctions is PermitterTest { function test_GetTotalEthRaised() public { uint256 expiry = block.timestamp + 1 hours; - bytes memory permitData1 = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); - bytes memory permitData2 = _createPermitSignature(otherBidder, MAX_TOKENS_PER_BIDDER, expiry); + bytes memory permitData1 = _createPermitSignature(bidder, expiry); + bytes memory permitData2 = _createPermitSignature(otherBidder, expiry); assertEq(permitter.getTotalEthRaised(), 0); diff --git a/test/PermitterFactory.t.sol b/test/PermitterFactory.t.sol index f8f043d..4e823a7 100644 --- a/test/PermitterFactory.t.sol +++ b/test/PermitterFactory.t.sol @@ -20,6 +20,7 @@ contract PermitterFactoryTest is Test { // Default configuration uint256 public constant MAX_TOTAL_ETH = 100 ether; uint256 public constant MAX_TOKENS_PER_BIDDER = 1000 ether; + uint256 public constant MIN_TOKENS_PER_BIDDER = 10 ether; bytes32 public constant DEFAULT_SALT = bytes32(uint256(1)); function setUp() public virtual { @@ -32,7 +33,13 @@ contract CreatePermitter is PermitterFactoryTest { function test_DeploysPermitterWithCorrectParameters() public { vm.prank(deployer); address permitterAddress = factory.createPermitter( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); Permitter permitter = Permitter(permitterAddress); @@ -40,6 +47,7 @@ contract CreatePermitter is PermitterFactoryTest { assertEq(permitter.trustedSigner(), trustedSigner); assertEq(permitter.maxTotalEth(), MAX_TOTAL_ETH); assertEq(permitter.maxTokensPerBidder(), MAX_TOKENS_PER_BIDDER); + assertEq(permitter.minTokensPerBidder(), MIN_TOKENS_PER_BIDDER); assertEq(permitter.owner(), owner); assertEq(permitter.authorizedCaller(), authorizedCaller); assertEq(permitter.paused(), false); @@ -58,11 +66,18 @@ contract CreatePermitter is PermitterFactoryTest { trustedSigner, authorizedCaller, MAX_TOTAL_ETH, - MAX_TOKENS_PER_BIDDER + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER ); factory.createPermitter( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); } @@ -71,12 +86,24 @@ contract CreatePermitter is PermitterFactoryTest { vm.prank(deployer); address permitter1 = factory.createPermitter( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); vm.prank(deployer2); address permitter2 = factory.createPermitter( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); assertTrue(permitter1 != permitter2); @@ -88,10 +115,22 @@ contract CreatePermitter is PermitterFactoryTest { vm.startPrank(deployer); address permitter1 = factory.createPermitter( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, salt1 + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + salt1 ); address permitter2 = factory.createPermitter( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, salt2 + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + salt2 ); vm.stopPrank(); @@ -102,7 +141,13 @@ contract CreatePermitter is PermitterFactoryTest { vm.expectRevert(IPermitter.InvalidTrustedSigner.selector); vm.prank(deployer); factory.createPermitter( - address(0), MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + address(0), + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); } @@ -113,6 +158,7 @@ contract CreatePermitter is PermitterFactoryTest { trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, address(0), authorizedCaller, DEFAULT_SALT @@ -123,14 +169,28 @@ contract CreatePermitter is PermitterFactoryTest { vm.expectRevert(IPermitter.InvalidCap.selector); vm.prank(deployer); factory.createPermitter( - trustedSigner, 0, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + 0, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); } function test_RevertIf_MaxTokensPerBidderIsZero() public { vm.expectRevert(IPermitter.InvalidCap.selector); vm.prank(deployer); - factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, 0, owner, authorizedCaller, DEFAULT_SALT); + factory.createPermitter( + trustedSigner, + MAX_TOTAL_ETH, + 0, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT + ); } } @@ -140,11 +200,23 @@ contract PredictPermitterAddress is PermitterFactoryTest { vm.startPrank(deployer); address predicted = factory.predictPermitterAddress( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); address actual = factory.createPermitter( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); vm.stopPrank(); @@ -156,15 +228,33 @@ contract PredictPermitterAddress is PermitterFactoryTest { vm.startPrank(deployer); address addr1 = factory.predictPermitterAddress( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); address addr2 = factory.predictPermitterAddress( - trustedSigner, MAX_TOTAL_ETH + 1, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH + 1, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); address addr3 = factory.predictPermitterAddress( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER + 1, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER + 1, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); address differentOwner = makeAddr("differentOwner"); @@ -172,6 +262,7 @@ contract PredictPermitterAddress is PermitterFactoryTest { trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, differentOwner, authorizedCaller, DEFAULT_SALT @@ -179,12 +270,34 @@ contract PredictPermitterAddress is PermitterFactoryTest { address differentSigner = makeAddr("differentSigner"); address addr5 = factory.predictPermitterAddress( - differentSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + differentSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); address differentCaller = makeAddr("differentCaller"); address addr6 = factory.predictPermitterAddress( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, differentCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + differentCaller, + DEFAULT_SALT + ); + + address addr7 = factory.predictPermitterAddress( + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER + 1, + owner, + authorizedCaller, + DEFAULT_SALT ); vm.stopPrank(); @@ -195,6 +308,7 @@ contract PredictPermitterAddress is PermitterFactoryTest { assertTrue(addr1 != addr4); assertTrue(addr1 != addr5); assertTrue(addr1 != addr6); + assertTrue(addr1 != addr7); assertTrue(addr2 != addr3); assertTrue(addr2 != addr4); assertTrue(addr2 != addr5); @@ -207,11 +321,23 @@ contract PredictPermitterAddress is PermitterFactoryTest { vm.startPrank(deployer); address addr1 = factory.predictPermitterAddress( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); address addr2 = factory.predictPermitterAddress( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); vm.stopPrank(); @@ -224,12 +350,24 @@ contract PredictPermitterAddress is PermitterFactoryTest { vm.prank(deployer); address addr1 = factory.predictPermitterAddress( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); vm.prank(deployer2); address addr2 = factory.predictPermitterAddress( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + DEFAULT_SALT ); assertTrue(addr1 != addr2); @@ -245,12 +383,15 @@ contract MultipleDeployments is PermitterFactoryTest { vm.startPrank(deployer); - address permitter1 = - factory.createPermitter(trustedSigner, 50 ether, 500 ether, owner, authorizedCaller, salt1); - address permitter2 = - factory.createPermitter(trustedSigner, 100 ether, 1000 ether, owner, authorizedCaller, salt2); - address permitter3 = - factory.createPermitter(trustedSigner, 200 ether, 2000 ether, owner, authorizedCaller, salt3); + address permitter1 = factory.createPermitter( + trustedSigner, 50 ether, 500 ether, 5 ether, owner, authorizedCaller, salt1 + ); + address permitter2 = factory.createPermitter( + trustedSigner, 100 ether, 1000 ether, 10 ether, owner, authorizedCaller, salt2 + ); + address permitter3 = factory.createPermitter( + trustedSigner, 200 ether, 2000 ether, 20 ether, owner, authorizedCaller, salt3 + ); vm.stopPrank(); @@ -267,6 +408,10 @@ contract MultipleDeployments is PermitterFactoryTest { assertEq(Permitter(permitter1).maxTokensPerBidder(), 500 ether); assertEq(Permitter(permitter2).maxTokensPerBidder(), 1000 ether); assertEq(Permitter(permitter3).maxTokensPerBidder(), 2000 ether); + + assertEq(Permitter(permitter1).minTokensPerBidder(), 5 ether); + assertEq(Permitter(permitter2).minTokensPerBidder(), 10 ether); + assertEq(Permitter(permitter3).minTokensPerBidder(), 20 ether); } function test_DeployPermittersWithDifferentOwners() public { @@ -279,6 +424,7 @@ contract MultipleDeployments is PermitterFactoryTest { trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, owner1, authorizedCaller, bytes32(uint256(1)) @@ -287,6 +433,7 @@ contract MultipleDeployments is PermitterFactoryTest { trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, owner2, authorizedCaller, bytes32(uint256(2)) @@ -314,10 +461,22 @@ contract MultipleDeployments is PermitterFactoryTest { vm.startPrank(deployer); address permitter1 = factory.createPermitter( - signer1, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, bytes32(uint256(1)) + signer1, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + bytes32(uint256(1)) ); address permitter2 = factory.createPermitter( - signer2, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, authorizedCaller, bytes32(uint256(2)) + signer2, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller, + bytes32(uint256(2)) ); vm.stopPrank(); @@ -333,10 +492,22 @@ contract MultipleDeployments is PermitterFactoryTest { vm.startPrank(deployer); address permitter1 = factory.createPermitter( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, caller1, bytes32(uint256(1)) + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + caller1, + bytes32(uint256(1)) ); address permitter2 = factory.createPermitter( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, caller2, bytes32(uint256(2)) + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + caller2, + bytes32(uint256(2)) ); vm.stopPrank(); From 34160b386292c456db2857c14bf12bfe6cda180e Mon Sep 17 00:00:00 2001 From: Jemil Ezzet Date: Fri, 9 Jan 2026 11:15:17 -0800 Subject: [PATCH 2/8] Revert if min is larger than max --- src/Permitter.sol | 3 +++ src/interfaces/IPermitter.sol | 5 +++++ test/Permitter.t.sol | 17 +++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/src/Permitter.sol b/src/Permitter.sol index 8200036..0814647 100644 --- a/src/Permitter.sol +++ b/src/Permitter.sol @@ -87,6 +87,9 @@ contract Permitter is IPermitter, EIP712 { if (_owner == address(0)) revert InvalidOwner(); if (_maxTotalEth == 0) revert InvalidCap(); if (_maxTokensPerBidder == 0) revert InvalidCap(); + if (_minTokensPerBidder > _maxTokensPerBidder) { + revert MinTokensExceedsMaxTokens(_minTokensPerBidder, _maxTokensPerBidder); + } trustedSigner = _trustedSigner; maxTotalEth = _maxTotalEth; diff --git a/src/interfaces/IPermitter.sol b/src/interfaces/IPermitter.sol index 71daba4..dc31a04 100644 --- a/src/interfaces/IPermitter.sol +++ b/src/interfaces/IPermitter.sol @@ -61,6 +61,11 @@ interface IPermitter { /// @notice Emitted when a cap value is invalid (zero). error InvalidCap(); + /// @notice Emitted when minTokensPerBidder exceeds maxTokensPerBidder. + /// @param minTokens The minimum tokens per bidder. + /// @param maxTokens The maximum tokens per bidder. + error MinTokensExceedsMaxTokens(uint256 minTokens, uint256 maxTokens); + /// @notice Emitted when proposed cap is below current amount. /// @param proposed The proposed new cap. /// @param current The current amount that would exceed the cap. diff --git a/test/Permitter.t.sol b/test/Permitter.t.sol index 1545422..f0a2dbc 100644 --- a/test/Permitter.t.sol +++ b/test/Permitter.t.sol @@ -119,6 +119,23 @@ contract Constructor is PermitterTest { Permitter p = new Permitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, 0, owner, authorizedCaller); assertEq(p.minTokensPerBidder(), 0); } + + function test_RevertIf_MinTokensExceedsMaxTokens() public { + uint256 minTokens = 2000 ether; + uint256 maxTokens = 1000 ether; + vm.expectRevert( + abi.encodeWithSelector(IPermitter.MinTokensExceedsMaxTokens.selector, minTokens, maxTokens) + ); + new Permitter(trustedSigner, MAX_TOTAL_ETH, maxTokens, minTokens, owner, authorizedCaller); + } + + function test_AllowsMinTokensEqualToMaxTokens() public { + uint256 equalTokens = 500 ether; + Permitter p = + new Permitter(trustedSigner, MAX_TOTAL_ETH, equalTokens, equalTokens, owner, authorizedCaller); + assertEq(p.minTokensPerBidder(), equalTokens); + assertEq(p.maxTokensPerBidder(), equalTokens); + } } /// @notice Tests for validateBid with valid permits. From 1828f181d12c368d99e797315c4e9e229abf9113 Mon Sep 17 00:00:00 2001 From: Jemil Ezzet Date: Mon, 12 Jan 2026 09:39:37 -0800 Subject: [PATCH 3/8] Fix fuzz test --- test/Fuzz.t.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Fuzz.t.sol b/test/Fuzz.t.sol index c93be4f..1991b36 100644 --- a/test/Fuzz.t.sol +++ b/test/Fuzz.t.sol @@ -347,6 +347,7 @@ contract FactoryFuzz is FuzzTest { // Bound to valid non-zero caps maxTotalEth = bound(maxTotalEth, 1, type(uint256).max); maxTokensPerBidder = bound(maxTokensPerBidder, 1, type(uint256).max); + minTokensPerBidder = bound(minTokensPerBidder, 0, maxTokensPerBidder); vm.startPrank(deployer); From 537b97c64cee36d0bb77b151f6016903a456706d Mon Sep 17 00:00:00 2001 From: Jemil Ezzet Date: Mon, 12 Jan 2026 09:40:32 -0800 Subject: [PATCH 4/8] Lint --- src/Permitter.sol | 8 ++------ test/Fuzz.t.sol | 4 +--- test/Integration.t.sol | 19 +++++++++++++++---- test/Permitter.t.sol | 15 ++++++++++----- test/PermitterFactory.t.sol | 8 +------- 5 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/Permitter.sol b/src/Permitter.sol index 0814647..4a6a947 100644 --- a/src/Permitter.sol +++ b/src/Permitter.sol @@ -113,17 +113,13 @@ contract Permitter is IPermitter, EIP712 { if (paused) revert ContractPaused(); // 2. CHEAP: Check minimum bid amount - if (bidAmount < minTokensPerBidder) { - revert BidBelowMinimum(bidAmount, minTokensPerBidder); - } + if (bidAmount < minTokensPerBidder) revert BidBelowMinimum(bidAmount, minTokensPerBidder); // 3. Decode permit data (Permit memory permit, bytes memory signature) = abi.decode(permitData, (Permit, bytes)); // 4. CHEAP: Check time window - if (block.timestamp > permit.expiry) { - revert SignatureExpired(permit.expiry, block.timestamp); - } + if (block.timestamp > permit.expiry) revert SignatureExpired(permit.expiry, block.timestamp); // 5. MODERATE: Verify EIP-712 signature address recovered = _recoverSigner(permit, signature); diff --git a/test/Fuzz.t.sol b/test/Fuzz.t.sol index 1991b36..a36cce0 100644 --- a/test/Fuzz.t.sol +++ b/test/Fuzz.t.sol @@ -270,9 +270,7 @@ contract OwnerFunctionsFuzz is FuzzTest { } /// @notice Fuzz test that non-owners cannot schedule per-bidder cap updates. - function testFuzz_NonOwnerCannotScheduleMaxTokensPerBidder(address caller, uint256 newCap) - public - { + function testFuzz_NonOwnerCannotScheduleMaxTokensPerBidder(address caller, uint256 newCap) public { vm.assume(caller != owner); vm.assume(newCap > 0); // Must be > 0 for valid cap diff --git a/test/Integration.t.sol b/test/Integration.t.sol index 37ddf4a..2457f0e 100644 --- a/test/Integration.t.sol +++ b/test/Integration.t.sol @@ -235,8 +235,7 @@ contract EmergencyScenarios is IntegrationTest { permitter.validateBid(bidder1, 100 ether, 1 ether, permit); // Create a new permit with the new signer - IPermitter.Permit memory newPermitStruct = - IPermitter.Permit({bidder: bidder1, expiry: expiry}); + IPermitter.Permit memory newPermitStruct = IPermitter.Permit({bidder: bidder1, expiry: expiry}); bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, newPermitStruct.bidder, newPermitStruct.expiry)); @@ -397,10 +396,22 @@ contract MultipleAuctions is IntegrationTest { // Deploy additional permitters for different auctions vm.startPrank(deployer); address permitter2Address = factory.createPermitter( - trustedSigner, 50 ether, 500 ether, 5 ether, auctionOwner, authorizedCaller2, bytes32(uint256(2)) + trustedSigner, + 50 ether, + 500 ether, + 5 ether, + auctionOwner, + authorizedCaller2, + bytes32(uint256(2)) ); address permitter3Address = factory.createPermitter( - trustedSigner, 200 ether, 2000 ether, 20 ether, auctionOwner, authorizedCaller3, bytes32(uint256(3)) + trustedSigner, + 200 ether, + 2000 ether, + 20 ether, + auctionOwner, + authorizedCaller3, + bytes32(uint256(3)) ); vm.stopPrank(); diff --git a/test/Permitter.t.sol b/test/Permitter.t.sol index f0a2dbc..c657e00 100644 --- a/test/Permitter.t.sol +++ b/test/Permitter.t.sol @@ -86,7 +86,12 @@ contract Constructor is PermitterTest { function test_RevertIf_TrustedSignerIsZero() public { vm.expectRevert(IPermitter.InvalidTrustedSigner.selector); new Permitter( - address(0), MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, MIN_TOKENS_PER_BIDDER, owner, authorizedCaller + address(0), + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER, + owner, + authorizedCaller ); } @@ -116,7 +121,8 @@ contract Constructor is PermitterTest { function test_AllowsZeroMinTokensPerBidder() public { // minTokensPerBidder can be 0 (no minimum requirement) - Permitter p = new Permitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, 0, owner, authorizedCaller); + Permitter p = + new Permitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, 0, owner, authorizedCaller); assertEq(p.minTokensPerBidder(), 0); } @@ -346,9 +352,8 @@ contract MinTokensPerBidderTests is PermitterTest { function test_ZeroMinTokensPerBidderAllowsAnyBid() public { // Deploy a new permitter with zero minimum - Permitter zeroMinPermitter = new Permitter( - trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, 0, owner, authorizedCaller - ); + Permitter zeroMinPermitter = + new Permitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, 0, owner, authorizedCaller); uint256 expiry = block.timestamp + 1 hours; IPermitter.Permit memory permit = IPermitter.Permit({bidder: bidder, expiry: expiry}); diff --git a/test/PermitterFactory.t.sol b/test/PermitterFactory.t.sol index 4e823a7..2aaf21e 100644 --- a/test/PermitterFactory.t.sol +++ b/test/PermitterFactory.t.sol @@ -183,13 +183,7 @@ contract CreatePermitter is PermitterFactoryTest { vm.expectRevert(IPermitter.InvalidCap.selector); vm.prank(deployer); factory.createPermitter( - trustedSigner, - MAX_TOTAL_ETH, - 0, - MIN_TOKENS_PER_BIDDER, - owner, - authorizedCaller, - DEFAULT_SALT + trustedSigner, MAX_TOTAL_ETH, 0, MIN_TOKENS_PER_BIDDER, owner, authorizedCaller, DEFAULT_SALT ); } } From 901063a6c8eaba9fa1bcf96937376d5a15e793bd Mon Sep 17 00:00:00 2001 From: Jemil Ezzet Date: Mon, 12 Jan 2026 09:48:37 -0800 Subject: [PATCH 5/8] Add Makefile --- Makefile | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ece23a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +.PHONY: install build test fmt lint coverage clean + +# Install dependencies and tools +install: + forge install + cargo install scopelint + +# Build contracts +build: + forge build + +# Run tests +test: + forge test + +# Format code +fmt: + scopelint fmt + +# Check linting (formatting + validation) +lint: + scopelint check + +# Run coverage +coverage: + forge coverage --report summary --report lcov + +# Clean build artifacts +clean: + forge clean From e799ba11b5a0a1ec1d113808d3e03c0c09ed6c5f Mon Sep 17 00:00:00 2001 From: Jemil Ezzet Date: Mon, 12 Jan 2026 11:51:43 -0800 Subject: [PATCH 6/8] Relint --- test/Fuzz.t.sol | 9 ++++++--- test/Permitter.t.sol | 15 +++++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/test/Fuzz.t.sol b/test/Fuzz.t.sol index a36cce0..aa260f7 100644 --- a/test/Fuzz.t.sol +++ b/test/Fuzz.t.sol @@ -157,8 +157,9 @@ contract CapEnforcementFuzz is FuzzTest { for (uint256 i = 0; i < numBids; i++) { // Generate deterministic random bid amounts from seed, at least MIN_TOKENS_PER_BIDDER - uint256 bidAmount = - bound(uint256(keccak256(abi.encode(seed, i))), MIN_TOKENS_PER_BIDDER, MAX_TOKENS_PER_BIDDER); + uint256 bidAmount = bound( + uint256(keccak256(abi.encode(seed, i))), MIN_TOKENS_PER_BIDDER, MAX_TOKENS_PER_BIDDER + ); if (totalBid + bidAmount <= MAX_TOKENS_PER_BIDDER) { // Bid should succeed @@ -270,7 +271,9 @@ contract OwnerFunctionsFuzz is FuzzTest { } /// @notice Fuzz test that non-owners cannot schedule per-bidder cap updates. - function testFuzz_NonOwnerCannotScheduleMaxTokensPerBidder(address caller, uint256 newCap) public { + function testFuzz_NonOwnerCannotScheduleMaxTokensPerBidder(address caller, uint256 newCap) + public + { vm.assume(caller != owner); vm.assume(newCap > 0); // Must be > 0 for valid cap diff --git a/test/Permitter.t.sol b/test/Permitter.t.sol index c657e00..103e029 100644 --- a/test/Permitter.t.sol +++ b/test/Permitter.t.sol @@ -121,8 +121,9 @@ contract Constructor is PermitterTest { function test_AllowsZeroMinTokensPerBidder() public { // minTokensPerBidder can be 0 (no minimum requirement) - Permitter p = - new Permitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, 0, owner, authorizedCaller); + Permitter p = new Permitter( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, 0, owner, authorizedCaller + ); assertEq(p.minTokensPerBidder(), 0); } @@ -137,8 +138,9 @@ contract Constructor is PermitterTest { function test_AllowsMinTokensEqualToMaxTokens() public { uint256 equalTokens = 500 ether; - Permitter p = - new Permitter(trustedSigner, MAX_TOTAL_ETH, equalTokens, equalTokens, owner, authorizedCaller); + Permitter p = new Permitter( + trustedSigner, MAX_TOTAL_ETH, equalTokens, equalTokens, owner, authorizedCaller + ); assertEq(p.minTokensPerBidder(), equalTokens); assertEq(p.maxTokensPerBidder(), equalTokens); } @@ -352,8 +354,9 @@ contract MinTokensPerBidderTests is PermitterTest { function test_ZeroMinTokensPerBidderAllowsAnyBid() public { // Deploy a new permitter with zero minimum - Permitter zeroMinPermitter = - new Permitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, 0, owner, authorizedCaller); + Permitter zeroMinPermitter = new Permitter( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, 0, owner, authorizedCaller + ); uint256 expiry = block.timestamp + 1 hours; IPermitter.Permit memory permit = IPermitter.Permit({bidder: bidder, expiry: expiry}); From 00d0f07523c72e155b87116e5a291c12da59f5f2 Mon Sep 17 00:00:00 2001 From: Jemil Ezzet Date: Mon, 12 Jan 2026 12:02:53 -0800 Subject: [PATCH 7/8] Add ability for owner to update min tokens --- src/Permitter.sol | 43 +++++++++- src/interfaces/IPermitter.sol | 20 ++++- test/Permitter.t.sol | 147 +++++++++++++++++++++++++++++++++- 3 files changed, 205 insertions(+), 5 deletions(-) diff --git a/src/Permitter.sol b/src/Permitter.sol index 4a6a947..86fd0ba 100644 --- a/src/Permitter.sol +++ b/src/Permitter.sol @@ -56,6 +56,12 @@ contract Permitter is IPermitter, EIP712 { /// @notice Time when pending maxTokensPerBidder update can be executed. uint256 public pendingMaxTokensPerBidderTime; + /// @notice Pending minTokensPerBidder update value. + uint256 public pendingMinTokensPerBidder; + + /// @notice Time when pending minTokensPerBidder update can be executed. + uint256 public pendingMinTokensPerBidderTime; + /// @notice Pending trustedSigner update address. address public pendingTrustedSigner; @@ -191,7 +197,7 @@ contract Permitter is IPermitter, EIP712 { pendingMaxTokensPerBidder = newMaxTokensPerBidder; pendingMaxTokensPerBidderTime = executeTime; - emit CapUpdateScheduled(CapType.TOKENS_PER_BIDDER, newMaxTokensPerBidder, executeTime); + emit CapUpdateScheduled(CapType.MAX_TOKENS_PER_BIDDER, newMaxTokensPerBidder, executeTime); } /// @inheritdoc IPermitter @@ -208,7 +214,40 @@ contract Permitter is IPermitter, EIP712 { pendingMaxTokensPerBidder = 0; pendingMaxTokensPerBidderTime = 0; - emit CapUpdated(CapType.TOKENS_PER_BIDDER, oldCap, maxTokensPerBidder); + emit CapUpdated(CapType.MAX_TOKENS_PER_BIDDER, oldCap, maxTokensPerBidder); + } + + /// @inheritdoc IPermitter + function scheduleUpdateMinTokensPerBidder(uint256 newMinTokensPerBidder) external onlyOwner { + if (newMinTokensPerBidder > maxTokensPerBidder) { + revert MinTokensExceedsMaxTokens(newMinTokensPerBidder, maxTokensPerBidder); + } + + uint256 executeTime = block.timestamp + UPDATE_DELAY; + pendingMinTokensPerBidder = newMinTokensPerBidder; + pendingMinTokensPerBidderTime = executeTime; + + emit CapUpdateScheduled(CapType.MIN_TOKENS_PER_BIDDER, newMinTokensPerBidder, executeTime); + } + + /// @inheritdoc IPermitter + function executeUpdateMinTokensPerBidder() external onlyOwner { + if (pendingMinTokensPerBidderTime == 0) revert UpdateNotScheduled(); + if (block.timestamp < pendingMinTokensPerBidderTime) { + revert UpdateTooEarly(pendingMinTokensPerBidderTime, block.timestamp); + } + if (pendingMinTokensPerBidder > maxTokensPerBidder) { + revert MinTokensExceedsMaxTokens(pendingMinTokensPerBidder, maxTokensPerBidder); + } + + uint256 oldMin = minTokensPerBidder; + minTokensPerBidder = pendingMinTokensPerBidder; + + // Clear pending update + pendingMinTokensPerBidder = 0; + pendingMinTokensPerBidderTime = 0; + + emit CapUpdated(CapType.MIN_TOKENS_PER_BIDDER, oldMin, minTokensPerBidder); } /// @inheritdoc IPermitter diff --git a/src/interfaces/IPermitter.sol b/src/interfaces/IPermitter.sol index dc31a04..f5110db 100644 --- a/src/interfaces/IPermitter.sol +++ b/src/interfaces/IPermitter.sol @@ -8,7 +8,8 @@ interface IPermitter { /// @notice Enum for cap types used in events. enum CapType { TOTAL_ETH, - TOKENS_PER_BIDDER + MAX_TOKENS_PER_BIDDER, + MIN_TOKENS_PER_BIDDER } /// @notice The permit structure containing bidder authorization data. @@ -161,6 +162,15 @@ interface IPermitter { /// @dev Reverts if no update is scheduled or delay hasn't passed. function executeUpdateMaxTokensPerBidder() external; + /// @notice Schedule an update to the minimum tokens per bidder (owner only). + /// @dev Update will be executable after UPDATE_DELAY has passed. + /// @param newMinTokensPerBidder New minimum tokens per bidder. + function scheduleUpdateMinTokensPerBidder(uint256 newMinTokensPerBidder) external; + + /// @notice Execute a scheduled update to the minimum tokens per bidder (owner only). + /// @dev Reverts if no update is scheduled or delay hasn't passed. + function executeUpdateMinTokensPerBidder() external; + /// @notice Schedule an update to the trusted signer address (owner only). /// @dev Update will be executable after UPDATE_DELAY has passed. /// @param newSigner New trusted signer address. @@ -238,6 +248,14 @@ interface IPermitter { /// @return The timestamp. function pendingMaxTokensPerBidderTime() external view returns (uint256); + /// @notice Get the pending min tokens per bidder update. + /// @return The pending value. + function pendingMinTokensPerBidder() external view returns (uint256); + + /// @notice Get the time when pending min tokens per bidder update can be executed. + /// @return The timestamp. + function pendingMinTokensPerBidderTime() external view returns (uint256); + /// @notice Get the pending trusted signer update. /// @return The pending address. function pendingTrustedSigner() external view returns (address); diff --git a/test/Permitter.t.sol b/test/Permitter.t.sol index 103e029..1c92c75 100644 --- a/test/Permitter.t.sol +++ b/test/Permitter.t.sol @@ -471,7 +471,7 @@ contract TimelockCapUpdates is PermitterTest { vm.expectEmit(true, false, false, true); emit IPermitter.CapUpdateScheduled( - IPermitter.CapType.TOKENS_PER_BIDDER, newCap, expectedExecuteTime + IPermitter.CapType.MAX_TOKENS_PER_BIDDER, newCap, expectedExecuteTime ); vm.prank(owner); @@ -490,7 +490,7 @@ contract TimelockCapUpdates is PermitterTest { vm.warp(block.timestamp + permitter.UPDATE_DELAY()); vm.expectEmit(true, false, false, true); - emit IPermitter.CapUpdated(IPermitter.CapType.TOKENS_PER_BIDDER, MAX_TOKENS_PER_BIDDER, newCap); + emit IPermitter.CapUpdated(IPermitter.CapType.MAX_TOKENS_PER_BIDDER, MAX_TOKENS_PER_BIDDER, newCap); vm.prank(owner); permitter.executeUpdateMaxTokensPerBidder(); @@ -499,6 +499,149 @@ contract TimelockCapUpdates is PermitterTest { } } +/// @notice Tests for timelock-based min tokens per bidder updates. +contract TimelockMinTokensUpdates is PermitterTest { + function test_ScheduleUpdateMinTokensPerBidder() public { + uint256 newMin = 5 ether; + uint256 expectedExecuteTime = block.timestamp + permitter.UPDATE_DELAY(); + + vm.expectEmit(true, false, false, true); + emit IPermitter.CapUpdateScheduled( + IPermitter.CapType.MIN_TOKENS_PER_BIDDER, newMin, expectedExecuteTime + ); + + vm.prank(owner); + permitter.scheduleUpdateMinTokensPerBidder(newMin); + + assertEq(permitter.pendingMinTokensPerBidder(), newMin); + assertEq(permitter.pendingMinTokensPerBidderTime(), expectedExecuteTime); + // Original min unchanged + assertEq(permitter.minTokensPerBidder(), MIN_TOKENS_PER_BIDDER); + } + + function test_RevertIf_ScheduleUpdateMinTokensPerBidderExceedsMax() public { + uint256 newMin = MAX_TOKENS_PER_BIDDER + 1; + + vm.expectRevert( + abi.encodeWithSelector( + IPermitter.MinTokensExceedsMaxTokens.selector, newMin, MAX_TOKENS_PER_BIDDER + ) + ); + vm.prank(owner); + permitter.scheduleUpdateMinTokensPerBidder(newMin); + } + + function test_RevertIf_ExecuteUpdateMinTokensPerBidderTooEarly() public { + uint256 newMin = 5 ether; + + vm.prank(owner); + permitter.scheduleUpdateMinTokensPerBidder(newMin); + + // Try to execute immediately + uint256 scheduledTime = permitter.pendingMinTokensPerBidderTime(); + vm.expectRevert( + abi.encodeWithSelector(IPermitter.UpdateTooEarly.selector, scheduledTime, block.timestamp) + ); + vm.prank(owner); + permitter.executeUpdateMinTokensPerBidder(); + } + + function test_RevertIf_ExecuteUpdateMinTokensPerBidderNotScheduled() public { + vm.expectRevert(IPermitter.UpdateNotScheduled.selector); + vm.prank(owner); + permitter.executeUpdateMinTokensPerBidder(); + } + + function test_ExecuteUpdateMinTokensPerBidder_AfterDelay() public { + uint256 newMin = 5 ether; + + vm.prank(owner); + permitter.scheduleUpdateMinTokensPerBidder(newMin); + + // Fast forward past the delay + vm.warp(block.timestamp + permitter.UPDATE_DELAY()); + + vm.expectEmit(true, false, false, true); + emit IPermitter.CapUpdated(IPermitter.CapType.MIN_TOKENS_PER_BIDDER, MIN_TOKENS_PER_BIDDER, newMin); + + vm.prank(owner); + permitter.executeUpdateMinTokensPerBidder(); + + assertEq(permitter.minTokensPerBidder(), newMin); + assertEq(permitter.pendingMinTokensPerBidder(), 0); + assertEq(permitter.pendingMinTokensPerBidderTime(), 0); + } + + function test_AllowsSettingMinTokensToZero() public { + uint256 newMin = 0; + + vm.prank(owner); + permitter.scheduleUpdateMinTokensPerBidder(newMin); + + vm.warp(block.timestamp + permitter.UPDATE_DELAY()); + + vm.prank(owner); + permitter.executeUpdateMinTokensPerBidder(); + + assertEq(permitter.minTokensPerBidder(), 0); + } + + function test_AllowsSettingMinTokensEqualToMax() public { + uint256 newMin = MAX_TOKENS_PER_BIDDER; + + vm.prank(owner); + permitter.scheduleUpdateMinTokensPerBidder(newMin); + + vm.warp(block.timestamp + permitter.UPDATE_DELAY()); + + vm.prank(owner); + permitter.executeUpdateMinTokensPerBidder(); + + assertEq(permitter.minTokensPerBidder(), MAX_TOKENS_PER_BIDDER); + } + + function test_RevertIf_ExecuteMinTokensExceedsMaxAfterMaxReduced() public { + // Schedule a min tokens update that's valid now + uint256 newMin = 500 ether; + vm.prank(owner); + permitter.scheduleUpdateMinTokensPerBidder(newMin); + + // Schedule and execute a max tokens reduction + uint256 newMax = 400 ether; + vm.prank(owner); + permitter.scheduleUpdateMaxTokensPerBidder(newMax); + + vm.warp(block.timestamp + permitter.UPDATE_DELAY()); + + vm.prank(owner); + permitter.executeUpdateMaxTokensPerBidder(); + + // Now try to execute the min update - should fail because min > new max + vm.expectRevert( + abi.encodeWithSelector(IPermitter.MinTokensExceedsMaxTokens.selector, newMin, newMax) + ); + vm.prank(owner); + permitter.executeUpdateMinTokensPerBidder(); + } + + function test_RevertIf_ScheduleMinTokensByNonOwner() public { + vm.expectRevert(IPermitter.Unauthorized.selector); + vm.prank(bidder); + permitter.scheduleUpdateMinTokensPerBidder(5 ether); + } + + function test_RevertIf_ExecuteMinTokensByNonOwner() public { + vm.prank(owner); + permitter.scheduleUpdateMinTokensPerBidder(5 ether); + + vm.warp(block.timestamp + permitter.UPDATE_DELAY()); + + vm.expectRevert(IPermitter.Unauthorized.selector); + vm.prank(bidder); + permitter.executeUpdateMinTokensPerBidder(); + } +} + /// @notice Tests for timelock-based signer updates. contract TimelockSignerUpdates is PermitterTest { function test_ScheduleUpdateTrustedSigner() public { From a72cddcc83ad185a0303180345c1ce1b2a387bc6 Mon Sep 17 00:00:00 2001 From: Jemil Ezzet Date: Mon, 12 Jan 2026 12:04:45 -0800 Subject: [PATCH 8/8] Lint --- test/Permitter.t.sol | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/Permitter.t.sol b/test/Permitter.t.sol index 1c92c75..6c5ed78 100644 --- a/test/Permitter.t.sol +++ b/test/Permitter.t.sol @@ -490,7 +490,9 @@ contract TimelockCapUpdates is PermitterTest { vm.warp(block.timestamp + permitter.UPDATE_DELAY()); vm.expectEmit(true, false, false, true); - emit IPermitter.CapUpdated(IPermitter.CapType.MAX_TOKENS_PER_BIDDER, MAX_TOKENS_PER_BIDDER, newCap); + emit IPermitter.CapUpdated( + IPermitter.CapType.MAX_TOKENS_PER_BIDDER, MAX_TOKENS_PER_BIDDER, newCap + ); vm.prank(owner); permitter.executeUpdateMaxTokensPerBidder(); @@ -562,7 +564,9 @@ contract TimelockMinTokensUpdates is PermitterTest { vm.warp(block.timestamp + permitter.UPDATE_DELAY()); vm.expectEmit(true, false, false, true); - emit IPermitter.CapUpdated(IPermitter.CapType.MIN_TOKENS_PER_BIDDER, MIN_TOKENS_PER_BIDDER, newMin); + emit IPermitter.CapUpdated( + IPermitter.CapType.MIN_TOKENS_PER_BIDDER, MIN_TOKENS_PER_BIDDER, newMin + ); vm.prank(owner); permitter.executeUpdateMinTokensPerBidder();