From d9ed202f01915b10609662a8a2c739e90b510cb6 Mon Sep 17 00:00:00 2001 From: Raf Solari Date: Wed, 31 Dec 2025 14:55:13 -0500 Subject: [PATCH 1/3] Implement Permitter hook contract for KYC-based token auction restrictions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds EIP-712 signed permits for enforcing KYC approvals and bid caps on Uniswap CCA auctions. Includes PermitterFactory for CREATE2 deployments, comprehensive validation logic, and 69 tests covering units, integration, and fuzz scenarios. All tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Haiku 4.5 --- .gitmodules | 3 + foundry.lock | 6 + foundry.toml | 3 + lib/openzeppelin-contracts | 1 + script/Deploy.s.sol | 10 +- src/Counter.sol | 14 - src/Permitter.sol | 182 ++++++++++++ src/PermitterFactory.sol | 55 ++++ src/interfaces/IPermitter.sol | 144 +++++++++ src/interfaces/IPermitterFactory.sol | 51 ++++ test/Counter.t.sol | 30 -- test/Fuzz.t.sol | 362 +++++++++++++++++++++++ test/Integration.t.sol | 399 +++++++++++++++++++++++++ test/Permitter.t.sol | 420 +++++++++++++++++++++++++++ test/PermitterFactory.t.sol | 268 +++++++++++++++++ 15 files changed, 1901 insertions(+), 47 deletions(-) create mode 160000 lib/openzeppelin-contracts delete mode 100644 src/Counter.sol create mode 100644 src/Permitter.sol create mode 100644 src/PermitterFactory.sol create mode 100644 src/interfaces/IPermitter.sol create mode 100644 src/interfaces/IPermitterFactory.sol delete mode 100644 test/Counter.t.sol create mode 100644 test/Fuzz.t.sol create mode 100644 test/Integration.t.sol create mode 100644 test/Permitter.t.sol create mode 100644 test/PermitterFactory.t.sol diff --git a/.gitmodules b/.gitmodules index 888d42d..690924b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/foundry.lock b/foundry.lock index fee8a95..dc035b1 100644 --- a/foundry.lock +++ b/foundry.lock @@ -4,5 +4,11 @@ "name": "v1.11.0", "rev": "8e40513d678f392f398620b3ef2b418648b33e89" } + }, + "lib/openzeppelin-contracts": { + "tag": { + "name": "v5.5.0", + "rev": "fcbae5394ae8ad52d8e580a3477db99814b9d565" + } } } \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 86b1a95..d26d5b4 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,6 +4,9 @@ optimizer_runs = 10_000_000 solc_version = "0.8.30" verbosity = 3 + remappings = [ + "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/" + ] [profile.ci] fuzz = { runs = 5000 } diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..fcbae53 --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit fcbae5394ae8ad52d8e580a3477db99814b9d565 diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index f009f18..998825f 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -4,11 +4,15 @@ pragma solidity 0.8.30; import {Script} from "forge-std/Script.sol"; -import {Counter} from "src/Counter.sol"; +import {PermitterFactory} from "src/PermitterFactory.sol"; +/// @notice Deployment script for the PermitterFactory contract. +/// @dev The factory is deployed with CREATE2 to ensure the same address across all chains. contract Deploy is Script { - function run() public returns (Counter _counter) { + /// @notice Deploys the PermitterFactory contract. + /// @return factory The deployed PermitterFactory contract. + function run() public returns (PermitterFactory factory) { vm.broadcast(); - _counter = new Counter(); + factory = new PermitterFactory(); } } diff --git a/src/Counter.sol b/src/Counter.sol deleted file mode 100644 index 5a58b55..0000000 --- a/src/Counter.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.30; - -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} diff --git a/src/Permitter.sol b/src/Permitter.sol new file mode 100644 index 0000000..725b7b0 --- /dev/null +++ b/src/Permitter.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {IPermitter} from "./interfaces/IPermitter.sol"; + +/// @title Permitter +/// @notice Implements Uniswap CCA ValidationHook interface for bid validation using EIP-712 signed +/// permits. Enforces KYC-based permissions and caps on token sales. +/// @dev Uses EIP-712 signatures for gasless permit verification. The domain separator includes +/// 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)"); + + /// @notice Address authorized to sign permits. + address public trustedSigner; + + /// @notice Maximum total ETH that can be raised. + uint256 public maxTotalEth; + + /// @notice Maximum tokens any single bidder can purchase. + uint256 public maxTokensPerBidder; + + /// @notice Cumulative bid amounts per address. + mapping(address bidder => uint256 amount) public cumulativeBids; + + /// @notice Total ETH raised across all bidders. + uint256 public totalEthRaised; + + /// @notice Owner address that can update caps and pause. + address public owner; + + /// @notice Whether the contract is paused. + bool public paused; + + /// @notice Modifier to restrict access to owner only. + modifier onlyOwner() { + if (msg.sender != owner) revert Unauthorized(); + _; + } + + /// @notice Creates a new Permitter instance. + /// @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 _owner Address that can update caps and pause. + constructor(address _trustedSigner, uint256 _maxTotalEth, uint256 _maxTokensPerBidder, address _owner) + EIP712("Permitter", "1") + { + if (_trustedSigner == address(0)) revert InvalidTrustedSigner(); + if (_owner == address(0)) revert InvalidOwner(); + + trustedSigner = _trustedSigner; + maxTotalEth = _maxTotalEth; + maxTokensPerBidder = _maxTokensPerBidder; + owner = _owner; + } + + /// @inheritdoc IPermitter + function validateBid( + address bidder, + uint256 bidAmount, + uint256 ethValue, + bytes calldata permitData + ) external returns (bool valid) { + // 1. CHEAPEST: Check if paused + if (paused) revert ContractPaused(); + + // 2. Decode permit data + (Permit memory permit, bytes memory signature) = abi.decode(permitData, (Permit, bytes)); + + // 3. CHEAP: Check time window + if (block.timestamp > permit.expiry) { + revert SignatureExpired(permit.expiry, block.timestamp); + } + + // 4. MODERATE: Verify EIP-712 signature + address recovered = _recoverSigner(permit, signature); + if (recovered != trustedSigner) { + revert InvalidSignature(trustedSigner, recovered); + } + + // 5. Check permit is for this bidder + if (permit.bidder != bidder) { + revert InvalidSignature(bidder, permit.bidder); + } + + // 6. STORAGE READ: Check individual cap + 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 + uint256 alreadyRaised = totalEthRaised; + uint256 newTotalEth = alreadyRaised + ethValue; + if (newTotalEth > maxTotalEth) { + revert ExceedsTotalCap(ethValue, maxTotalEth, alreadyRaised); + } + + // 8. STORAGE WRITE: Update state + cumulativeBids[bidder] = newCumulative; + totalEthRaised = newTotalEth; + + // 9. Emit event for monitoring + emit PermitVerified( + bidder, bidAmount, permit.maxBidAmount - newCumulative, maxTotalEth - newTotalEth + ); + + return true; + } + + /// @inheritdoc IPermitter + function updateMaxTotalEth(uint256 newMaxTotalEth) external onlyOwner { + uint256 oldCap = maxTotalEth; + maxTotalEth = newMaxTotalEth; + emit CapUpdated(CapType.TOTAL_ETH, oldCap, newMaxTotalEth); + } + + /// @inheritdoc IPermitter + function updateMaxTokensPerBidder(uint256 newMaxTokensPerBidder) external onlyOwner { + uint256 oldCap = maxTokensPerBidder; + maxTokensPerBidder = newMaxTokensPerBidder; + emit CapUpdated(CapType.TOKENS_PER_BIDDER, oldCap, newMaxTokensPerBidder); + } + + /// @inheritdoc IPermitter + function updateTrustedSigner(address newSigner) external onlyOwner { + if (newSigner == address(0)) revert InvalidTrustedSigner(); + address oldSigner = trustedSigner; + trustedSigner = newSigner; + emit SignerUpdated(oldSigner, newSigner); + } + + /// @inheritdoc IPermitter + function pause() external onlyOwner { + paused = true; + emit Paused(msg.sender); + } + + /// @inheritdoc IPermitter + function unpause() external onlyOwner { + paused = false; + emit Unpaused(msg.sender); + } + + /// @inheritdoc IPermitter + function getBidAmount(address bidder) external view returns (uint256 cumulativeBid) { + return cumulativeBids[bidder]; + } + + /// @inheritdoc IPermitter + function getTotalEthRaised() external view returns (uint256) { + return totalEthRaised; + } + + /// @notice Get the EIP-712 domain separator. + /// @return The domain separator hash. + function domainSeparator() external view returns (bytes32) { + return _domainSeparatorV4(); + } + + /// @notice Recover the signer address from a permit and signature. + /// @param permit The permit struct. + /// @param signature The EIP-712 signature. + /// @return The recovered signer address. + function _recoverSigner(Permit memory permit, bytes memory signature) internal view returns (address) { + bytes32 structHash = + keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); + bytes32 digest = _hashTypedDataV4(structHash); + return ECDSA.recover(digest, signature); + } +} diff --git a/src/PermitterFactory.sol b/src/PermitterFactory.sol new file mode 100644 index 0000000..9cdcf12 --- /dev/null +++ b/src/PermitterFactory.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {IPermitterFactory} from "./interfaces/IPermitterFactory.sol"; +import {Permitter} from "./Permitter.sol"; + +/// @title PermitterFactory +/// @notice Factory contract for deploying isolated Permitter instances for each auction using +/// CREATE2 for deterministic addresses. +/// @dev Deploy this factory with CREATE2 using the same salt on all chains to get the same factory +/// address across networks. +contract PermitterFactory is IPermitterFactory { + /// @inheritdoc IPermitterFactory + function createPermitter( + address trustedSigner, + uint256 maxTotalEth, + uint256 maxTokensPerBidder, + address owner, + bytes32 salt + ) external returns (address permitter) { + // Compute the final salt using the sender address to prevent front-running + bytes32 finalSalt = keccak256(abi.encodePacked(msg.sender, salt)); + + // Deploy the Permitter using CREATE2 + permitter = address( + new Permitter{salt: finalSalt}(trustedSigner, maxTotalEth, maxTokensPerBidder, owner) + ); + + emit PermitterCreated(permitter, owner, trustedSigner, maxTotalEth, maxTokensPerBidder); + } + + /// @inheritdoc IPermitterFactory + function predictPermitterAddress( + address trustedSigner, + uint256 maxTotalEth, + uint256 maxTokensPerBidder, + address owner, + bytes32 salt + ) external view returns (address) { + // Compute the final salt the same way as in createPermitter + bytes32 finalSalt = keccak256(abi.encodePacked(msg.sender, salt)); + + // Compute the init code hash + bytes memory initCode = abi.encodePacked( + type(Permitter).creationCode, + abi.encode(trustedSigner, maxTotalEth, maxTokensPerBidder, owner) + ); + bytes32 initCodeHash = keccak256(initCode); + + // Compute the CREATE2 address + return address( + uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), finalSalt, initCodeHash)))) + ); + } +} diff --git a/src/interfaces/IPermitter.sol b/src/interfaces/IPermitter.sol new file mode 100644 index 0000000..965c86d --- /dev/null +++ b/src/interfaces/IPermitter.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +/// @title IPermitter +/// @notice Interface for the Permitter contract that validates bids in CCA auctions using EIP-712 +/// signed permits. +interface IPermitter { + /// @notice Enum for cap types used in events. + enum CapType { + TOTAL_ETH, + TOKENS_PER_BIDDER + } + + /// @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; + } + + /// @notice Emitted when the contract is paused. + error ContractPaused(); + + /// @notice Emitted when a signature has expired. + /// @param expiry The expiry timestamp of the signature. + /// @param currentTime The current block timestamp. + error SignatureExpired(uint256 expiry, uint256 currentTime); + + /// @notice Emitted when signature verification fails. + /// @param expected The expected signer address. + /// @param recovered The recovered signer address. + error InvalidSignature(address expected, address recovered); + + /// @notice Emitted when a bid would exceed the personal cap. + /// @param requested The requested bid amount. + /// @param cap The maximum allowed cap. + /// @param alreadyBid The amount already bid. + error ExceedsPersonalCap(uint256 requested, uint256 cap, uint256 alreadyBid); + + /// @notice Emitted when a bid would exceed the total cap. + /// @param requested The requested bid amount. + /// @param cap The maximum total cap. + /// @param alreadyRaised The amount already raised. + error ExceedsTotalCap(uint256 requested, uint256 cap, uint256 alreadyRaised); + + /// @notice Emitted when the caller is not authorized. + error Unauthorized(); + + /// @notice Emitted when the trusted signer is the zero address. + error InvalidTrustedSigner(); + + /// @notice Emitted when the owner is the zero address. + error InvalidOwner(); + + /// @notice Emitted when a permit is successfully verified. + /// @param bidder The address of the bidder. + /// @param bidAmount The amount of tokens bid. + /// @param remainingPersonalCap The remaining tokens the bidder can purchase. + /// @param remainingTotalCap The remaining ETH that can be raised. + event PermitVerified( + address indexed bidder, uint256 bidAmount, uint256 remainingPersonalCap, uint256 remainingTotalCap + ); + + /// @notice Emitted when a cap is updated. + /// @param capType The type of cap being updated. + /// @param oldCap The old cap value. + /// @param newCap The new cap value. + event CapUpdated(CapType indexed capType, uint256 oldCap, uint256 newCap); + + /// @notice Emitted when the trusted signer is updated. + /// @param oldSigner The old signer address. + /// @param newSigner The new signer address. + event SignerUpdated(address indexed oldSigner, address indexed newSigner); + + /// @notice Emitted when the contract is paused. + /// @param by The address that paused the contract. + event Paused(address indexed by); + + /// @notice Emitted when the contract is unpaused. + /// @param by The address that unpaused the contract. + event Unpaused(address indexed by); + + /// @notice Validates a bid in the CCA auction. + /// @dev Called by CCA contract before accepting bid. + /// @param bidder Address attempting to place bid. + /// @param bidAmount Amount of tokens being bid for. + /// @param ethValue Amount of ETH being bid (passed by CCA contract). + /// @param permitData ABI-encoded permit signature and metadata. + /// @return valid True if bid is permitted, reverts otherwise with custom error. + function validateBid(address bidder, uint256 bidAmount, uint256 ethValue, bytes calldata permitData) + external + returns (bool valid); + + /// @notice Update the maximum total ETH cap (owner only). + /// @param newMaxTotalEth New ETH cap. + function updateMaxTotalEth(uint256 newMaxTotalEth) external; + + /// @notice Update the maximum tokens per bidder cap (owner only). + /// @param newMaxTokensPerBidder New per-bidder cap. + function updateMaxTokensPerBidder(uint256 newMaxTokensPerBidder) external; + + /// @notice Update the trusted signer address (owner only). + /// @dev Use this to rotate keys if signing key is compromised. + /// @param newSigner New trusted signer address. + function updateTrustedSigner(address newSigner) external; + + /// @notice Emergency pause all bid validations (owner only). + function pause() external; + + /// @notice Resume bid validations (owner only). + function unpause() external; + + /// @notice Get cumulative bid amount for an address. + /// @param bidder Address to query. + /// @return cumulativeBid Total tokens bid by this address. + function getBidAmount(address bidder) external view returns (uint256 cumulativeBid); + + /// @notice Get total ETH raised across all bidders. + /// @return totalEthRaised Cumulative ETH raised. + function getTotalEthRaised() external view returns (uint256 totalEthRaised); + + /// @notice Get the trusted signer address. + /// @return The trusted signer address. + function trustedSigner() external view returns (address); + + /// @notice Get the maximum total ETH cap. + /// @return The maximum total ETH cap. + function maxTotalEth() external view returns (uint256); + + /// @notice Get the maximum tokens per bidder cap. + /// @return The maximum tokens per bidder cap. + function maxTokensPerBidder() external view returns (uint256); + + /// @notice Get the owner address. + /// @return The owner address. + function owner() external view returns (address); + + /// @notice Check if the contract is paused. + /// @return True if paused, false otherwise. + function paused() external view returns (bool); +} diff --git a/src/interfaces/IPermitterFactory.sol b/src/interfaces/IPermitterFactory.sol new file mode 100644 index 0000000..e2744da --- /dev/null +++ b/src/interfaces/IPermitterFactory.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +/// @title IPermitterFactory +/// @notice Factory interface for deploying isolated Permitter instances for each auction using +/// CREATE2 for deterministic addresses. +interface IPermitterFactory { + /// @notice Emitted when a new Permitter is created. + /// @param permitter The address of the deployed Permitter contract. + /// @param owner The address that can update caps and pause. + /// @param trustedSigner The address authorized to sign permits. + /// @param maxTotalEth The maximum total ETH that can be raised. + /// @param maxTokensPerBidder The maximum tokens any single bidder can purchase. + event PermitterCreated( + address indexed permitter, + address indexed owner, + address indexed trustedSigner, + uint256 maxTotalEth, + uint256 maxTokensPerBidder + ); + + /// @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 owner Address that can update caps and pause (auction creator). + /// @param salt Salt for CREATE2 deployment to enable deterministic addresses. + /// @return permitter Address of deployed Permitter contract. + function createPermitter( + address trustedSigner, + uint256 maxTotalEth, + uint256 maxTokensPerBidder, + address owner, + bytes32 salt + ) external returns (address permitter); + + /// @notice Predict the address of a Permitter before deployment. + /// @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 owner Address that can update caps and pause. + /// @param salt Salt for CREATE2 deployment. + /// @return The predicted address of the Permitter. + function predictPermitterAddress( + address trustedSigner, + uint256 maxTotalEth, + uint256 maxTokensPerBidder, + address owner, + bytes32 salt + ) external view returns (address); +} diff --git a/test/Counter.t.sol b/test/Counter.t.sol deleted file mode 100644 index 651040f..0000000 --- a/test/Counter.t.sol +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.30; - -/// forge-lint: disable-next-line(unused-import) -import {console2} from "forge-std/Test.sol"; -import {Test} from "forge-std/Test.sol"; -import {Deploy} from "script/Deploy.s.sol"; -import {Counter} from "src/Counter.sol"; - -contract CounterTest is Test, Deploy { - Counter counter; - - function setUp() public { - counter = Deploy.run(); - } -} - -contract Increment is CounterTest { - function test_NumberIsIncremented() public { - counter.increment(); - assertEq(counter.number(), 1); - } -} - -contract SetNumber is CounterTest { - function testFuzz_NumberIsSet(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), x); - } -} diff --git a/test/Fuzz.t.sol b/test/Fuzz.t.sol new file mode 100644 index 0000000..9ec7462 --- /dev/null +++ b/test/Fuzz.t.sol @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {Permitter} from "src/Permitter.sol"; +import {PermitterFactory} from "src/PermitterFactory.sol"; +import {IPermitter} from "src/interfaces/IPermitter.sol"; + +/// @notice Fuzz tests for the Permitter system. +contract FuzzTest is Test { + Permitter public permitter; + PermitterFactory public factory; + + address public owner = makeAddr("owner"); + address public trustedSigner; + uint256 public signerPrivateKey; + + uint256 public constant MAX_TOTAL_ETH = 100 ether; + uint256 public constant MAX_TOKENS_PER_BIDDER = 1000 ether; + + bytes32 public constant PERMIT_TYPEHASH = + keccak256("Permit(address bidder,uint256 maxBidAmount,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); + } + + function _createPermitSignature(address _bidder, uint256 _maxBidAmount, uint256 _expiry) + 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)); + + bytes32 domainSeparator = permitter.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); + + 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}); + + bytes32 structHash = + keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); + + bytes32 domainSeparator = permitter.domainSeparator(); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + permitData = abi.encode(permit, signature); + } +} + +/// @notice Fuzz tests for signature verification. +contract SignatureVerificationFuzz is FuzzTest { + /// @notice Fuzz test that random signatures are rejected. + function testFuzz_RandomSignaturesAreRejected( + address bidder, + uint256 bidAmount, + uint256 expiry, + bytes memory randomSignature + ) public { + vm.assume(bidder != address(0)); + vm.assume(bidAmount > 0 && 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}); + + bytes memory permitData = abi.encode(permit, randomSignature); + + // Should revert with either InvalidSignature or an ECDSA error + vm.expectRevert(); + permitter.validateBid(bidder, bidAmount, 1, permitData); + } + + /// @notice Fuzz test that signatures from wrong signer are rejected. + function testFuzz_WrongSignerRejected(address bidder, uint256 wrongSignerKey) public { + vm.assume(bidder != address(0)); + // Ensure wrong signer key is valid (non-zero, less than secp256k1 order) + vm.assume(wrongSignerKey > 0 && wrongSignerKey < type(uint256).max / 2); + vm.assume(vm.addr(wrongSignerKey) != trustedSigner); + + uint256 expiry = block.timestamp + 1 hours; + bytes memory permitData = + _createPermitSignatureWithKey(bidder, MAX_TOKENS_PER_BIDDER, expiry, wrongSignerKey); + + address recoveredSigner = vm.addr(wrongSignerKey); + + vm.expectRevert( + abi.encodeWithSelector(IPermitter.InvalidSignature.selector, trustedSigner, recoveredSigner) + ); + permitter.validateBid(bidder, 100 ether, 1 ether, permitData); + } + + /// @notice Fuzz test that valid signatures are accepted. + function testFuzz_ValidSignaturesAccepted( + address bidder, + uint256 bidAmount, + uint256 ethValue, + 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); + + uint256 expiry = block.timestamp + expiryOffset; + bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + + bool result = permitter.validateBid(bidder, bidAmount, ethValue, permitData); + + assertTrue(result); + assertEq(permitter.getBidAmount(bidder), bidAmount); + assertEq(permitter.getTotalEthRaised(), ethValue); + } +} + +/// @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[] memory bidAmounts) + public + { + vm.assume(permitMax > 0 && permitMax <= MAX_TOKENS_PER_BIDDER); + vm.assume(bidAmounts.length > 0 && bidAmounts.length <= 10); + + address bidder = makeAddr("fuzzBidder"); + uint256 expiry = block.timestamp + 1 hours; + bytes memory permitData = _createPermitSignature(bidder, permitMax, expiry); + + uint256 totalBid = 0; + + for (uint256 i = 0; i < bidAmounts.length; i++) { + uint256 bidAmount = bound(bidAmounts[i], 1, permitMax); + + if (totalBid + bidAmount <= permitMax) { + // Bid should succeed + permitter.validateBid(bidder, bidAmount, 0, permitData); + totalBid += bidAmount; + assertEq(permitter.getBidAmount(bidder), totalBid); + } else { + // Bid should fail + vm.expectRevert( + abi.encodeWithSelector( + IPermitter.ExceedsPersonalCap.selector, bidAmount, permitMax, totalBid + ) + ); + permitter.validateBid(bidder, bidAmount, 0, permitData); + } + } + + // Invariant: cumulative bids should never exceed permit max + assertLe(permitter.getBidAmount(bidder), permitMax); + } + + /// @notice Fuzz test that total ETH raised never exceeds max. + function testFuzz_TotalEthNeverExceedsMax(uint256[] memory ethValues) public { + vm.assume(ethValues.length > 0 && ethValues.length <= 10); + + uint256 expiry = block.timestamp + 1 hours; + uint256 totalEth = 0; + + for (uint256 i = 0; i < ethValues.length; i++) { + address bidder = address(uint160(i + 1)); + uint256 ethValue = bound(ethValues[i], 1, MAX_TOTAL_ETH); + + bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + + if (totalEth + ethValue <= MAX_TOTAL_ETH) { + // Should succeed + permitter.validateBid(bidder, 100 ether, ethValue, permitData); + totalEth += ethValue; + assertEq(permitter.getTotalEthRaised(), totalEth); + } else { + // Should fail + vm.expectRevert( + abi.encodeWithSelector(IPermitter.ExceedsTotalCap.selector, ethValue, MAX_TOTAL_ETH, totalEth) + ); + permitter.validateBid(bidder, 100 ether, ethValue, permitData); + } + } + + // Invariant: total ETH raised should never exceed max + assertLe(permitter.getTotalEthRaised(), MAX_TOTAL_ETH); + } +} + +/// @notice Fuzz tests for expiry enforcement. +contract ExpiryEnforcementFuzz is FuzzTest { + /// @notice Fuzz test that expired permits are always rejected. + function testFuzz_ExpiredPermitsRejected(address bidder, uint256 timePastExpiry) public { + vm.assume(bidder != address(0)); + vm.assume(timePastExpiry > 0 && timePastExpiry < 365 days); + + uint256 expiry = block.timestamp; + bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + + // Warp past expiry + vm.warp(expiry + timePastExpiry); + + vm.expectRevert( + abi.encodeWithSelector(IPermitter.SignatureExpired.selector, expiry, block.timestamp) + ); + permitter.validateBid(bidder, 100 ether, 1 ether, permitData); + } + + /// @notice Fuzz test that non-expired permits are accepted. + function testFuzz_NonExpiredPermitsAccepted(address bidder, uint256 timeBeforeExpiry) public { + vm.assume(bidder != address(0)); + vm.assume(timeBeforeExpiry > 0 && timeBeforeExpiry < 365 days); + + uint256 expiry = block.timestamp + timeBeforeExpiry; + bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + + // Warp to just before expiry + vm.warp(expiry - 1); + + bool result = permitter.validateBid(bidder, 100 ether, 1 ether, permitData); + assertTrue(result); + } +} + +/// @notice Fuzz tests for owner functions. +contract OwnerFunctionsFuzz is FuzzTest { + /// @notice Fuzz test that non-owners cannot update caps. + function testFuzz_NonOwnerCannotUpdateMaxTotalEth(address caller, uint256 newCap) public { + vm.assume(caller != owner); + + vm.expectRevert(IPermitter.Unauthorized.selector); + vm.prank(caller); + permitter.updateMaxTotalEth(newCap); + } + + /// @notice Fuzz test that non-owners cannot update per-bidder cap. + function testFuzz_NonOwnerCannotUpdateMaxTokensPerBidder(address caller, uint256 newCap) public { + vm.assume(caller != owner); + + vm.expectRevert(IPermitter.Unauthorized.selector); + vm.prank(caller); + permitter.updateMaxTokensPerBidder(newCap); + } + + /// @notice Fuzz test that non-owners cannot update signer. + function testFuzz_NonOwnerCannotUpdateSigner(address caller, address newSigner) public { + vm.assume(caller != owner); + + vm.expectRevert(IPermitter.Unauthorized.selector); + vm.prank(caller); + permitter.updateTrustedSigner(newSigner); + } + + /// @notice Fuzz test that non-owners cannot pause. + function testFuzz_NonOwnerCannotPause(address caller) public { + vm.assume(caller != owner); + + vm.expectRevert(IPermitter.Unauthorized.selector); + vm.prank(caller); + permitter.pause(); + } + + /// @notice Fuzz test that owner can update caps to any value. + function testFuzz_OwnerCanUpdateCaps(uint256 newTotalEth, uint256 newPerBidder) public { + vm.startPrank(owner); + + permitter.updateMaxTotalEth(newTotalEth); + assertEq(permitter.maxTotalEth(), newTotalEth); + + permitter.updateMaxTokensPerBidder(newPerBidder); + assertEq(permitter.maxTokensPerBidder(), newPerBidder); + + vm.stopPrank(); + } +} + +/// @notice Fuzz tests for factory. +contract FactoryFuzz is FuzzTest { + /// @notice Fuzz test that CREATE2 addresses are deterministic. + function testFuzz_Create2AddressesAreDeterministic( + address deployer, + address fuzzedTrustedSigner, + uint256 maxTotalEth, + uint256 maxTokensPerBidder, + address fuzzedOwner, + bytes32 salt + ) public { + vm.assume(deployer != address(0)); + vm.assume(fuzzedTrustedSigner != address(0)); + vm.assume(fuzzedOwner != address(0)); + + vm.startPrank(deployer); + + address predicted = + factory.predictPermitterAddress(fuzzedTrustedSigner, maxTotalEth, maxTokensPerBidder, fuzzedOwner, salt); + + address actual = + factory.createPermitter(fuzzedTrustedSigner, maxTotalEth, maxTokensPerBidder, fuzzedOwner, salt); + + vm.stopPrank(); + + assertEq(predicted, actual); + } + + /// @notice Fuzz test that different salts produce different addresses. + function testFuzz_DifferentSaltsDifferentAddresses(bytes32 salt1, bytes32 salt2) public { + vm.assume(salt1 != salt2); + + address deployer = makeAddr("fuzzDeployer"); + + vm.startPrank(deployer); + + address addr1 = + factory.predictPermitterAddress(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt1); + + address addr2 = + factory.predictPermitterAddress(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt2); + + vm.stopPrank(); + + assertTrue(addr1 != addr2); + } + + /// @notice Fuzz test that different deployers with same salt produce different addresses. + function testFuzz_DifferentDeployersDifferentAddresses(address deployer1, address deployer2, bytes32 salt) + public + { + vm.assume(deployer1 != deployer2); + vm.assume(deployer1 != address(0)); + vm.assume(deployer2 != address(0)); + + vm.prank(deployer1); + address addr1 = + factory.predictPermitterAddress(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt); + + vm.prank(deployer2); + address addr2 = + factory.predictPermitterAddress(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt); + + assertTrue(addr1 != addr2); + } +} diff --git a/test/Integration.t.sol b/test/Integration.t.sol new file mode 100644 index 0000000..a39a021 --- /dev/null +++ b/test/Integration.t.sol @@ -0,0 +1,399 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {PermitterFactory} from "src/PermitterFactory.sol"; +import {Permitter} from "src/Permitter.sol"; +import {IPermitter} from "src/interfaces/IPermitter.sol"; + +/// @notice Integration tests for the full Permitter system. +contract IntegrationTest is Test { + PermitterFactory public factory; + Permitter public permitter; + + // Test accounts + address public deployer = makeAddr("deployer"); + address public auctionOwner = makeAddr("auctionOwner"); + address public trustedSigner; + uint256 public signerPrivateKey; + + address public bidder1 = makeAddr("bidder1"); + address public bidder2 = makeAddr("bidder2"); + address public bidder3 = makeAddr("bidder3"); + + // Auction configuration + uint256 public constant MAX_TOTAL_ETH = 100 ether; + uint256 public constant MAX_TOKENS_PER_BIDDER = 1000 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)"); + + function setUp() public virtual { + // Create a trusted signer with a known private key + signerPrivateKey = 0x1234; + trustedSigner = vm.addr(signerPrivateKey); + + // Deploy the factory + factory = new PermitterFactory(); + + // Deploy a Permitter for a specific auction + vm.prank(deployer); + address permitterAddress = + factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, auctionOwner, AUCTION_SALT); + permitter = Permitter(permitterAddress); + } + + /// @notice Helper function to create a valid permit signature. + function _createPermitSignature(address _bidder, uint256 _maxBidAmount, uint256 _expiry) + 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)); + + bytes32 domainSeparator = permitter.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); + + permitData = abi.encode(permit, signature); + } +} + +/// @notice Test a complete auction lifecycle. +contract FullAuctionLifecycle is IntegrationTest { + function test_CompleteAuctionWithMultipleBidders() public { + 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); + + // Bidder 1 places multiple bids + permitter.validateBid(bidder1, 100 ether, 10 ether, permit1); + permitter.validateBid(bidder1, 150 ether, 15 ether, permit1); + + assertEq(permitter.getBidAmount(bidder1), 250 ether); + assertEq(permitter.getTotalEthRaised(), 25 ether); + + // Bidder 2 places a bid + permitter.validateBid(bidder2, 200 ether, 20 ether, permit2); + + assertEq(permitter.getBidAmount(bidder2), 200 ether); + assertEq(permitter.getTotalEthRaised(), 45 ether); + + // Bidder 3 places multiple bids + permitter.validateBid(bidder3, 100 ether, 10 ether, permit3); + permitter.validateBid(bidder3, 200 ether, 20 ether, permit3); + + assertEq(permitter.getBidAmount(bidder3), 300 ether); + assertEq(permitter.getTotalEthRaised(), 75 ether); + + // Bidder 1 places another bid + permitter.validateBid(bidder1, 100 ether, 10 ether, permit1); + + assertEq(permitter.getBidAmount(bidder1), 350 ether); + assertEq(permitter.getTotalEthRaised(), 85 ether); + + // Verify final state + assertEq(permitter.getBidAmount(bidder1), 350 ether); + assertEq(permitter.getBidAmount(bidder2), 200 ether); + assertEq(permitter.getBidAmount(bidder3), 300 ether); + assertEq(permitter.getTotalEthRaised(), 85 ether); + } + + 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); + + // Fill up most of the cap + permitter.validateBid(bidder1, 100 ether, 50 ether, permit1); + permitter.validateBid(bidder2, 100 ether, 49 ether, permit2); + + assertEq(permitter.getTotalEthRaised(), 99 ether); + + // Final bid that exactly hits the cap + permitter.validateBid(bidder1, 10 ether, 1 ether, permit1); + + assertEq(permitter.getTotalEthRaised(), 100 ether); + + // Any more bids should fail + vm.expectRevert( + abi.encodeWithSelector(IPermitter.ExceedsTotalCap.selector, 1, MAX_TOTAL_ETH, 100 ether) + ); + permitter.validateBid(bidder2, 1 ether, 1, permit2); + } + + function test_BidderReachesPersonalCap() public { + uint256 expiry = block.timestamp + 24 hours; + uint256 personalCap = 500 ether; + + bytes memory permit = _createPermitSignature(bidder1, personalCap, expiry); + + // Place bids up to the personal cap + permitter.validateBid(bidder1, 200 ether, 2 ether, permit); + permitter.validateBid(bidder1, 200 ether, 2 ether, permit); + permitter.validateBid(bidder1, 100 ether, 1 ether, permit); + + assertEq(permitter.getBidAmount(bidder1), 500 ether); + + // Next bid should fail + vm.expectRevert( + abi.encodeWithSelector(IPermitter.ExceedsPersonalCap.selector, 1 ether, personalCap, 500 ether) + ); + permitter.validateBid(bidder1, 1 ether, 0.01 ether, permit); + } +} + +/// @notice Test emergency scenarios. +contract EmergencyScenarios is IntegrationTest { + function test_OwnerPausesAndResumesAuction() public { + uint256 expiry = block.timestamp + 24 hours; + bytes memory permit = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry); + + // Place a bid successfully + permitter.validateBid(bidder1, 100 ether, 1 ether, permit); + + // Owner pauses the auction + vm.prank(auctionOwner); + permitter.pause(); + + // Bid should fail while paused + vm.expectRevert(IPermitter.ContractPaused.selector); + permitter.validateBid(bidder1, 100 ether, 1 ether, permit); + + // Owner unpauses + vm.prank(auctionOwner); + permitter.unpause(); + + // Bid should succeed again + permitter.validateBid(bidder1, 100 ether, 1 ether, permit); + + assertEq(permitter.getBidAmount(bidder1), 200 ether); + } + + function test_SignerKeyRotation() public { + uint256 expiry = block.timestamp + 24 hours; + + // Place a bid with the original signer + bytes memory permit = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry); + permitter.validateBid(bidder1, 100 ether, 1 ether, permit); + + // Rotate to a new signer + uint256 newSignerKey = 0x5678; + address newSigner = vm.addr(newSignerKey); + + vm.prank(auctionOwner); + permitter.updateTrustedSigner(newSigner); + + // Old permit should fail + vm.expectRevert( + abi.encodeWithSelector(IPermitter.InvalidSignature.selector, newSigner, trustedSigner) + ); + permitter.validateBid(bidder1, 100 ether, 1 ether, permit); + + // 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) + ); + + bytes32 domainSeparator = permitter.domainSeparator(); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(newSignerKey, digest); + bytes memory newSignature = abi.encodePacked(r, s, v); + bytes memory newPermitData = abi.encode(newPermitStruct, newSignature); + + // New permit should work + permitter.validateBid(bidder1, 100 ether, 1 ether, newPermitData); + + assertEq(permitter.getBidAmount(bidder1), 200 ether); + } + + function test_OwnerAdjustsCapsLowerDuringAuction() public { + uint256 expiry = block.timestamp + 24 hours; + bytes memory permit = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry); + + // Place initial bids + permitter.validateBid(bidder1, 400 ether, 40 ether, permit); + + // Owner lowers the total cap + vm.prank(auctionOwner); + permitter.updateMaxTotalEth(50 ether); + + // Next bid exceeds the new cap + vm.expectRevert( + abi.encodeWithSelector(IPermitter.ExceedsTotalCap.selector, 15 ether, 50 ether, 40 ether) + ); + permitter.validateBid(bidder1, 100 ether, 15 ether, permit); + + // But a smaller bid should work + permitter.validateBid(bidder1, 50 ether, 10 ether, permit); + + assertEq(permitter.getTotalEthRaised(), 50 ether); + } + + function test_OwnerAdjustsCapsHigherDuringAuction() public { + uint256 expiry = block.timestamp + 24 hours; + bytes memory permit = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry); + + // Set a low initial cap + vm.prank(auctionOwner); + permitter.updateMaxTotalEth(10 ether); + + // Place bids up to the cap + permitter.validateBid(bidder1, 100 ether, 10 ether, permit); + + // Next bid fails + vm.expectRevert( + abi.encodeWithSelector(IPermitter.ExceedsTotalCap.selector, 1 ether, 10 ether, 10 ether) + ); + permitter.validateBid(bidder1, 10 ether, 1 ether, permit); + + // Owner raises the cap + vm.prank(auctionOwner); + permitter.updateMaxTotalEth(100 ether); + + // Now the bid succeeds + permitter.validateBid(bidder1, 100 ether, 10 ether, permit); + + assertEq(permitter.getTotalEthRaised(), 20 ether); + } +} + +/// @notice Test permit expiry scenarios. +contract PermitExpiry is IntegrationTest { + function test_PermitExpiresAfterTimestamp() public { + uint256 expiry = block.timestamp + 1 hours; + bytes memory permit = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry); + + // Bid succeeds before expiry + permitter.validateBid(bidder1, 100 ether, 1 ether, permit); + + // Warp past expiry + vm.warp(expiry + 1); + + // Bid fails after expiry + vm.expectRevert( + abi.encodeWithSelector(IPermitter.SignatureExpired.selector, expiry, block.timestamp) + ); + permitter.validateBid(bidder1, 100 ether, 1 ether, permit); + } + + function test_BidderCanGetNewPermitAfterExpiry() public { + uint256 expiry1 = block.timestamp + 1 hours; + bytes memory permit1 = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry1); + + // Use first permit + permitter.validateBid(bidder1, 100 ether, 1 ether, permit1); + + // Warp past first expiry + vm.warp(expiry1 + 1); + + // First permit is now expired + vm.expectRevert( + abi.encodeWithSelector(IPermitter.SignatureExpired.selector, expiry1, block.timestamp) + ); + permitter.validateBid(bidder1, 100 ether, 1 ether, permit1); + + // Get a new permit with new expiry + uint256 expiry2 = block.timestamp + 24 hours; + bytes memory permit2 = _createPermitSignature(bidder1, MAX_TOKENS_PER_BIDDER, expiry2); + + // New permit works + permitter.validateBid(bidder1, 100 ether, 1 ether, permit2); + + assertEq(permitter.getBidAmount(bidder1), 200 ether); + } +} + +/// @notice Test multiple auctions scenario. +contract MultipleAuctions is IntegrationTest { + Permitter public permitter2; + Permitter public permitter3; + + function setUp() public override { + super.setUp(); + + // Deploy additional permitters for different auctions + vm.startPrank(deployer); + address permitter2Address = factory.createPermitter( + trustedSigner, 50 ether, 500 ether, auctionOwner, bytes32(uint256(2)) + ); + address permitter3Address = factory.createPermitter( + trustedSigner, 200 ether, 2000 ether, auctionOwner, bytes32(uint256(3)) + ); + vm.stopPrank(); + + permitter2 = Permitter(permitter2Address); + permitter3 = Permitter(permitter3Address); + } + + function test_BidderParticipatesInMultipleAuctions() public { + 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); + + // Bid in all auctions + permitter.validateBid(bidder1, 100 ether, 10 ether, permit1); + permitter2.validateBid(bidder1, 200 ether, 20 ether, permit2); + permitter3.validateBid(bidder1, 500 ether, 50 ether, permit3); + + // Verify each auction has independent state + assertEq(permitter.getBidAmount(bidder1), 100 ether); + assertEq(permitter2.getBidAmount(bidder1), 200 ether); + assertEq(permitter3.getBidAmount(bidder1), 500 ether); + + assertEq(permitter.getTotalEthRaised(), 10 ether); + assertEq(permitter2.getTotalEthRaised(), 20 ether); + assertEq(permitter3.getTotalEthRaised(), 50 ether); + } + + function test_PermitFromOneAuctionCannotBeUsedInAnother() public { + uint256 expiry = block.timestamp + 24 hours; + + // Create permit for auction 1 + bytes memory permit1 = _createPermitSignatureForPermitter(bidder1, MAX_TOKENS_PER_BIDDER, expiry, permitter); + + // Try to use it in auction 2 - should fail because domain separator is different + vm.expectRevert(); + permitter2.validateBid(bidder1, 100 ether, 10 ether, permit1); + } + + 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}); + + bytes32 structHash = + keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); + + bytes32 domainSeparator = _permitter.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); + + permitData = abi.encode(permit, signature); + } +} diff --git a/test/Permitter.t.sol b/test/Permitter.t.sol new file mode 100644 index 0000000..317c31b --- /dev/null +++ b/test/Permitter.t.sol @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {Permitter} from "src/Permitter.sol"; +import {IPermitter} from "src/interfaces/IPermitter.sol"; + +/// @notice Base test contract for Permitter tests. +contract PermitterTest is Test { + Permitter public permitter; + + // Test accounts + address public owner = makeAddr("owner"); + address public trustedSigner; + uint256 public signerPrivateKey; + address public bidder = makeAddr("bidder"); + address public otherBidder = makeAddr("otherBidder"); + + // Default configuration + uint256 public constant MAX_TOTAL_ETH = 100 ether; + uint256 public constant MAX_TOKENS_PER_BIDDER = 1000 ether; + + // EIP-712 constants + bytes32 public constant PERMIT_TYPEHASH = + keccak256("Permit(address bidder,uint256 maxBidAmount,uint256 expiry)"); + + function setUp() public virtual { + // Create a trusted signer with a known private key + signerPrivateKey = 0x1234; + trustedSigner = vm.addr(signerPrivateKey); + + // Deploy the Permitter + permitter = new Permitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner); + } + + /// @notice Helper function to create a valid permit signature. + function _createPermitSignature(address _bidder, uint256 _maxBidAmount, uint256 _expiry) + internal + view + returns (bytes memory permitData) + { + return _createPermitSignatureWithKey(_bidder, _maxBidAmount, _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)); + + bytes32 domainSeparator = permitter.domainSeparator(); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_privateKey, digest); + bytes memory signature = abi.encodePacked(r, s, v); + + permitData = abi.encode(permit, signature); + } +} + +/// @notice Tests for constructor behavior. +contract Constructor is PermitterTest { + function test_SetsInitialState() public view { + assertEq(permitter.trustedSigner(), trustedSigner); + assertEq(permitter.maxTotalEth(), MAX_TOTAL_ETH); + assertEq(permitter.maxTokensPerBidder(), MAX_TOKENS_PER_BIDDER); + assertEq(permitter.owner(), owner); + assertEq(permitter.paused(), false); + assertEq(permitter.totalEthRaised(), 0); + } + + function test_RevertsWhenTrustedSignerIsZero() public { + vm.expectRevert(IPermitter.InvalidTrustedSigner.selector); + new Permitter(address(0), MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner); + } + + function test_RevertsWhenOwnerIsZero() public { + vm.expectRevert(IPermitter.InvalidOwner.selector); + new Permitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, address(0)); + } +} + +/// @notice Tests for validateBid with valid permits. +contract ValidateBidSuccess is PermitterTest { + function test_ValidBidSucceeds() public { + uint256 bidAmount = 100 ether; + uint256 ethValue = 1 ether; + uint256 expiry = block.timestamp + 1 hours; + + bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + + bool result = permitter.validateBid(bidder, bidAmount, ethValue, permitData); + + assertTrue(result); + assertEq(permitter.getBidAmount(bidder), bidAmount); + assertEq(permitter.getTotalEthRaised(), ethValue); + } + + function test_MultipleBidsFromSameBidder() public { + uint256 expiry = block.timestamp + 1 hours; + bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + + // First bid + permitter.validateBid(bidder, 100 ether, 1 ether, permitData); + assertEq(permitter.getBidAmount(bidder), 100 ether); + + // Second bid + permitter.validateBid(bidder, 200 ether, 2 ether, permitData); + assertEq(permitter.getBidAmount(bidder), 300 ether); + + // Third bid + permitter.validateBid(bidder, 50 ether, 0.5 ether, permitData); + assertEq(permitter.getBidAmount(bidder), 350 ether); + assertEq(permitter.getTotalEthRaised(), 3.5 ether); + } + + 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); + + permitter.validateBid(bidder, 100 ether, 1 ether, permitData1); + permitter.validateBid(otherBidder, 200 ether, 2 ether, permitData2); + + assertEq(permitter.getBidAmount(bidder), 100 ether); + assertEq(permitter.getBidAmount(otherBidder), 200 ether); + assertEq(permitter.getTotalEthRaised(), 3 ether); + } + + function test_EmitsPermitVerifiedEvent() public { + uint256 bidAmount = 100 ether; + uint256 ethValue = 1 ether; + uint256 expiry = block.timestamp + 1 hours; + bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + + vm.expectEmit(true, false, false, true); + emit IPermitter.PermitVerified( + bidder, bidAmount, MAX_TOKENS_PER_BIDDER - bidAmount, MAX_TOTAL_ETH - ethValue + ); + + permitter.validateBid(bidder, bidAmount, ethValue, permitData); + } +} + +/// @notice Tests for validateBid reverts. +contract ValidateBidRevert is PermitterTest { + function test_RevertsWhenPaused() public { + uint256 expiry = block.timestamp + 1 hours; + bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + + vm.prank(owner); + permitter.pause(); + + vm.expectRevert(IPermitter.ContractPaused.selector); + permitter.validateBid(bidder, 100 ether, 1 ether, permitData); + } + + function test_RevertsWhenSignatureExpired() public { + uint256 expiry = block.timestamp - 1; // Already expired + bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + + vm.expectRevert(abi.encodeWithSelector(IPermitter.SignatureExpired.selector, expiry, block.timestamp)); + permitter.validateBid(bidder, 100 ether, 1 ether, permitData); + } + + function test_RevertsWhenSignatureFromWrongSigner() public { + uint256 wrongSignerKey = 0x5678; + address wrongSigner = vm.addr(wrongSignerKey); + uint256 expiry = block.timestamp + 1 hours; + + bytes memory permitData = + _createPermitSignatureWithKey(bidder, MAX_TOKENS_PER_BIDDER, expiry, wrongSignerKey); + + vm.expectRevert( + abi.encodeWithSelector(IPermitter.InvalidSignature.selector, trustedSigner, wrongSigner) + ); + permitter.validateBid(bidder, 100 ether, 1 ether, permitData); + } + + function test_RevertsWhenBidderMismatch() 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); + + vm.expectRevert(abi.encodeWithSelector(IPermitter.InvalidSignature.selector, bidder, otherBidder)); + permitter.validateBid(bidder, 100 ether, 1 ether, permitData); + } + + function test_RevertsWhenExceedsPermitMaxBidAmount() public { + uint256 permitMax = 500 ether; + uint256 expiry = block.timestamp + 1 hours; + bytes memory permitData = _createPermitSignature(bidder, permitMax, expiry); + + // First bid succeeds + 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) + ); + permitter.validateBid(bidder, 200 ether, 2 ether, permitData); + } + + function test_RevertsWhenExceedsGlobalMaxTokensPerBidder() public { + // Set a lower global cap than the permit allows + vm.prank(owner); + permitter.updateMaxTokensPerBidder(300 ether); + + uint256 expiry = block.timestamp + 1 hours; + bytes memory permitData = _createPermitSignature(bidder, 500 ether, expiry); + + // First bid succeeds + permitter.validateBid(bidder, 200 ether, 2 ether, permitData); + + // Second bid exceeds global max + vm.expectRevert( + abi.encodeWithSelector(IPermitter.ExceedsPersonalCap.selector, 200 ether, 300 ether, 200 ether) + ); + permitter.validateBid(bidder, 200 ether, 2 ether, permitData); + } + + function test_RevertsWhenExceedsTotalEthCap() public { + uint256 expiry = block.timestamp + 1 hours; + bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + + // Bid that brings us close to the cap + permitter.validateBid(bidder, 100 ether, 99 ether, permitData); + + // Bid that exceeds total cap + vm.expectRevert( + abi.encodeWithSelector(IPermitter.ExceedsTotalCap.selector, 2 ether, MAX_TOTAL_ETH, 99 ether) + ); + permitter.validateBid(bidder, 10 ether, 2 ether, permitData); + } +} + +/// @notice Tests for owner-only functions. +contract OwnerFunctions is PermitterTest { + function test_UpdateMaxTotalEth() public { + uint256 newCap = 200 ether; + + vm.expectEmit(true, false, false, true); + emit IPermitter.CapUpdated(IPermitter.CapType.TOTAL_ETH, MAX_TOTAL_ETH, newCap); + + vm.prank(owner); + permitter.updateMaxTotalEth(newCap); + + assertEq(permitter.maxTotalEth(), newCap); + } + + function test_UpdateMaxTotalEthRevertsForNonOwner() public { + vm.expectRevert(IPermitter.Unauthorized.selector); + vm.prank(bidder); + permitter.updateMaxTotalEth(200 ether); + } + + function test_UpdateMaxTokensPerBidder() public { + uint256 newCap = 2000 ether; + + vm.expectEmit(true, false, false, true); + emit IPermitter.CapUpdated(IPermitter.CapType.TOKENS_PER_BIDDER, MAX_TOKENS_PER_BIDDER, newCap); + + vm.prank(owner); + permitter.updateMaxTokensPerBidder(newCap); + + assertEq(permitter.maxTokensPerBidder(), newCap); + } + + function test_UpdateMaxTokensPerBidderRevertsForNonOwner() public { + vm.expectRevert(IPermitter.Unauthorized.selector); + vm.prank(bidder); + permitter.updateMaxTokensPerBidder(2000 ether); + } + + function test_UpdateTrustedSigner() public { + address newSigner = makeAddr("newSigner"); + + vm.expectEmit(true, true, false, false); + emit IPermitter.SignerUpdated(trustedSigner, newSigner); + + vm.prank(owner); + permitter.updateTrustedSigner(newSigner); + + assertEq(permitter.trustedSigner(), newSigner); + } + + function test_UpdateTrustedSignerRevertsForNonOwner() public { + vm.expectRevert(IPermitter.Unauthorized.selector); + vm.prank(bidder); + permitter.updateTrustedSigner(makeAddr("newSigner")); + } + + function test_UpdateTrustedSignerRevertsForZeroAddress() public { + vm.expectRevert(IPermitter.InvalidTrustedSigner.selector); + vm.prank(owner); + permitter.updateTrustedSigner(address(0)); + } + + function test_Pause() public { + vm.expectEmit(true, false, false, false); + emit IPermitter.Paused(owner); + + vm.prank(owner); + permitter.pause(); + + assertTrue(permitter.paused()); + } + + function test_PauseRevertsForNonOwner() public { + vm.expectRevert(IPermitter.Unauthorized.selector); + vm.prank(bidder); + permitter.pause(); + } + + function test_Unpause() public { + vm.prank(owner); + permitter.pause(); + + vm.expectEmit(true, false, false, false); + emit IPermitter.Unpaused(owner); + + vm.prank(owner); + permitter.unpause(); + + assertFalse(permitter.paused()); + } + + function test_UnpauseRevertsForNonOwner() public { + vm.prank(owner); + permitter.pause(); + + vm.expectRevert(IPermitter.Unauthorized.selector); + vm.prank(bidder); + permitter.unpause(); + } +} + +/// @notice Tests for signer rotation. +contract SignerRotation is PermitterTest { + function test_OldSignerInvalidAfterRotation() public { + uint256 expiry = block.timestamp + 1 hours; + + // Create a permit with the old signer + bytes memory oldPermitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + + // Rotate to new signer + uint256 newSignerKey = 0x5678; + address newSigner = vm.addr(newSignerKey); + vm.prank(owner); + permitter.updateTrustedSigner(newSigner); + + // Old permit should fail + vm.expectRevert( + abi.encodeWithSelector(IPermitter.InvalidSignature.selector, newSigner, trustedSigner) + ); + permitter.validateBid(bidder, 100 ether, 1 ether, oldPermitData); + } + + function test_NewSignerWorksAfterRotation() public { + uint256 newSignerKey = 0x5678; + address newSigner = vm.addr(newSignerKey); + + // Rotate to new signer + vm.prank(owner); + permitter.updateTrustedSigner(newSigner); + + // Create a permit with the new signer + uint256 expiry = block.timestamp + 1 hours; + bytes memory newPermitData = + _createPermitSignatureWithKey(bidder, MAX_TOKENS_PER_BIDDER, expiry, newSignerKey); + + // New permit should work + bool result = permitter.validateBid(bidder, 100 ether, 1 ether, newPermitData); + assertTrue(result); + } +} + +/// @notice Tests for view functions. +contract ViewFunctions is PermitterTest { + function test_GetBidAmount() public { + uint256 expiry = block.timestamp + 1 hours; + bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); + + assertEq(permitter.getBidAmount(bidder), 0); + + permitter.validateBid(bidder, 100 ether, 1 ether, permitData); + assertEq(permitter.getBidAmount(bidder), 100 ether); + + permitter.validateBid(bidder, 50 ether, 0.5 ether, permitData); + assertEq(permitter.getBidAmount(bidder), 150 ether); + } + + 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); + + assertEq(permitter.getTotalEthRaised(), 0); + + permitter.validateBid(bidder, 100 ether, 5 ether, permitData1); + assertEq(permitter.getTotalEthRaised(), 5 ether); + + permitter.validateBid(otherBidder, 200 ether, 10 ether, permitData2); + assertEq(permitter.getTotalEthRaised(), 15 ether); + } + + function test_DomainSeparator() public view { + bytes32 domainSeparator = permitter.domainSeparator(); + // Just verify it returns a non-zero value (actual value depends on contract address and chain) + assertTrue(domainSeparator != bytes32(0)); + } +} diff --git a/test/PermitterFactory.t.sol b/test/PermitterFactory.t.sol new file mode 100644 index 0000000..4bef880 --- /dev/null +++ b/test/PermitterFactory.t.sol @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {PermitterFactory} from "src/PermitterFactory.sol"; +import {Permitter} from "src/Permitter.sol"; +import {IPermitterFactory} from "src/interfaces/IPermitterFactory.sol"; +import {IPermitter} from "src/interfaces/IPermitter.sol"; + +/// @notice Base test contract for PermitterFactory tests. +contract PermitterFactoryTest is Test { + PermitterFactory public factory; + + // Test accounts + address public deployer = makeAddr("deployer"); + address public owner = makeAddr("owner"); + address public trustedSigner = makeAddr("trustedSigner"); + + // Default configuration + uint256 public constant MAX_TOTAL_ETH = 100 ether; + uint256 public constant MAX_TOKENS_PER_BIDDER = 1000 ether; + bytes32 public constant DEFAULT_SALT = bytes32(uint256(1)); + + function setUp() public virtual { + factory = new PermitterFactory(); + } +} + +/// @notice Tests for createPermitter function. +contract CreatePermitter is PermitterFactoryTest { + function test_DeploysPermitterWithCorrectParameters() public { + vm.prank(deployer); + address permitterAddress = + factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); + + Permitter permitter = Permitter(permitterAddress); + + assertEq(permitter.trustedSigner(), trustedSigner); + assertEq(permitter.maxTotalEth(), MAX_TOTAL_ETH); + assertEq(permitter.maxTokensPerBidder(), MAX_TOKENS_PER_BIDDER); + assertEq(permitter.owner(), owner); + assertEq(permitter.paused(), false); + assertEq(permitter.totalEthRaised(), 0); + } + + function test_EmitsPermitterCreatedEvent() public { + vm.prank(deployer); + + // We can't predict the exact address before the call, so we just check the event is emitted + // with correct parameters + vm.expectEmit(false, true, true, true); + emit IPermitterFactory.PermitterCreated( + address(0), // We don't know the address yet + owner, + trustedSigner, + MAX_TOTAL_ETH, + MAX_TOKENS_PER_BIDDER + ); + + factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); + } + + function test_SameSaltFromDifferentSendersCreatesDifferentAddresses() public { + address deployer2 = makeAddr("deployer2"); + + vm.prank(deployer); + address permitter1 = + factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); + + vm.prank(deployer2); + address permitter2 = + factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); + + assertTrue(permitter1 != permitter2); + } + + function test_DifferentSaltFromSameSenderCreatesDifferentAddresses() public { + bytes32 salt1 = bytes32(uint256(1)); + bytes32 salt2 = bytes32(uint256(2)); + + vm.startPrank(deployer); + address permitter1 = + factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt1); + address permitter2 = + factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt2); + vm.stopPrank(); + + assertTrue(permitter1 != permitter2); + } + + function test_RevertsWhenTrustedSignerIsZero() public { + vm.expectRevert(IPermitter.InvalidTrustedSigner.selector); + vm.prank(deployer); + factory.createPermitter(address(0), MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); + } + + function test_RevertsWhenOwnerIsZero() public { + vm.expectRevert(IPermitter.InvalidOwner.selector); + vm.prank(deployer); + factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, address(0), DEFAULT_SALT); + } +} + +/// @notice Tests for predictPermitterAddress function. +contract PredictPermitterAddress is PermitterFactoryTest { + function test_PredictedAddressMatchesActualDeployment() public { + vm.startPrank(deployer); + + address predicted = + factory.predictPermitterAddress(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); + + address actual = + factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); + + vm.stopPrank(); + + assertEq(predicted, actual); + } + + function test_DifferentParametersProduceDifferentAddresses() public { + vm.startPrank(deployer); + + address addr1 = factory.predictPermitterAddress( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT + ); + + address addr2 = factory.predictPermitterAddress( + trustedSigner, MAX_TOTAL_ETH + 1, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT + ); + + address addr3 = factory.predictPermitterAddress( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER + 1, owner, DEFAULT_SALT + ); + + address differentOwner = makeAddr("differentOwner"); + address addr4 = factory.predictPermitterAddress( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, differentOwner, DEFAULT_SALT + ); + + address differentSigner = makeAddr("differentSigner"); + address addr5 = factory.predictPermitterAddress( + differentSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT + ); + + vm.stopPrank(); + + // All addresses should be different + assertTrue(addr1 != addr2); + assertTrue(addr1 != addr3); + assertTrue(addr1 != addr4); + assertTrue(addr1 != addr5); + assertTrue(addr2 != addr3); + assertTrue(addr2 != addr4); + assertTrue(addr2 != addr5); + assertTrue(addr3 != addr4); + assertTrue(addr3 != addr5); + assertTrue(addr4 != addr5); + } + + function test_SameSenderSameParamsProduceSameAddress() public { + vm.startPrank(deployer); + + address addr1 = factory.predictPermitterAddress( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT + ); + + address addr2 = factory.predictPermitterAddress( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT + ); + + vm.stopPrank(); + + assertEq(addr1, addr2); + } + + function test_DifferentSendersProduceDifferentAddresses() public { + address deployer2 = makeAddr("deployer2"); + + vm.prank(deployer); + address addr1 = factory.predictPermitterAddress( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT + ); + + vm.prank(deployer2); + address addr2 = factory.predictPermitterAddress( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT + ); + + assertTrue(addr1 != addr2); + } +} + +/// @notice Tests for multiple deployments. +contract MultipleDeployments is PermitterFactoryTest { + function test_DeployMultiplePermittersForSameOwner() public { + bytes32 salt1 = bytes32(uint256(1)); + bytes32 salt2 = bytes32(uint256(2)); + bytes32 salt3 = bytes32(uint256(3)); + + vm.startPrank(deployer); + + address permitter1 = + factory.createPermitter(trustedSigner, 50 ether, 500 ether, owner, salt1); + address permitter2 = + factory.createPermitter(trustedSigner, 100 ether, 1000 ether, owner, salt2); + address permitter3 = + factory.createPermitter(trustedSigner, 200 ether, 2000 ether, owner, salt3); + + vm.stopPrank(); + + // Verify all are different addresses + assertTrue(permitter1 != permitter2); + assertTrue(permitter2 != permitter3); + assertTrue(permitter1 != permitter3); + + // Verify each has correct configuration + assertEq(Permitter(permitter1).maxTotalEth(), 50 ether); + assertEq(Permitter(permitter2).maxTotalEth(), 100 ether); + assertEq(Permitter(permitter3).maxTotalEth(), 200 ether); + + assertEq(Permitter(permitter1).maxTokensPerBidder(), 500 ether); + assertEq(Permitter(permitter2).maxTokensPerBidder(), 1000 ether); + assertEq(Permitter(permitter3).maxTokensPerBidder(), 2000 ether); + } + + function test_DeployPermittersWithDifferentOwners() public { + address owner1 = makeAddr("owner1"); + address owner2 = makeAddr("owner2"); + + vm.startPrank(deployer); + + address permitter1 = + factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner1, bytes32(uint256(1))); + address permitter2 = + factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner2, bytes32(uint256(2))); + + vm.stopPrank(); + + assertEq(Permitter(permitter1).owner(), owner1); + assertEq(Permitter(permitter2).owner(), owner2); + + // Verify owner1 can modify permitter1 but not permitter2 + vm.prank(owner1); + Permitter(permitter1).pause(); + assertTrue(Permitter(permitter1).paused()); + + vm.expectRevert(IPermitter.Unauthorized.selector); + vm.prank(owner1); + Permitter(permitter2).pause(); + } + + function test_DeployPermittersWithDifferentSigners() public { + address signer1 = makeAddr("signer1"); + address signer2 = makeAddr("signer2"); + + vm.startPrank(deployer); + + address permitter1 = + factory.createPermitter(signer1, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, bytes32(uint256(1))); + address permitter2 = + factory.createPermitter(signer2, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, bytes32(uint256(2))); + + vm.stopPrank(); + + assertEq(Permitter(permitter1).trustedSigner(), signer1); + assertEq(Permitter(permitter2).trustedSigner(), signer2); + } +} From a595b58e276a3c0cc522bdf632039303309c8460 Mon Sep 17 00:00:00 2001 From: Raf Solari Date: Wed, 31 Dec 2025 15:00:31 -0500 Subject: [PATCH 2/3] Fix CI failures: test naming conventions and fuzz test rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename test_Reverts* to test_RevertIf_* for scopelint compliance - Fix fuzz tests that used vm.assume on array lengths causing too many rejections - Replace array fuzz params with bound() on numBids/seed for deterministic random generation - Apply scopelint formatting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- foundry.toml | 6 +-- src/Permitter.sol | 27 +++++++------ src/PermitterFactory.sol | 4 +- src/interfaces/IPermitter.sol | 14 +++++-- test/Fuzz.t.sol | 74 +++++++++++++++++++++-------------- test/Integration.t.sol | 30 +++++++++----- test/Permitter.t.sol | 51 ++++++++++++++---------- test/PermitterFactory.t.sol | 66 ++++++++++++++++++------------- 8 files changed, 164 insertions(+), 108 deletions(-) diff --git a/foundry.toml b/foundry.toml index d26d5b4..6b38397 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,11 +2,11 @@ evm_version = "cancun" optimizer = true optimizer_runs = 10_000_000 - solc_version = "0.8.30" - verbosity = 3 remappings = [ - "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/" + "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/", ] + solc_version = "0.8.30" + verbosity = 3 [profile.ci] fuzz = { runs = 5000 } diff --git a/src/Permitter.sol b/src/Permitter.sol index 725b7b0..469c35a 100644 --- a/src/Permitter.sol +++ b/src/Permitter.sol @@ -47,9 +47,12 @@ contract Permitter is IPermitter, EIP712 { /// @param _maxTotalEth Maximum total ETH that can be raised. /// @param _maxTokensPerBidder Maximum tokens any single bidder can purchase. /// @param _owner Address that can update caps and pause. - constructor(address _trustedSigner, uint256 _maxTotalEth, uint256 _maxTokensPerBidder, address _owner) - EIP712("Permitter", "1") - { + constructor( + address _trustedSigner, + uint256 _maxTotalEth, + uint256 _maxTokensPerBidder, + address _owner + ) EIP712("Permitter", "1") { if (_trustedSigner == address(0)) revert InvalidTrustedSigner(); if (_owner == address(0)) revert InvalidOwner(); @@ -79,14 +82,10 @@ contract Permitter is IPermitter, EIP712 { // 4. MODERATE: Verify EIP-712 signature address recovered = _recoverSigner(permit, signature); - if (recovered != trustedSigner) { - revert InvalidSignature(trustedSigner, recovered); - } + if (recovered != trustedSigner) revert InvalidSignature(trustedSigner, recovered); // 5. Check permit is for this bidder - if (permit.bidder != bidder) { - revert InvalidSignature(bidder, permit.bidder); - } + if (permit.bidder != bidder) revert InvalidSignature(bidder, permit.bidder); // 6. STORAGE READ: Check individual cap uint256 alreadyBid = cumulativeBids[bidder]; @@ -103,9 +102,7 @@ contract Permitter is IPermitter, EIP712 { // 7. STORAGE READ: Check global cap uint256 alreadyRaised = totalEthRaised; uint256 newTotalEth = alreadyRaised + ethValue; - if (newTotalEth > maxTotalEth) { - revert ExceedsTotalCap(ethValue, maxTotalEth, alreadyRaised); - } + if (newTotalEth > maxTotalEth) revert ExceedsTotalCap(ethValue, maxTotalEth, alreadyRaised); // 8. STORAGE WRITE: Update state cumulativeBids[bidder] = newCumulative; @@ -173,7 +170,11 @@ contract Permitter is IPermitter, EIP712 { /// @param permit The permit struct. /// @param signature The EIP-712 signature. /// @return The recovered signer address. - function _recoverSigner(Permit memory permit, bytes memory signature) internal view returns (address) { + function _recoverSigner(Permit memory permit, bytes memory signature) + internal + view + returns (address) + { bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); bytes32 digest = _hashTypedDataV4(structHash); diff --git a/src/PermitterFactory.sol b/src/PermitterFactory.sol index 9cdcf12..0309097 100644 --- a/src/PermitterFactory.sol +++ b/src/PermitterFactory.sol @@ -49,7 +49,9 @@ contract PermitterFactory is IPermitterFactory { // Compute the CREATE2 address return address( - uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), finalSalt, initCodeHash)))) + uint160( + uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), finalSalt, initCodeHash))) + ) ); } } diff --git a/src/interfaces/IPermitter.sol b/src/interfaces/IPermitter.sol index 965c86d..ee2e481 100644 --- a/src/interfaces/IPermitter.sol +++ b/src/interfaces/IPermitter.sol @@ -61,7 +61,10 @@ interface IPermitter { /// @param remainingPersonalCap The remaining tokens the bidder can purchase. /// @param remainingTotalCap The remaining ETH that can be raised. event PermitVerified( - address indexed bidder, uint256 bidAmount, uint256 remainingPersonalCap, uint256 remainingTotalCap + address indexed bidder, + uint256 bidAmount, + uint256 remainingPersonalCap, + uint256 remainingTotalCap ); /// @notice Emitted when a cap is updated. @@ -90,9 +93,12 @@ interface IPermitter { /// @param ethValue Amount of ETH being bid (passed by CCA contract). /// @param permitData ABI-encoded permit signature and metadata. /// @return valid True if bid is permitted, reverts otherwise with custom error. - function validateBid(address bidder, uint256 bidAmount, uint256 ethValue, bytes calldata permitData) - external - returns (bool valid); + function validateBid( + address bidder, + uint256 bidAmount, + uint256 ethValue, + bytes calldata permitData + ) external returns (bool valid); /// @notice Update the maximum total ETH cap (owner only). /// @param newMaxTotalEth New ETH cap. diff --git a/test/Fuzz.t.sol b/test/Fuzz.t.sol index 9ec7462..fc72f4e 100644 --- a/test/Fuzz.t.sol +++ b/test/Fuzz.t.sol @@ -55,8 +55,9 @@ contract FuzzTest is Test { uint256 _expiry, uint256 _privateKey ) 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, maxBidAmount: _maxBidAmount, expiry: _expiry + }); bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); @@ -140,11 +141,13 @@ 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[] memory bidAmounts) - public - { - vm.assume(permitMax > 0 && permitMax <= MAX_TOKENS_PER_BIDDER); - vm.assume(bidAmounts.length > 0 && bidAmounts.length <= 10); + function testFuzz_CumulativeBidsNeverExceedPermitMax( + uint256 permitMax, + uint256 numBids, + uint256 seed + ) public { + permitMax = bound(permitMax, 1, MAX_TOKENS_PER_BIDDER); + numBids = bound(numBids, 1, 10); address bidder = makeAddr("fuzzBidder"); uint256 expiry = block.timestamp + 1 hours; @@ -152,8 +155,9 @@ contract CapEnforcementFuzz is FuzzTest { uint256 totalBid = 0; - for (uint256 i = 0; i < bidAmounts.length; i++) { - uint256 bidAmount = bound(bidAmounts[i], 1, permitMax); + 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); if (totalBid + bidAmount <= permitMax) { // Bid should succeed @@ -176,15 +180,17 @@ contract CapEnforcementFuzz is FuzzTest { } /// @notice Fuzz test that total ETH raised never exceeds max. - function testFuzz_TotalEthNeverExceedsMax(uint256[] memory ethValues) public { - vm.assume(ethValues.length > 0 && ethValues.length <= 10); + function testFuzz_TotalEthNeverExceedsMax(uint256 numBids, uint256 seed) public { + numBids = bound(numBids, 1, 10); uint256 expiry = block.timestamp + 1 hours; uint256 totalEth = 0; - for (uint256 i = 0; i < ethValues.length; i++) { + for (uint256 i = 0; i < numBids; i++) { + // forge-lint: disable-next-line(unsafe-typecast) address bidder = address(uint160(i + 1)); - uint256 ethValue = bound(ethValues[i], 1, MAX_TOTAL_ETH); + // 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); @@ -196,7 +202,9 @@ contract CapEnforcementFuzz is FuzzTest { } else { // Should fail vm.expectRevert( - abi.encodeWithSelector(IPermitter.ExceedsTotalCap.selector, ethValue, MAX_TOTAL_ETH, totalEth) + abi.encodeWithSelector( + IPermitter.ExceedsTotalCap.selector, ethValue, MAX_TOTAL_ETH, totalEth + ) ); permitter.validateBid(bidder, 100 ether, ethValue, permitData); } @@ -311,11 +319,13 @@ contract FactoryFuzz is FuzzTest { vm.startPrank(deployer); - address predicted = - factory.predictPermitterAddress(fuzzedTrustedSigner, maxTotalEth, maxTokensPerBidder, fuzzedOwner, salt); + address predicted = factory.predictPermitterAddress( + fuzzedTrustedSigner, maxTotalEth, maxTokensPerBidder, fuzzedOwner, salt + ); - address actual = - factory.createPermitter(fuzzedTrustedSigner, maxTotalEth, maxTokensPerBidder, fuzzedOwner, salt); + address actual = factory.createPermitter( + fuzzedTrustedSigner, maxTotalEth, maxTokensPerBidder, fuzzedOwner, salt + ); vm.stopPrank(); @@ -330,11 +340,13 @@ contract FactoryFuzz is FuzzTest { vm.startPrank(deployer); - address addr1 = - factory.predictPermitterAddress(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt1); + address addr1 = factory.predictPermitterAddress( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt1 + ); - address addr2 = - factory.predictPermitterAddress(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt2); + address addr2 = factory.predictPermitterAddress( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt2 + ); vm.stopPrank(); @@ -342,20 +354,24 @@ contract FactoryFuzz is FuzzTest { } /// @notice Fuzz test that different deployers with same salt produce different addresses. - function testFuzz_DifferentDeployersDifferentAddresses(address deployer1, address deployer2, bytes32 salt) - public - { + function testFuzz_DifferentDeployersDifferentAddresses( + address deployer1, + address deployer2, + bytes32 salt + ) public { vm.assume(deployer1 != deployer2); vm.assume(deployer1 != address(0)); vm.assume(deployer2 != address(0)); vm.prank(deployer1); - address addr1 = - factory.predictPermitterAddress(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt); + address addr1 = factory.predictPermitterAddress( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt + ); vm.prank(deployer2); - address addr2 = - factory.predictPermitterAddress(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt); + address addr2 = factory.predictPermitterAddress( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, salt + ); assertTrue(addr1 != addr2); } diff --git a/test/Integration.t.sol b/test/Integration.t.sol index a39a021..8fd63a9 100644 --- a/test/Integration.t.sol +++ b/test/Integration.t.sol @@ -40,8 +40,9 @@ contract IntegrationTest is Test { // Deploy a Permitter for a specific auction vm.prank(deployer); - address permitterAddress = - factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, auctionOwner, AUCTION_SALT); + address permitterAddress = factory.createPermitter( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, auctionOwner, AUCTION_SALT + ); permitter = Permitter(permitterAddress); } @@ -149,7 +150,9 @@ contract FullAuctionLifecycle is IntegrationTest { // Next bid should fail vm.expectRevert( - abi.encodeWithSelector(IPermitter.ExceedsPersonalCap.selector, 1 ether, personalCap, 500 ether) + abi.encodeWithSelector( + IPermitter.ExceedsPersonalCap.selector, 1 ether, personalCap, 500 ether + ) ); permitter.validateBid(bidder1, 1 ether, 0.01 ether, permit); } @@ -207,7 +210,12 @@ contract EmergencyScenarios is IntegrationTest { IPermitter.Permit({bidder: bidder1, maxBidAmount: MAX_TOKENS_PER_BIDDER, expiry: expiry}); bytes32 structHash = keccak256( - abi.encode(PERMIT_TYPEHASH, newPermitStruct.bidder, newPermitStruct.maxBidAmount, newPermitStruct.expiry) + abi.encode( + PERMIT_TYPEHASH, + newPermitStruct.bidder, + newPermitStruct.maxBidAmount, + newPermitStruct.expiry + ) ); bytes32 domainSeparator = permitter.domainSeparator(); @@ -347,8 +355,10 @@ contract MultipleAuctions is IntegrationTest { // 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 permit2 = + _createPermitSignatureForPermitter(bidder1, 300 ether, expiry, permitter2); + bytes memory permit3 = + _createPermitSignatureForPermitter(bidder1, 1000 ether, expiry, permitter3); // Bid in all auctions permitter.validateBid(bidder1, 100 ether, 10 ether, permit1); @@ -369,7 +379,8 @@ 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, MAX_TOKENS_PER_BIDDER, expiry, permitter); // Try to use it in auction 2 - should fail because domain separator is different vm.expectRevert(); @@ -382,8 +393,9 @@ contract MultipleAuctions is IntegrationTest { 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, maxBidAmount: _maxBidAmount, expiry: _expiry + }); bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); diff --git a/test/Permitter.t.sol b/test/Permitter.t.sol index 317c31b..f98f665 100644 --- a/test/Permitter.t.sol +++ b/test/Permitter.t.sol @@ -49,8 +49,9 @@ contract PermitterTest is Test { uint256 _expiry, uint256 _privateKey ) 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, maxBidAmount: _maxBidAmount, expiry: _expiry + }); bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry)); @@ -76,12 +77,12 @@ contract Constructor is PermitterTest { assertEq(permitter.totalEthRaised(), 0); } - function test_RevertsWhenTrustedSignerIsZero() public { + function test_RevertIf_TrustedSignerIsZero() public { vm.expectRevert(IPermitter.InvalidTrustedSigner.selector); new Permitter(address(0), MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner); } - function test_RevertsWhenOwnerIsZero() public { + function test_RevertIf_OwnerIsZero() public { vm.expectRevert(IPermitter.InvalidOwner.selector); new Permitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, address(0)); } @@ -152,7 +153,7 @@ contract ValidateBidSuccess is PermitterTest { /// @notice Tests for validateBid reverts. contract ValidateBidRevert is PermitterTest { - function test_RevertsWhenPaused() public { + function test_RevertIf_Paused() public { uint256 expiry = block.timestamp + 1 hours; bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); @@ -163,15 +164,17 @@ contract ValidateBidRevert is PermitterTest { permitter.validateBid(bidder, 100 ether, 1 ether, permitData); } - function test_RevertsWhenSignatureExpired() public { + function test_RevertIf_SignatureExpired() public { uint256 expiry = block.timestamp - 1; // Already expired bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); - vm.expectRevert(abi.encodeWithSelector(IPermitter.SignatureExpired.selector, expiry, block.timestamp)); + vm.expectRevert( + abi.encodeWithSelector(IPermitter.SignatureExpired.selector, expiry, block.timestamp) + ); permitter.validateBid(bidder, 100 ether, 1 ether, permitData); } - function test_RevertsWhenSignatureFromWrongSigner() public { + function test_RevertIf_SignatureFromWrongSigner() public { uint256 wrongSignerKey = 0x5678; address wrongSigner = vm.addr(wrongSignerKey); uint256 expiry = block.timestamp + 1 hours; @@ -185,16 +188,18 @@ contract ValidateBidRevert is PermitterTest { permitter.validateBid(bidder, 100 ether, 1 ether, permitData); } - function test_RevertsWhenBidderMismatch() public { + 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); - vm.expectRevert(abi.encodeWithSelector(IPermitter.InvalidSignature.selector, bidder, otherBidder)); + vm.expectRevert( + abi.encodeWithSelector(IPermitter.InvalidSignature.selector, bidder, otherBidder) + ); permitter.validateBid(bidder, 100 ether, 1 ether, permitData); } - function test_RevertsWhenExceedsPermitMaxBidAmount() public { + function test_RevertIf_ExceedsPermitMaxBidAmount() public { uint256 permitMax = 500 ether; uint256 expiry = block.timestamp + 1 hours; bytes memory permitData = _createPermitSignature(bidder, permitMax, expiry); @@ -204,12 +209,14 @@ contract ValidateBidRevert is PermitterTest { // Second bid exceeds permit max vm.expectRevert( - abi.encodeWithSelector(IPermitter.ExceedsPersonalCap.selector, 200 ether, permitMax, 400 ether) + abi.encodeWithSelector( + IPermitter.ExceedsPersonalCap.selector, 200 ether, permitMax, 400 ether + ) ); permitter.validateBid(bidder, 200 ether, 2 ether, permitData); } - function test_RevertsWhenExceedsGlobalMaxTokensPerBidder() public { + function test_RevertIf_ExceedsGlobalMaxTokensPerBidder() public { // Set a lower global cap than the permit allows vm.prank(owner); permitter.updateMaxTokensPerBidder(300 ether); @@ -222,12 +229,14 @@ contract ValidateBidRevert is PermitterTest { // Second bid exceeds global max vm.expectRevert( - abi.encodeWithSelector(IPermitter.ExceedsPersonalCap.selector, 200 ether, 300 ether, 200 ether) + abi.encodeWithSelector( + IPermitter.ExceedsPersonalCap.selector, 200 ether, 300 ether, 200 ether + ) ); permitter.validateBid(bidder, 200 ether, 2 ether, permitData); } - function test_RevertsWhenExceedsTotalEthCap() public { + function test_RevertIf_ExceedsTotalEthCap() public { uint256 expiry = block.timestamp + 1 hours; bytes memory permitData = _createPermitSignature(bidder, MAX_TOKENS_PER_BIDDER, expiry); @@ -256,7 +265,7 @@ contract OwnerFunctions is PermitterTest { assertEq(permitter.maxTotalEth(), newCap); } - function test_UpdateMaxTotalEthRevertsForNonOwner() public { + function test_RevertIf_UpdateMaxTotalEthCalledByNonOwner() public { vm.expectRevert(IPermitter.Unauthorized.selector); vm.prank(bidder); permitter.updateMaxTotalEth(200 ether); @@ -274,7 +283,7 @@ contract OwnerFunctions is PermitterTest { assertEq(permitter.maxTokensPerBidder(), newCap); } - function test_UpdateMaxTokensPerBidderRevertsForNonOwner() public { + function test_RevertIf_UpdateMaxTokensPerBidderCalledByNonOwner() public { vm.expectRevert(IPermitter.Unauthorized.selector); vm.prank(bidder); permitter.updateMaxTokensPerBidder(2000 ether); @@ -292,13 +301,13 @@ contract OwnerFunctions is PermitterTest { assertEq(permitter.trustedSigner(), newSigner); } - function test_UpdateTrustedSignerRevertsForNonOwner() public { + function test_RevertIf_UpdateTrustedSignerCalledByNonOwner() public { vm.expectRevert(IPermitter.Unauthorized.selector); vm.prank(bidder); permitter.updateTrustedSigner(makeAddr("newSigner")); } - function test_UpdateTrustedSignerRevertsForZeroAddress() public { + function test_RevertIf_UpdateTrustedSignerWithZeroAddress() public { vm.expectRevert(IPermitter.InvalidTrustedSigner.selector); vm.prank(owner); permitter.updateTrustedSigner(address(0)); @@ -314,7 +323,7 @@ contract OwnerFunctions is PermitterTest { assertTrue(permitter.paused()); } - function test_PauseRevertsForNonOwner() public { + function test_RevertIf_PauseCalledByNonOwner() public { vm.expectRevert(IPermitter.Unauthorized.selector); vm.prank(bidder); permitter.pause(); @@ -333,7 +342,7 @@ contract OwnerFunctions is PermitterTest { assertFalse(permitter.paused()); } - function test_UnpauseRevertsForNonOwner() public { + function test_RevertIf_UnpauseCalledByNonOwner() public { vm.prank(owner); permitter.pause(); diff --git a/test/PermitterFactory.t.sol b/test/PermitterFactory.t.sol index 4bef880..b1a70e0 100644 --- a/test/PermitterFactory.t.sol +++ b/test/PermitterFactory.t.sol @@ -30,8 +30,9 @@ contract PermitterFactoryTest is Test { contract CreatePermitter is PermitterFactoryTest { function test_DeploysPermitterWithCorrectParameters() public { vm.prank(deployer); - address permitterAddress = - factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); + address permitterAddress = factory.createPermitter( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT + ); Permitter permitter = Permitter(permitterAddress); @@ -57,19 +58,23 @@ contract CreatePermitter is PermitterFactoryTest { MAX_TOKENS_PER_BIDDER ); - factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); + factory.createPermitter( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT + ); } function test_SameSaltFromDifferentSendersCreatesDifferentAddresses() public { address deployer2 = makeAddr("deployer2"); vm.prank(deployer); - address permitter1 = - factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); + address permitter1 = factory.createPermitter( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT + ); vm.prank(deployer2); - address permitter2 = - factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); + address permitter2 = factory.createPermitter( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT + ); assertTrue(permitter1 != permitter2); } @@ -88,16 +93,18 @@ contract CreatePermitter is PermitterFactoryTest { assertTrue(permitter1 != permitter2); } - function test_RevertsWhenTrustedSignerIsZero() public { + function test_RevertIf_TrustedSignerIsZero() public { vm.expectRevert(IPermitter.InvalidTrustedSigner.selector); vm.prank(deployer); factory.createPermitter(address(0), MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); } - function test_RevertsWhenOwnerIsZero() public { + function test_RevertIf_OwnerIsZero() public { vm.expectRevert(IPermitter.InvalidOwner.selector); vm.prank(deployer); - factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, address(0), DEFAULT_SALT); + factory.createPermitter( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, address(0), DEFAULT_SALT + ); } } @@ -106,11 +113,13 @@ contract PredictPermitterAddress is PermitterFactoryTest { function test_PredictedAddressMatchesActualDeployment() public { vm.startPrank(deployer); - address predicted = - factory.predictPermitterAddress(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); + address predicted = factory.predictPermitterAddress( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT + ); - address actual = - factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT); + address actual = factory.createPermitter( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, DEFAULT_SALT + ); vm.stopPrank(); @@ -199,12 +208,9 @@ contract MultipleDeployments is PermitterFactoryTest { vm.startPrank(deployer); - address permitter1 = - factory.createPermitter(trustedSigner, 50 ether, 500 ether, owner, salt1); - address permitter2 = - factory.createPermitter(trustedSigner, 100 ether, 1000 ether, owner, salt2); - address permitter3 = - factory.createPermitter(trustedSigner, 200 ether, 2000 ether, owner, salt3); + address permitter1 = factory.createPermitter(trustedSigner, 50 ether, 500 ether, owner, salt1); + address permitter2 = factory.createPermitter(trustedSigner, 100 ether, 1000 ether, owner, salt2); + address permitter3 = factory.createPermitter(trustedSigner, 200 ether, 2000 ether, owner, salt3); vm.stopPrank(); @@ -229,10 +235,12 @@ contract MultipleDeployments is PermitterFactoryTest { vm.startPrank(deployer); - address permitter1 = - factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner1, bytes32(uint256(1))); - address permitter2 = - factory.createPermitter(trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner2, bytes32(uint256(2))); + address permitter1 = factory.createPermitter( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner1, bytes32(uint256(1)) + ); + address permitter2 = factory.createPermitter( + trustedSigner, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner2, bytes32(uint256(2)) + ); vm.stopPrank(); @@ -255,10 +263,12 @@ contract MultipleDeployments is PermitterFactoryTest { vm.startPrank(deployer); - address permitter1 = - factory.createPermitter(signer1, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, bytes32(uint256(1))); - address permitter2 = - factory.createPermitter(signer2, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, bytes32(uint256(2))); + address permitter1 = factory.createPermitter( + signer1, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, bytes32(uint256(1)) + ); + address permitter2 = factory.createPermitter( + signer2, MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner, bytes32(uint256(2)) + ); vm.stopPrank(); From 330c6c9eb8839d298394629d9b3b826a3a5c48c2 Mon Sep 17 00:00:00 2001 From: Raf Solari Date: Wed, 31 Dec 2025 15:21:53 -0500 Subject: [PATCH 3/3] Add pull-requests write permission for coverage job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lcov-reporter-action requires write permission to post coverage comments on PRs. Added the permissions block to the coverage job. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a4e927..3da526b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,9 @@ jobs: coverage: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write env: FOUNDRY_PROFILE: coverage steps: