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 2 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"
]
}
}
}
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
43 changes: 25 additions & 18 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;

Check warning on line 30 in src/Permitter.sol

View check run for this annotation

GitHub Advanced Security / Slither

State variables that could be declared immutable

Permitter.minTokensPerBidder (src/Permitter.sol#30) should be immutable

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

Expand Down Expand Up @@ -70,23 +72,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 +112,45 @@
// 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
// 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 @@ -279,8 +287,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
16 changes: 14 additions & 2 deletions src/interfaces/IPermitter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,9 @@ interface IPermitter {

/// @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 +44,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 +61,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 @@ -194,6 +202,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
8 changes: 7 additions & 1 deletion src/interfaces/IPermitterFactory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,22 @@ interface IPermitterFactory {
/// @param authorizedCaller The CCA contract authorized to call validateBid.
/// @param maxTotalEth The maximum total ETH that can be raised.
/// @param maxTokensPerBidder The maximum tokens any single bidder can purchase.
/// @param minTokensPerBidder The minimum tokens any single bidder must purchase per bid.
event PermitterCreated(
address indexed permitter,
address indexed owner,
address indexed trustedSigner,
address authorizedCaller,
uint256 maxTotalEth,
uint256 maxTokensPerBidder
uint256 maxTokensPerBidder,
uint256 minTokensPerBidder
);

/// @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 minTokensPerBidder Minimum tokens any single bidder must purchase per bid.
/// @param owner Address that can update caps and pause (auction creator).
/// @param authorizedCaller CCA contract address authorized to call validateBid.
/// @param salt Salt for CREATE2 deployment to enable deterministic addresses.
Expand All @@ -33,6 +36,7 @@ interface IPermitterFactory {
address trustedSigner,
uint256 maxTotalEth,
uint256 maxTokensPerBidder,
uint256 minTokensPerBidder,
address owner,
address authorizedCaller,
bytes32 salt
Expand All @@ -42,6 +46,7 @@ interface IPermitterFactory {
/// @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 address authorized to call validateBid.
/// @param salt Salt for CREATE2 deployment.
Expand All @@ -50,6 +55,7 @@ interface IPermitterFactory {
address trustedSigner,
uint256 maxTotalEth,
uint256 maxTokensPerBidder,
uint256 minTokensPerBidder,
address owner,
address authorizedCaller,
bytes32 salt
Expand Down
29 changes: 17 additions & 12 deletions test/ExploitTests.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,21 @@ contract ExploitTests is Test {

uint256 public constant INITIAL_MAX_TOTAL_ETH = 100 ether;
uint256 public constant INITIAL_MAX_TOKENS_PER_BIDDER = 1000 ether;
uint256 public constant INITIAL_MIN_TOKENS_PER_BIDDER = 10 ether;

bytes32 public constant PERMIT_TYPEHASH =
keccak256("Permit(address bidder,uint256 maxBidAmount,uint256 expiry)");
bytes32 public constant PERMIT_TYPEHASH = keccak256("Permit(address bidder,uint256 expiry)");

function setUp() public {
signerPrivateKey = 0x1234;
trustedSigner = vm.addr(signerPrivateKey);

target = new Permitter(
trustedSigner, INITIAL_MAX_TOTAL_ETH, INITIAL_MAX_TOKENS_PER_BIDDER, owner, authorizedCaller
trustedSigner,
INITIAL_MAX_TOTAL_ETH,
INITIAL_MAX_TOKENS_PER_BIDDER,
INITIAL_MIN_TOKENS_PER_BIDDER,
owner,
authorizedCaller
);

vm.label(address(target), "Permitter");
Expand All @@ -37,16 +42,14 @@ contract ExploitTests is Test {
vm.label(authorizedCaller, "AuthorizedCaller");
}

function _createPermitSignature(address _bidder, uint256 _maxBidAmount, uint256 _expiry)
function _createPermitSignature(address _bidder, uint256 _expiry)
internal
view
returns (bytes memory permitData)
{
IPermitter.Permit memory permit =
IPermitter.Permit({bidder: _bidder, maxBidAmount: _maxBidAmount, expiry: _expiry});
IPermitter.Permit memory permit = IPermitter.Permit({bidder: _bidder, expiry: _expiry});

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 domainSeparator = target.domainSeparator();
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash));
Expand All @@ -71,6 +74,7 @@ contract ExploitTests is Test {
trustedSigner,
0, // maxTotalEth = 0 - REJECTED
INITIAL_MAX_TOKENS_PER_BIDDER,
INITIAL_MIN_TOKENS_PER_BIDDER,
owner,
authorizedCaller
);
Expand All @@ -82,6 +86,7 @@ contract ExploitTests is Test {
trustedSigner,
INITIAL_MAX_TOTAL_ETH,
0, // maxTokensPerBidder = 0 - REJECTED
INITIAL_MIN_TOKENS_PER_BIDDER,
owner,
authorizedCaller
);
Expand Down Expand Up @@ -124,7 +129,7 @@ contract ExploitTests is Test {
console.log("Fixes: H-01 (High)");

uint256 expiry = block.timestamp + 2 hours;
bytes memory permitData = _createPermitSignature(bidder, 500 ether, expiry);
bytes memory permitData = _createPermitSignature(bidder, expiry);

// First bid succeeds
vm.prank(authorizedCaller);
Expand Down Expand Up @@ -159,7 +164,7 @@ contract ExploitTests is Test {
console.log("Fixes: H-02 (High)");

uint256 expiry = block.timestamp + 2 hours;
bytes memory permitData = _createPermitSignature(bidder, 500 ether, expiry);
bytes memory permitData = _createPermitSignature(bidder, expiry);

// Raise 50 ETH
vm.prank(authorizedCaller);
Expand Down Expand Up @@ -201,7 +206,7 @@ contract ExploitTests is Test {
console.log("Fixes: M-01 (Medium/High)");

uint256 expiry = block.timestamp + 1 hours;
bytes memory permitData = _createPermitSignature(bidder, 500 ether, expiry);
bytes memory permitData = _createPermitSignature(bidder, expiry);

address attacker = makeAddr("attacker");
console.log("Attacker (not authorized):", attacker);
Expand Down Expand Up @@ -229,7 +234,7 @@ contract ExploitTests is Test {
console.log("Fixes: M-02 (Medium)");

uint256 expiry = block.timestamp + 2 hours;
bytes memory permitData = _createPermitSignature(bidder, 500 ether, expiry);
bytes memory permitData = _createPermitSignature(bidder, expiry);

console.log("Bidder has valid permit signed by:", trustedSigner);

Expand Down
Loading
Loading