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: 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..6b38397 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,6 +2,9 @@ evm_version = "cancun" optimizer = true optimizer_runs = 10_000_000 + remappings = [ + "@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/", + ] solc_version = "0.8.30" verbosity = 3 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..469c35a --- /dev/null +++ b/src/Permitter.sol @@ -0,0 +1,183 @@ +// 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..0309097 --- /dev/null +++ b/src/PermitterFactory.sol @@ -0,0 +1,57 @@ +// 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..ee2e481 --- /dev/null +++ b/src/interfaces/IPermitter.sol @@ -0,0 +1,150 @@ +// 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..fc72f4e --- /dev/null +++ b/test/Fuzz.t.sol @@ -0,0 +1,378 @@ +// 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 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; + bytes memory permitData = _createPermitSignature(bidder, permitMax, expiry); + + uint256 totalBid = 0; + + for (uint256 i = 0; i < numBids; i++) { + // Generate deterministic random bid amounts from seed + uint256 bidAmount = bound(uint256(keccak256(abi.encode(seed, i))), 1, permitMax); + + 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 numBids, uint256 seed) public { + numBids = bound(numBids, 1, 10); + + uint256 expiry = block.timestamp + 1 hours; + uint256 totalEth = 0; + + for (uint256 i = 0; i < numBids; i++) { + // forge-lint: disable-next-line(unsafe-typecast) + address bidder = address(uint160(i + 1)); + // 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); + + 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..8fd63a9 --- /dev/null +++ b/test/Integration.t.sol @@ -0,0 +1,411 @@ +// 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..f98f665 --- /dev/null +++ b/test/Permitter.t.sol @@ -0,0 +1,429 @@ +// 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_RevertIf_TrustedSignerIsZero() public { + vm.expectRevert(IPermitter.InvalidTrustedSigner.selector); + new Permitter(address(0), MAX_TOTAL_ETH, MAX_TOKENS_PER_BIDDER, owner); + } + + function test_RevertIf_OwnerIsZero() 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_RevertIf_Paused() 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_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) + ); + permitter.validateBid(bidder, 100 ether, 1 ether, permitData); + } + + function test_RevertIf_SignatureFromWrongSigner() 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_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) + ); + permitter.validateBid(bidder, 100 ether, 1 ether, permitData); + } + + function test_RevertIf_ExceedsPermitMaxBidAmount() public { + uint256 permitMax = 500 ether; + uint256 expiry = block.timestamp + 1 hours; + bytes memory permitData = _createPermitSignature(bidder, permitMax, expiry); + + // First bid succeeds + 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_RevertIf_ExceedsGlobalMaxTokensPerBidder() 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_RevertIf_ExceedsTotalEthCap() 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_RevertIf_UpdateMaxTotalEthCalledByNonOwner() 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_RevertIf_UpdateMaxTokensPerBidderCalledByNonOwner() 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_RevertIf_UpdateTrustedSignerCalledByNonOwner() public { + vm.expectRevert(IPermitter.Unauthorized.selector); + vm.prank(bidder); + permitter.updateTrustedSigner(makeAddr("newSigner")); + } + + function test_RevertIf_UpdateTrustedSignerWithZeroAddress() 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_RevertIf_PauseCalledByNonOwner() 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_RevertIf_UnpauseCalledByNonOwner() 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..b1a70e0 --- /dev/null +++ b/test/PermitterFactory.t.sol @@ -0,0 +1,278 @@ +// 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_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_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 + ); + } +} + +/// @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); + } +}