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
1 change: 1 addition & 0 deletions .claude/commands
Submodule commands added at 8d9bfb
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
[submodule "lib/openzeppelin-contracts"]
path = lib/openzeppelin-contracts
url = https://github.com/OpenZeppelin/openzeppelin-contracts
[submodule ".claude/commands"]
path = .claude/commands
url = https://github.com/withtally/claude-commands
20 changes: 20 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"mcpServers": {
"linear": {
"type": "stdio",
"command": "npx",
"args": [
"mcp-remote",
"https://mcp.linear.app/sse"
],
"env": {}
},
"context7": {
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp@latest"
]
}
}
}
30 changes: 30 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
.PHONY: install build test fmt lint coverage clean

# Install dependencies and tools
install:
forge install
cargo install scopelint

# Build contracts
build:
forge build

# Run tests
test:
forge test

# Format code
fmt:
scopelint fmt

# Check linting (formatting + validation)
lint:
scopelint check

# Run coverage
coverage:
forge coverage --report summary --report lcov

# Clean build artifacts
clean:
forge clean
3 changes: 3 additions & 0 deletions foundry.lock
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
".claude/commands": {
"rev": "8d9bfb8d1b8c029d9984cada2fa834cece331469"
},
"lib/forge-std": {
"tag": {
"name": "v1.11.0",
Expand Down
88 changes: 65 additions & 23 deletions src/Permitter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@
/// chainId and verifyingContract to prevent cross-chain and cross-auction replay attacks.
contract Permitter is IPermitter, EIP712 {
/// @notice EIP-712 typehash for the Permit struct.
bytes32 public constant PERMIT_TYPEHASH =
keccak256("Permit(address bidder,uint256 maxBidAmount,uint256 expiry)");
bytes32 public constant PERMIT_TYPEHASH = keccak256("Permit(address bidder,uint256 expiry)");

/// @notice Timelock delay for parameter updates (1 hour).
uint256 public constant UPDATE_DELAY = 1 hours;
Expand All @@ -27,6 +26,9 @@
/// @notice Maximum tokens any single bidder can purchase.
uint256 public maxTokensPerBidder;

/// @notice Minimum tokens any single bidder must purchase per bid.
uint256 public minTokensPerBidder;

/// @notice Cumulative bid amounts per address.
mapping(address bidder => uint256 amount) public cumulativeBids;

Expand Down Expand Up @@ -54,6 +56,12 @@
/// @notice Time when pending maxTokensPerBidder update can be executed.
uint256 public pendingMaxTokensPerBidderTime;

/// @notice Pending minTokensPerBidder update value.
uint256 public pendingMinTokensPerBidder;

/// @notice Time when pending minTokensPerBidder update can be executed.
uint256 public pendingMinTokensPerBidderTime;

/// @notice Pending trustedSigner update address.
address public pendingTrustedSigner;

Expand All @@ -70,23 +78,29 @@
/// @param _trustedSigner Address authorized to sign permits.
/// @param _maxTotalEth Maximum total ETH that can be raised.
/// @param _maxTokensPerBidder Maximum tokens any single bidder can purchase.
/// @param _minTokensPerBidder Minimum tokens any single bidder must purchase per bid.
/// @param _owner Address that can update caps and pause.
/// @param _authorizedCaller CCA contract authorized to call validateBid.
constructor(
address _trustedSigner,
uint256 _maxTotalEth,
uint256 _maxTokensPerBidder,
uint256 _minTokensPerBidder,
address _owner,
address _authorizedCaller
) EIP712("Permitter", "1") {
if (_trustedSigner == address(0)) revert InvalidTrustedSigner();
if (_owner == address(0)) revert InvalidOwner();
if (_maxTotalEth == 0) revert InvalidCap();
if (_maxTokensPerBidder == 0) revert InvalidCap();
if (_minTokensPerBidder > _maxTokensPerBidder) {
revert MinTokensExceedsMaxTokens(_minTokensPerBidder, _maxTokensPerBidder);
}

trustedSigner = _trustedSigner;
maxTotalEth = _maxTotalEth;
maxTokensPerBidder = _maxTokensPerBidder;
minTokensPerBidder = _minTokensPerBidder;
owner = _owner;
authorizedCaller = _authorizedCaller;
}
Expand All @@ -104,45 +118,41 @@
// 1. CHEAPEST: Check if paused
if (paused) revert ContractPaused();

// 2. Decode permit data
// 2. CHEAP: Check minimum bid amount
if (bidAmount < minTokensPerBidder) revert BidBelowMinimum(bidAmount, minTokensPerBidder);

// 3. Decode permit data
(Permit memory permit, bytes memory signature) = abi.decode(permitData, (Permit, bytes));

// 3. CHEAP: Check time window
if (block.timestamp > permit.expiry) {
revert SignatureExpired(permit.expiry, block.timestamp);
}
// 4. CHEAP: Check time window
if (block.timestamp > permit.expiry) revert SignatureExpired(permit.expiry, block.timestamp);

// 4. MODERATE: Verify EIP-712 signature
// 5. MODERATE: Verify EIP-712 signature
address recovered = _recoverSigner(permit, signature);
if (recovered != trustedSigner) revert InvalidSignature(trustedSigner, recovered);

// 5. Check permit is for this bidder
// 6. Check permit is for this bidder
if (permit.bidder != bidder) revert InvalidSignature(bidder, permit.bidder);

// 6. STORAGE READ: Check individual cap
// 7. STORAGE READ: Check individual cap using maxTokensPerBidder
uint256 alreadyBid = cumulativeBids[bidder];
uint256 newCumulative = alreadyBid + bidAmount;
if (newCumulative > permit.maxBidAmount) {
revert ExceedsPersonalCap(bidAmount, permit.maxBidAmount, alreadyBid);
}

// Also check against global maxTokensPerBidder if it's lower
if (newCumulative > maxTokensPerBidder) {
revert ExceedsPersonalCap(bidAmount, maxTokensPerBidder, alreadyBid);
}

// 7. STORAGE READ: Check global cap
// 8. STORAGE READ: Check global cap
uint256 alreadyRaised = totalEthRaised;
uint256 newTotalEth = alreadyRaised + ethValue;
if (newTotalEth > maxTotalEth) revert ExceedsTotalCap(ethValue, maxTotalEth, alreadyRaised);

// 8. STORAGE WRITE: Update state
// 9. STORAGE WRITE: Update state
cumulativeBids[bidder] = newCumulative;
totalEthRaised = newTotalEth;

// 9. Emit event for monitoring
// 10. Emit event for monitoring
emit PermitVerified(
bidder, bidAmount, permit.maxBidAmount - newCumulative, maxTotalEth - newTotalEth
bidder, bidAmount, maxTokensPerBidder - newCumulative, maxTotalEth - newTotalEth
);

return true;
Expand Down Expand Up @@ -187,7 +197,7 @@
pendingMaxTokensPerBidder = newMaxTokensPerBidder;
pendingMaxTokensPerBidderTime = executeTime;

emit CapUpdateScheduled(CapType.TOKENS_PER_BIDDER, newMaxTokensPerBidder, executeTime);
emit CapUpdateScheduled(CapType.MAX_TOKENS_PER_BIDDER, newMaxTokensPerBidder, executeTime);
}

/// @inheritdoc IPermitter
Expand All @@ -204,8 +214,41 @@
pendingMaxTokensPerBidder = 0;
pendingMaxTokensPerBidderTime = 0;

emit CapUpdated(CapType.TOKENS_PER_BIDDER, oldCap, maxTokensPerBidder);
emit CapUpdated(CapType.MAX_TOKENS_PER_BIDDER, oldCap, maxTokensPerBidder);
}

/// @inheritdoc IPermitter
function scheduleUpdateMinTokensPerBidder(uint256 newMinTokensPerBidder) external onlyOwner {
if (newMinTokensPerBidder > maxTokensPerBidder) {
revert MinTokensExceedsMaxTokens(newMinTokensPerBidder, maxTokensPerBidder);
}

uint256 executeTime = block.timestamp + UPDATE_DELAY;
pendingMinTokensPerBidder = newMinTokensPerBidder;
pendingMinTokensPerBidderTime = executeTime;

emit CapUpdateScheduled(CapType.MIN_TOKENS_PER_BIDDER, newMinTokensPerBidder, executeTime);
}

/// @inheritdoc IPermitter
function executeUpdateMinTokensPerBidder() external onlyOwner {
if (pendingMinTokensPerBidderTime == 0) revert UpdateNotScheduled();
if (block.timestamp < pendingMinTokensPerBidderTime) {
revert UpdateTooEarly(pendingMinTokensPerBidderTime, block.timestamp);
}
if (pendingMinTokensPerBidder > maxTokensPerBidder) {
revert MinTokensExceedsMaxTokens(pendingMinTokensPerBidder, maxTokensPerBidder);
}

uint256 oldMin = minTokensPerBidder;
minTokensPerBidder = pendingMinTokensPerBidder;

// Clear pending update
pendingMinTokensPerBidder = 0;
pendingMinTokensPerBidderTime = 0;

emit CapUpdated(CapType.MIN_TOKENS_PER_BIDDER, oldMin, minTokensPerBidder);
}

Check notice on line 251 in src/Permitter.sol

View check run for this annotation

GitHub Advanced Security / Slither

Block timestamp

Permitter.executeUpdateMinTokensPerBidder() (src/Permitter.sol#234-251) uses timestamp for comparisons Dangerous comparisons: - pendingMinTokensPerBidderTime == 0 (src/Permitter.sol#235) - block.timestamp < pendingMinTokensPerBidderTime (src/Permitter.sol#236)

/// @inheritdoc IPermitter
function scheduleUpdateTrustedSigner(address newSigner) external onlyOwner {
Expand Down Expand Up @@ -279,8 +322,7 @@
view
returns (address)
{
bytes32 structHash =
keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.maxBidAmount, permit.expiry));
bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, permit.bidder, permit.expiry));
bytes32 digest = _hashTypedDataV4(structHash);
return ECDSA.recover(digest, signature);
}
Expand Down
16 changes: 13 additions & 3 deletions src/PermitterFactory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ contract PermitterFactory is IPermitterFactory {
address trustedSigner,
uint256 maxTotalEth,
uint256 maxTokensPerBidder,
uint256 minTokensPerBidder,
address owner,
address authorizedCaller,
bytes32 salt
Expand All @@ -25,12 +26,18 @@ contract PermitterFactory is IPermitterFactory {
// Deploy the Permitter using CREATE2
permitter = address(
new Permitter{salt: finalSalt}(
trustedSigner, maxTotalEth, maxTokensPerBidder, owner, authorizedCaller
trustedSigner, maxTotalEth, maxTokensPerBidder, minTokensPerBidder, owner, authorizedCaller
)
);

emit PermitterCreated(
permitter, owner, trustedSigner, authorizedCaller, maxTotalEth, maxTokensPerBidder
permitter,
owner,
trustedSigner,
authorizedCaller,
maxTotalEth,
maxTokensPerBidder,
minTokensPerBidder
);
}

Expand All @@ -39,6 +46,7 @@ contract PermitterFactory is IPermitterFactory {
address trustedSigner,
uint256 maxTotalEth,
uint256 maxTokensPerBidder,
uint256 minTokensPerBidder,
address owner,
address authorizedCaller,
bytes32 salt
Expand All @@ -49,7 +57,9 @@ contract PermitterFactory is IPermitterFactory {
// Compute the init code hash
bytes memory initCode = abi.encodePacked(
type(Permitter).creationCode,
abi.encode(trustedSigner, maxTotalEth, maxTokensPerBidder, owner, authorizedCaller)
abi.encode(
trustedSigner, maxTotalEth, maxTokensPerBidder, minTokensPerBidder, owner, authorizedCaller
)
);
bytes32 initCodeHash = keccak256(initCode);

Expand Down
36 changes: 33 additions & 3 deletions src/interfaces/IPermitter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,15 @@ interface IPermitter {
/// @notice Enum for cap types used in events.
enum CapType {
TOTAL_ETH,
TOKENS_PER_BIDDER
MAX_TOKENS_PER_BIDDER,
MIN_TOKENS_PER_BIDDER
}

/// @notice The permit structure containing bidder authorization data.
/// @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;
}

Expand Down Expand Up @@ -46,6 +45,11 @@ interface IPermitter {
/// @param alreadyRaised The amount already raised.
error ExceedsTotalCap(uint256 requested, uint256 cap, uint256 alreadyRaised);

/// @notice Emitted when a bid is below the minimum amount.
/// @param bidAmount The bid amount that was attempted.
/// @param minRequired The minimum required bid amount.
error BidBelowMinimum(uint256 bidAmount, uint256 minRequired);

/// @notice Emitted when the caller is not authorized.
error Unauthorized();

Expand All @@ -58,6 +62,11 @@ interface IPermitter {
/// @notice Emitted when a cap value is invalid (zero).
error InvalidCap();

/// @notice Emitted when minTokensPerBidder exceeds maxTokensPerBidder.
/// @param minTokens The minimum tokens per bidder.
/// @param maxTokens The maximum tokens per bidder.
error MinTokensExceedsMaxTokens(uint256 minTokens, uint256 maxTokens);

/// @notice Emitted when proposed cap is below current amount.
/// @param proposed The proposed new cap.
/// @param current The current amount that would exceed the cap.
Expand Down Expand Up @@ -153,6 +162,15 @@ interface IPermitter {
/// @dev Reverts if no update is scheduled or delay hasn't passed.
function executeUpdateMaxTokensPerBidder() external;

/// @notice Schedule an update to the minimum tokens per bidder (owner only).
/// @dev Update will be executable after UPDATE_DELAY has passed.
/// @param newMinTokensPerBidder New minimum tokens per bidder.
function scheduleUpdateMinTokensPerBidder(uint256 newMinTokensPerBidder) external;

/// @notice Execute a scheduled update to the minimum tokens per bidder (owner only).
/// @dev Reverts if no update is scheduled or delay hasn't passed.
function executeUpdateMinTokensPerBidder() external;

/// @notice Schedule an update to the trusted signer address (owner only).
/// @dev Update will be executable after UPDATE_DELAY has passed.
/// @param newSigner New trusted signer address.
Expand Down Expand Up @@ -194,6 +212,10 @@ interface IPermitter {
/// @return The maximum tokens per bidder cap.
function maxTokensPerBidder() external view returns (uint256);

/// @notice Get the minimum tokens per bidder.
/// @return The minimum tokens per bidder.
function minTokensPerBidder() external view returns (uint256);

/// @notice Get the owner address.
/// @return The owner address.
function owner() external view returns (address);
Expand Down Expand Up @@ -226,6 +248,14 @@ interface IPermitter {
/// @return The timestamp.
function pendingMaxTokensPerBidderTime() external view returns (uint256);

/// @notice Get the pending min tokens per bidder update.
/// @return The pending value.
function pendingMinTokensPerBidder() external view returns (uint256);

/// @notice Get the time when pending min tokens per bidder update can be executed.
/// @return The timestamp.
function pendingMinTokensPerBidderTime() external view returns (uint256);

/// @notice Get the pending trusted signer update.
/// @return The pending address.
function pendingTrustedSigner() external view returns (address);
Expand Down
Loading
Loading