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 all commits
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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ jobs:

coverage:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
env:
FOUNDRY_PROFILE: coverage
steps:
Expand Down
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 @@ -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

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.

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

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 117 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#66-117) uses timestamp for comparisons Dangerous comparisons: - block.timestamp > permit.expiry (src/Permitter.sol#79)

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

Check warning on line 56 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-56) 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