Skip to content

fix: gate signed ERC20 transfers with allowance lockdown (Cantina 3.1.2)#63

Open
re1ro wants to merge 1 commit into
mainfrom
fix/lockdown-gate-signed-transfer
Open

fix: gate signed ERC20 transfers with allowance lockdown (Cantina 3.1.2)#63
re1ro wants to merge 1 commit into
mainfrom
fix/lockdown-gate-signed-transfer

Conversation

@re1ro

@re1ro re1ro commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Addresses Cantina finding 3.1.2 (High). A signed permit(...) carrying a TransferERC20 op (modeOrExpiration == 0) called _transferFrom directly in _processChainPermits with no lock check, so lockdown() could not stop it.

This change adds a lock gate to the signed transfer path by reusing the existing owner -> tokenKey -> spender allowance lock mechanism (product decision: reuse, not a new mapping). Before performing the signed transfer it reverts AllowanceLocked if the recipient-keyed allowance is locked.

if (allowances[owner][p.tokenKey][p.account].expiration == LOCKED_ALLOWANCE) {
    revert AllowanceLocked(owner, p.tokenKey, p.account);
}

Chosen key semantics

lockdown(token, recipient) blocks signed transfers to that recipient, where recipient = p.account.

For a signed transfer, the only counterparty committed in the signature is the recipient p.accountmsg.sender is just an arbitrary relayer, unknown at lock time. So an owner blocks a signed transfer to a given recipient by calling lockdown([{token, recipient}]).

Key derivation lines up. lockdown writes to bytes32(uint256(uint160(token))) (PermitBase.sol:159). The transfer branch requires uint256(p.tokenKey) >> 160 == 0 (Permit3.sol:338) and derives token = address(uint160(uint256(p.tokenKey))), so for a valid transfer op p.tokenKey == bytes32(uint256(uint160(token))) exactly. The check reads allowances[owner][p.tokenKey][p.account] — the same slot lockdown(token, recipient) writes. Confirmed they match.

The signature still authorizes the transfer; this only adds a lock gate. The allowance is not consumed/decremented, and the happy path is unchanged when nothing is locked.

Residual limitation (explicit)

This does NOT stop a leaked signature that sends to an arbitrary/unknown recipient, because the owner cannot know the recipient at lock time. The general kill-switch for a leaked signature remains invalidateNonces(salt).

This change is defense-in-depth / parity with the NFT CollectionLocked fix, not a complete mitigation of the leaked-signature threat.

Tests

Added regression tests in test/Permit3.t.sol:

  • test_permitTransferFrom_blockedByLockdownOnRecipient — owner signs a transfer to recipient, calls lockdown([{token, recipient}]), the permit reverts AllowanceLocked, recipient balance unchanged.
  • test_permitTransferFrom_succeedsWithoutLockdown — happy path still transfers successfully.

Full suite: forge test -> 215 passed, 0 failed.

🤖 Generated with Claude Code

A signed permit carrying a TransferERC20 op (modeOrExpiration == 0) called
_transferFrom directly with no lock check, so lockdown() could not stop it.

Reuse the existing owner->tokenKey->spender allowance lock: before a signed
transfer, revert AllowanceLocked if the recipient-keyed allowance is locked.
An owner blocks signed transfers to a recipient via lockdown(token, recipient).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 23, 2026 18:07
@eco-ai-app

eco-ai-app Bot commented Jun 23, 2026

Copy link
Copy Markdown

Overview

This PR introduces a check in Permit3.sol to gate signed transfers using the existing allowance lock mechanism, addressing Cantina 3.1.2. It also includes corresponding tests to ensure that locked allowances correctly revert signed transfers and that transfers succeed without a lockdown. The review team did not flag any issues with these changes.

QA & Test Coverage

No issues found in this domain.

Web Security

Skipped: not applicable to this PR — PR addresses blockchain-specific permit/signature security, not web security concerns.

Blockchain Security

No issues found in this domain.

Performance

Skipped: not applicable to this PR — PR adds a single conditional check with no algorithmic changes or performance implications.

Stats

Metric Value
Reviewers 4
Successful 2
Skipped 2
Failed 0
Total findings 0
Cross-domain 0
Tokens (in / out) 101908 / 1418

Automated review by Eco's specialized review team. Architecture, business logic, and correctness remain with the human reviewer.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes Cantina finding 3.1.2 by ensuring TransferERC20 permit operations respect the existing allowance lock (“lockdown”) mechanism, preventing signed transfer permits from bypassing lockdown.

Changes:

  • Added a LOCKED_ALLOWANCE gate in the TransferERC20 branch of _processChainPermits to revert with AllowanceLocked when the recipient-keyed slot is locked.
  • Added regression tests covering the locked-recipient revert case (and a happy-path transfer case).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/Permit3.sol Adds an allowance-lock check before executing TransferERC20 permit transfers.
test/Permit3.t.sol Adds tests for transfer-permit behavior with/without recipient lockdown.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Permit3.sol
Comment on lines +342 to +345
// Gate signed transfers with the existing allowance lock mechanism (Cantina 3.1.2).
// The recipient (p.account) is the only counterparty committed in the signature, so an
// owner blocks a signed transfer to a given recipient via lockdown(token, recipient),
// which writes the same owner->tokenKey->recipient allowance slot read here.
Comment thread test/Permit3.t.sol
Comment on lines +64 to +77
function test_permitTransferFrom_succeedsWithoutLockdown() public {
// Happy path with no lockdown still transfers successfully.
IPermit3.ChainPermits memory chainPermits = _createBasicTransferPermit();

deal(address(token), recipient, 0);

uint48 deadline = uint48(block.timestamp + 1 hours);
uint48 timestamp = uint48(block.timestamp);
bytes memory signature = _signPermit(chainPermits, deadline, timestamp, SALT);

permit3.permit(owner, SALT, deadline, timestamp, chainPermits.permits, signature);

assertEq(token.balanceOf(recipient), AMOUNT);
}
@MSevey MSevey requested a review from fish-sammy June 24, 2026 16:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants