Skip to content
This repository was archived by the owner on Feb 5, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions foundry.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,11 @@
"name": "v1.11.0",
"rev": "8e40513d678f392f398620b3ef2b418648b33e89"
}
},
"lib/openzeppelin-contracts": {
"tag": {
"name": "v5.5.0",
"rev": "fcbae5394ae8ad52d8e580a3477db99814b9d565"
}
}
}
3 changes: 3 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions lib/openzeppelin-contracts
Submodule openzeppelin-contracts added at fcbae5
10 changes: 7 additions & 3 deletions script/Deploy.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
14 changes: 0 additions & 14 deletions src/Counter.sol

This file was deleted.

182 changes: 182 additions & 0 deletions src/Permitter.sol
Original file line number Diff line number Diff line change
@@ -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;

Check warning on line 34 in src/Permitter.sol

View check run for this annotation

GitHub Advanced Security / Slither

State variables that could be declared immutable

Permitter.owner (src/Permitter.sol#34) should be immutable

Check warning

Code scanning / Slither

State variables that could be declared immutable Warning

Permitter.owner should be immutable

/// @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;
}

Check notice on line 120 in src/Permitter.sol

View check run for this annotation

GitHub Advanced Security / Slither

Block timestamp

Permitter.validateBid(address,uint256,uint256,bytes) (src/Permitter.sol#63-120) uses timestamp for comparisons Dangerous comparisons: - block.timestamp > permit.expiry (src/Permitter.sol#76)
Comment on lines +66 to +117

Check notice

Code scanning / Slither

Block timestamp Low

Permitter.validateBid(address,uint256,uint256,bytes) uses timestamp for comparisons
Dangerous comparisons:
- block.timestamp > permit.expiry

/// @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);
}
}
55 changes: 55 additions & 0 deletions src/PermitterFactory.sol
Original file line number Diff line number Diff line change
@@ -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))))
);
}

Check warning on line 54 in src/PermitterFactory.sol

View check run for this annotation

GitHub Advanced Security / Slither

Too many digits

PermitterFactory.predictPermitterAddress(address,uint256,uint256,address,bytes32) (src/PermitterFactory.sol#33-54) uses literals with too many digits: - initCode = abi.encodePacked(type()(Permitter).creationCode,abi.encode(trustedSigner,maxTotalEth,maxTokensPerBidder,owner)) (src/PermitterFactory.sol#44-47)
}
Loading
Loading