The approval model that Ethereum shipped in 2017 was a blunt instrument. A user calls approve(spender, amount), the token records a mapping entry, and the spender can drain up to that amount forever. Many tokens do not support signature-based approvals, so approvals require a separate on-chain transaction; and allowances are per token, per spender, and often end up “infinite” for convenience, expanding blast radius if a spender is compromised.

Permit2 is Uniswap’s answer to that problem. It introduces a low-overhead, next-generation token approval and meta-transaction system to make token approvals easier, more secure, and more consistent across applications. But solving one class of problem by routing all approvals through a shared contract creates its own class of problem — one that auditors, protocol developers, and security researchers need to understand at the implementation level.

This article covers that implementation level: the two transfer models, how nonces work under the hood, the subtleties of witness data, the risks of the initial approval, and how Permit2 interacts with gasless transaction infrastructure built on EIP-2771.


Why Permit2 Exists

Permit2 is essentially a smart contract-based permission management system that stands between users’ tokens and DApp contracts. Rather than each DApp handling approvals individually or each token implementing permit functionality, Permit2 serves as a common approval proxy.

The problem it solves is layered. ERC-2612 introduced permit() — a signature-based approval that collapses the approve + transferFrom two-transaction flow into one. But ERC-2612 must be baked into each token at deployment time. Tokens that predate 2020 — USDT, WBTC, most major DeFi tokens — simply don’t implement it.

Permit2 enables any ERC-20 token, even those that do not support EIP-2612, to now use permit-style approvals. This allows applications to have a single transaction flow by sending a permit signature along with transaction data when using Permit2 integrated contracts.

The mechanism requires one irreversible prerequisite: before integrating, contracts can request users’ tokens through Permit2, but users must approve the Permit2 contract through the specific token contract. That single approve(PERMIT2_ADDRESS, type(uint256).max) call on the token level unlocks the entire signature-based flow going forward. Uniswap’s implementation of Permit2 is deployed as a single, non-upgradable contract with a consistent address across Ethereum and several L2 networks, and is open source.


The Two Transfer Models

The design is modular, comprising two main logical components: AllowanceTransfer and SignatureTransfer. They serve distinct use cases and carry distinct security properties.

AllowanceTransfer

The AllowanceTransfer contract handles setting allowances on tokens, giving permissions to spenders on a specified amount for a specified duration of time. Users can give time-limited and amount-limited approvals to spenders via Permit2, and these allowances are recorded in Permit2’s contract state rather than in each token’s contract.

The AllowanceTransfer functions allow setting or revoking allowances for any ERC-20 token to any spender, with an associated expiration timestamp. If a DApp later tries to use a user’s tokens through Permit2, Permit2 will check that a valid allowance exists — correct token, spender, amount not exceeded, and not expired — before allowing the transfer.

The nonce in AllowanceTransfer is sequential per (owner, token, spender) tuple and is stored on-chain as part of the PackedAllowance struct:

// AllowanceTransfer storage layout (simplified)
struct PackedAllowance {
    uint160 amount;      // approved amount
    uint48  expiration;  // unix timestamp
    uint48  nonce;       // sequential, per (owner, token, spender)
}

mapping(address owner =>
    mapping(address token =>
        mapping(address spender => PackedAllowance)
    )
) public allowance;

The spender calls transferFrom and Permit2 runs the checks internally:

function _transfer(
    address from,
    address to,
    uint160 amount,
    address token
) private {
    PackedAllowance storage allowed = allowance[from][token][msg.sender];

    if (block.timestamp > allowed.expiration) revert AllowanceExpired(allowed.expiration);

    uint256 maxAmount = allowed.amount;
    if (maxAmount != type(uint160).max) {
        if (amount > maxAmount) revert InsufficientAllowance(maxAmount);
        unchecked {
            allowed.amount = uint160(maxAmount - amount);
        }
    }

    ERC20(token).transferFrom(from, to, amount);
}

The spender can call Permit2’s transferFrom multiple times until the allowance is exhausted or expires. AllowanceTransfer is powerful for recurring actions — subscriptions, repeated swaps, periodic DCA, vault deposits.

SignatureTransfer

The SignatureTransfer contract handles all signature-based transfers, meaning that an allowance on the token is bypassed and permissions to the spender only last for the duration of the transaction that the one-time signature is spent.

SignatureTransfer enables one-time, direct transfers via signature. Instead of setting an allowance in storage, a user can authorize a specific transfer of tokens to a specific recipient and by a specific spender through a signed message. When a DApp receives such a signature, it calls Permit2’s function — permitTransferFrom — to execute the transfer.

The core structs look like this:

struct TokenPermissions {
    address token;   // which ERC-20
    uint256 amount;  // maximum transferable
}

struct PermitTransferFrom {
    TokenPermissions permitted;
    uint256 nonce;      // unordered bitmap nonce
    uint256 deadline;   // signature expiry
}

struct SignatureTransferDetails {
    address to;        // recipient
    uint256 requestedAmount; // must be <= permitted.amount
}

The integrating contract calls:

PERMIT2.permitTransferFrom(
    permit,           // PermitTransferFrom
    transferDetails,  // SignatureTransferDetails
    owner,            // token owner (signer)
    signature         // EIP-712 signature from owner
);

Permit2 internally recovers the signer and validates nonce, deadline, amount, and token before executing the ERC-20 transferFrom.

The key security distinction: SignatureTransfer is more gas-efficient due to fewer state updates, and best suited for situations where multiple transfers are not expected. Signatures associated with a specific permission request cannot be reused because upon transfer completion the associated nonce is flipped from 0 to 1.


Nonce Management in Permit2

The two modules handle nonces differently, and the difference has security implications.

AllowanceTransfer: Sequential Nonces

AllowanceTransfer nonces are sequential integers stored per (owner, token, spender). When a signed permit is consumed, the stored nonce increments. Frontends must query the current nonce before building a signature — if the nonce has moved (another permit consumed by the same spender), the queued signature is invalidated.

This creates a race condition window: if a user signs a new permit while an old one is pending, or if two dApps for the same spender request permit signatures simultaneously, one will fail. ChainSecurity’s audit of Permit2 flagged this as a Race Condition on Approvals finding — a possible attack vector where a frontrunner invalidates a benign permit by submitting a competing one first.

SignatureTransfer: Unordered Bitmap Nonces

Signature-based transfers use unordered, non-monotonic nonces so that signed permits do not need to be transacted in any particular order.

The nonce structure uses a bitmap. The first 248 bits of the nonce value is the index of the desired bitmap, and the last 8 bits of the nonce value is the position of the bit in the bitmap.

From the Permit2 source:

function bitmapPositions(uint256 nonce)
    private
    pure
    returns (uint256 wordPos, uint256 bitPos)
{
    wordPos = uint248(nonce >> 8);   // high 248 bits → which word
    bitPos  = uint8(nonce);          // low 8 bits    → which bit in word
}

function _useUnorderedNonce(address from, uint256 nonce) internal {
    (uint256 wordPos, uint256 bitPos) = bitmapPositions(nonce);
    uint256 bit     = 1 << bitPos;
    uint256 flipped = nonceBitmap[from][wordPos] ^= bit;
    if (flipped & bit == 0) revert InvalidNonce(); // already used
}

Each uint256 word holds 256 independent nonce slots. The nonces for signature-based transfers are non-monotonic and do not require incremental ordering. This allows multiple distinct signed operations to be authorized in parallel without worrying about ordering or one invalidating another — the nonces can be any values like random IDs that haven’t been used before.

There is no method to “get the current nonce” as with AllowanceTransfers because nonces are stored as bits in an unordered manner within a bitmap. You can generate nonces in any way you wish on the frontend, as long as the generation technique does not cause collisions.

Security implication: A collision in nonce generation — two concurrent sessions generating the same nonce — means one transaction will revert with InvalidNonce. Frontends should use a CSPRNG or a timestamp-seeded value, never an incrementing counter sourced from a shared in-memory variable, to avoid both collisions and predictability exploits.

Users can also invalidate a range of nonces proactively by calling invalidateUnorderedNonces(wordPos, mask), which ORs a mask into the stored bitmap. This is useful for cancelling pending signed permits without having to submit each one individually.


Witness Data Security

Permit2’s SignatureTransfer supports an extension called witness data — additional application-specific fields that get hashed into the EIP-712 signature alongside the PermitTransferFrom struct. This prevents a relayer from reusing a token permission signature in an unintended context.

The function signature is:

function permitWitnessTransferFrom(
    PermitTransferFrom memory permit,
    SignatureTransferDetails calldata transferDetails,
    address owner,
    bytes32 witness,
    string calldata witnessTypeString,
    bytes calldata signature
) external;

A typical integration hashes the application-level order data into witness:

// Example: limit order protocol using witness to bind token permission
// to a specific order context
struct LimitOrder {
    address maker;
    address tokenOut;
    uint256 amountOut;
    uint256 expiry;
}

bytes32 constant ORDER_TYPEHASH = keccak256(
    "LimitOrder(address maker,address tokenOut,uint256 amountOut,uint256 expiry)"
);

function hashOrder(LimitOrder calldata order) internal pure returns (bytes32) {
    return keccak256(abi.encode(ORDER_TYPEHASH, order));
}

function fillOrder(
    IPermit2.PermitTransferFrom calldata permit,
    IPermit2.SignatureTransferDetails calldata transferDetails,
    address owner,
    LimitOrder calldata order,
    bytes calldata sig
) external {
    bytes32 witness = hashOrder(order);

    // witnessTypeString must follow EIP-712 ordering of nested structs
    // and MUST include the TokenPermissions type definition
    string memory witnessTypeString =
        "LimitOrder witness)LimitOrder(address maker,address tokenOut,"
        "uint256 amountOut,uint256 expiry)TokenPermissions(address token,uint256 amount)";

    PERMIT2.permitWitnessTransferFrom(
        permit,
        transferDetails,
        owner,
        witness,
        witnessTypeString,
        sig
    );

    // execute order logic
}

Witness data attack vectors:

  1. Missing witness: If an integrator calls permitTransferFrom (no witness) instead of permitWitnessTransferFrom, the signature is not bound to the application-level order. A malicious relayer can take a valid, signed PermitTransferFrom message and replay it in a completely different protocol context — transferring the user’s tokens to a different recipient or for a different purpose.

  2. Incorrect witnessTypeString: The witness type string must follow EIP-712 ordering of nested structs and must include the TokenPermissions type definition. A malformed type string will produce a different digest on the contract side than what the wallet displayed to the user, breaking signature verification — or worse, if the malformed string still produces a valid recovery (due to type confusion), binding the signature to an unintended struct layout.

  3. Witness hash collision: If the application-level struct is sparse or developer-controlled fields are not fully constrained, an attacker may be able to craft a different LimitOrder that produces the same witness hash, satisfying the Permit2 check while executing unintended logic.

  4. Witness is user-controlled input: Never allow the calling contract to accept witness as an external parameter without recomputing it from trusted, on-chain inputs.

// VULNERABLE: witness accepted from calldata directly
function fillOrderUnsafe(
    IPermit2.PermitTransferFrom calldata permit,
    IPermit2.SignatureTransferDetails calldata transferDetails,
    address owner,
    bytes32 witness,           // ← attacker-supplied
    string calldata witnessTypeString,
    bytes calldata sig
) external {
    PERMIT2.permitWitnessTransferFrom(
        permit, transferDetails, owner, witness, witnessTypeString, sig
    );
}

// SAFE: witness recomputed from validated application data
function fillOrderSafe(
    IPermit2.PermitTransferFrom calldata permit,
    IPermit2.SignatureTransferDetails calldata transferDetails,
    address owner,
    LimitOrder calldata order,  // ← validated, not the hash
    bytes calldata sig
) external {
    bytes32 witness = hashOrder(order); // computed internally
    PERMIT2.permitWitnessTransferFrom(
        permit, transferDetails, owner, witness, WITNESS_TYPE_STRING, sig
    );
}

The Risks of Approving Permit2 Itself

On the downside, Permit2 concentrates trust. A user who approves Permit2 for USDC once has authorized Permit2 to move any amount of USDC on their behalf, and now relies on the contract’s correctness and on wallets displaying permit signatures accurately.

The attack surface this creates is significant:

Phishing for signatures, not approvals. With Permit2, once authorization is granted to the Permit2 contract, subsequent authorizations can be achieved through signatures. Users tend to be less cautious when it comes to signatures compared to confirming transactions, and they rarely verify the information contained in the signatures. This significantly increases the risk of phishing attacks.

With Permit2, the user’s interaction is reduced to an off-chain signature, while intermediaries like the Permit2 contract or projects integrated with it handle the on-chain operations. This shift offers advantages by reducing on-chain friction for users, but it also presents risks. Off-chain signatures are where users often lower their defenses.

Full-balance exposure on the ERC-20 level. Permit2 by default authorizes access to your entire token balance, no matter how much you plan to swap. A user who approves type(uint256).max on the token level to Permit2 has removed the last line of defense that lived in the token contract itself. A malicious or buggy Permit2-integrated application can now issue permitTransferFrom calls that drain the full balance.

Non-upgradable means no patch. The Permit2 contract deployed by Uniswap is unowned (no admin) and non-upgradable, which means its code is immutable — a deliberate choice to increase trust that Permit2 itself won’t change or be rug-pulled by a malicious admin. This is a double-edged property: no admin can steal funds, but if a zero-day were found in the canonical deployment, every integrated protocol would be simultaneously affected with no emergency upgrade path.

Mitigation for integrators:

  • Request the minimum necessary approval amount, not type(uint256).max.
  • Use lockdown() in emergency scenarios — it invalidates all permits for a given set of (token, spender) pairs atomically.
  • Every permit carries a deadline, and AllowanceTransfer permits can be overwritten by signing a new permit with a zero amount. For immediate revocation, call lockdown on the Permit2 contract, which invalidates all permits for a given spender in one transaction.
// Emergency lockdown example
IPermit2.TokenSpenderPair[] memory pairs = new IPermit2.TokenSpenderPair[](1);
pairs[0] = IPermit2.TokenSpenderPair({token: USDC, spender: COMPROMISED_PROTOCOL});
PERMIT2.lockdown(pairs);

How Gasless Transactions Work via EIP-2771

EIP-2771 provides a standard for meta-transactions: a user signs a message describing the transaction they want executed, a relayer submits it on-chain paying the gas, and the recipient contract treats the original signer as the effective msg.sender.

EIP-2771 defines a contract-level protocol for the recipient contract to accept meta-transactions through a trusted relay contract. No protocol changes are made. The protocol is designed to allow Ethereum to accept calls from external accounts that do not have ETH to pay for gas fees. The protocol sends valid msg.sender (called _msgSender()) and msg.data (called _msgData()) to the recipient contract by appending additional calldata.

The execution model has three actors:

  1. Signer — the user, signs the meta-transaction payload.
  2. Relayer — an off-chain entity that submits the signed payload on-chain and pays gas.
  3. Trusted Forwarder — a contract that verifies the signature and forwards the call to the recipient, appending the original signer’s address to calldata.
  4. Recipient — the target contract, which reads _msgSender() instead of msg.sender.
// ERC2771Context implementation pattern (simplified)
abstract contract ERC2771Context {
    address private immutable _trustedForwarder;

    constructor(address trustedForwarder) {
        _trustedForwarder = trustedForwarder;
    }

    function isTrustedForwarder(address forwarder)
        public
        view
        virtual
        returns (bool)
    {
        return forwarder == _trustedForwarder;
    }

    function _msgSender() internal view virtual returns (address sender) {
        if (isTrustedForwarder(msg.sender) && msg.data.length >= 20) {
            // The trusted forwarder appended the original sender
            // as the last 20 bytes of calldata.
            assembly {
                sender := shr(96, calldataload(sub(calldatasize(), 20)))
            }
        } else {
            return msg.sender;
        }
    }

    function _msgData() internal view virtual returns (bytes calldata) {
        if (isTrustedForwarder(msg.sender) && msg.data.length >= 20) {
            return msg.data[:msg.data.length - 20];
        } else {
            return msg.data;
        }
    }
}

When a transaction is forwarded, the _msgSender() function extracts the message sender from the last 20 bytes of the calldata. This is the mechanism that must be protected — the last 20 bytes of calldata become the effective identity of the caller.


The Trusted Forwarder Attack Surface

The EIP-2771 model concentrates enormous trust in the forwarder contract. Modifying which forwarders are trusted must be restricted, since an attacker could “trust” their own address to forward transactions, and therefore be able to forge transactions. It is recommended to have the list of trusted forwarders be immutable, and if this is not feasible, then only trusted contract owners should be able to modify it.

The Multicall + ERC2771 Address Spoofing Vulnerability

In late 2023, a critical vulnerability class was discovered affecting protocols that combined ERC2771Context with Multicall. Any contract implementing both Multicall and ERC-2771 is vulnerable to address spoofing. An attacker can wrap malicious calldata within a forwarded request and use Multicall’s delegatecall feature to manipulate the _msgSender() resolution in the subcalls.

The mechanism works as follows:

  1. Multicall allows batching calls via delegatecall, which preserves msg.sender and msg.data of the outer call context.
  2. ERC2771Context._msgSender() reads the last 20 bytes of msg.data to extract the original sender.
  3. An attacker constructs a forwarder request whose appended calldata, when passed through delegatecall, causes _msgData() to return a crafted slice — one whose last 20 bytes are an arbitrary victim address.
// Conceptual exploit path (NOT production code)
// Attacker constructs calldata such that after delegatecall resolution,
// the last 20 bytes of msg.data read by _msgSender() are the victim's address.

// Inner call payload (will be delegatecalled by Multicall):
//   functionSelector (4 bytes)
//   ... params ...
//   victimAddress   (20 bytes) ← appears at position [-20:] in context
bytes memory maliciousPayload = abi.encodePacked(
    abi.encodeWithSelector(TARGET_SELECTOR, ...params...),
    victimAddress // crafted tail
);

// Outer request submitted to trusted forwarder:
forwarder.execute(ForwardRequest({
    from: attacker,
    to: targetContract,
    data: abi.encodeWithSelector(Multicall.multicall.selector,
        [maliciousPayload])
}));

Any contract implementing ERC-2771 and Multicall with a valid trusted forwarder is vulnerable to address spoofing via malicious calldata in forwarded requests. The vulnerability allows attackers to perform unauthorized actions, including executing privileged function calls and taking control of assets from any account.

Real-world impact: One attack exploited a Forwarder contract that called the multicall function of the TIME token. Due to the use of the ERC-2771 native meta-transaction security protocol by the TIME token, the attacker maliciously forged _msgSender, leading to the destruction of tokens in the pair.

Mitigations:

  • Never combine ERC2771Context and Multicall in the same contract.
  • Following ERC-2771 security considerations, the trusted forwarder addresses in ERC2771Context implementations should be immutable and can only be set during deployment.
  • If Multicall is required, implement a batching mechanism inside the forwarder rather than in the recipient contract.

Replay Protection in Meta-Transaction Frameworks

A meta-transaction framework with weak replay protection is worse than no meta-transaction support — every signed message becomes a potential weapon.

EIP-712 prevents replay attacks through: a unique domain per application, chainId preventing cross-chain replay, and verifyingContract binding the signature to a specific contract.

The domain separator in any EIP-712–based system (including Permit2’s own internal signing) encodes:

bytes32 DOMAIN_SEPARATOR = keccak256(
    abi.encode(
        keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"),
        keccak256(bytes("Permit2")),
        block.chainid,
        address(this)
    )
);

The domain separator scopes the signature to one specific token on one specific chain, which is what prevents cross-chain replay even when the same address holds the same token on two networks.

Beyond the domain separator, replay protection in forwarder contracts requires a per-sender nonce:

// MinimalForwarder pattern
contract Forwarder {
    struct ForwardRequest {
        address from;
        address to;
        uint256 value;
        uint256 gas;
        uint256 nonce;
        bytes   data;
    }

    mapping(address => uint256) private _nonces;

    function getNonce(address from) public view returns (uint256) {
        return _nonces[from];
    }

    function verify(ForwardRequest calldata req, bytes calldata signature)
        public
        view
        returns (bool)
    {
        address signer = ECDSA.recover(
            _hashTypedDataV4(keccak256(abi.encode(
                FORWARD_REQUEST_TYPEHASH,
                req.from, req.to, req.value, req.gas, req.nonce,
                keccak256(req.data)
            ))),
            signature
        );
        return _nonces[req.from] == req.nonce && signer == req.from;
    }

    function execute(ForwardRequest calldata req, bytes calldata signature)
        public
        payable
        returns (bool, bytes memory)
    {
        require(verify(req, signature), "ForwardRequest: invalid signature");
        _nonces[req.from]++;

        (bool success, bytes memory result) = req.to.call{
            gas:   req.gas,
            value: req.value
        }(abi.encodePacked(req.data, req.from)); // appended sender

        if (!success) {
            assembly { revert(add(result, 32), mload(result)) }
        }
        return (success, result);
    }
}

Replay attack scenarios to audit:

  1. Cross-chain replay: If chainId is hardcoded rather than read from block.chainid, a signature valid on mainnet will also be valid on a fork (Ethereum Classic, testnets, or a new L2 that inherits the address space).

  2. Cross-contract replay: If verifyingContract is omitted from the domain separator, a signature valid for ProtocolV1 is also valid for ProtocolV2 at a different address, assuming the struct schema matches.

  3. Nonce reuse after contract upgrade: If a protocol upgrades its forwarder and resets nonces, all previously consumed nonces become valid again. Use nonce migration or inherit the old nonce state into the new deployment.

  4. Batch replay: A meta-transaction framework that uses a single nonce for a batch of operations must invalidate all sub-operations atomically. If the nonce increments mid-batch on partial failure, a replay of the failed sub-operations is possible.


Security Implications of Delegating Gas Payment

When a relayer pays gas on behalf of a user, the execution economics change in ways that affect both the user and the protocol.

Gas griefing. A user signs a meta-transaction without specifying a gas limit. The relayer submits it with insufficient gas, causing the call to revert — but the nonce is still incremented. The user cannot re-submit the same signed transaction. This is especially dangerous when the nonce is sequential (as in AllowanceTransfer or the forwarder above): the user must request a new signature from scratch.

// Gas limit enforcement in forwarder (best practice)
function execute(ForwardRequest calldata req, bytes calldata signature)
    public
    payable
    returns (bool, bytes memory)
{
    require(verify(req, signature), "invalid signature");

    // Ensure enough gas remains for the call plus a safety buffer
    require(
        gasleft() >= req.gas + 40_000,
        "insufficient gas for forwarded call"
    );

    _nonces[req.from]++;
    // ... execute
}

Relayer censorship. A relayer that controls which transactions it forwards can selectively execute or drop meta-transactions. For protocols that rely on time-sensitive operations (liquidations, oracle updates), a censoring relayer can cause economic harm without the user being able to self-rescue (since they have no ETH).

Fee token extraction. Gasless UX is not free — the relayer must be compensated. Gas tank models where users pre-deposit fee tokens, or fee-in-calldata models where the relayer extracts a fee from the transaction itself, introduce new accounting surfaces. If the fee calculation can be manipulated (e.g., via a re-entrant call before the fee token transfer), the relayer can be underpaid, or the user can be overcharged.

ERC-2771 + Permit2 interaction. When a meta-transaction via EIP-2771 is used to call a Permit2-integrated contract, two signatures are in play:

  • The EIP-2771 ForwardRequest signature (proves who initiated the meta-transaction)
  • The Permit2 permit signature (proves the token owner authorized the transfer)

Both must be validated against the same owner address. If _msgSender() in the recipient contract is used to identify the Permit2 token owner but the actual Permit2 signature was made by a different address, the permitTransferFrom call will revert. This is correct behavior, but developers sometimes accidentally pass msg.sender (the forwarder) rather than _msgSender() (the actual user) as the owner argument.

// VULNERABLE: uses msg.sender instead of _msgSender()
function depositWithPermit(
    IPermit2.PermitTransferFrom calldata permit,
    IPermit2.SignatureTransferDetails calldata details,
    bytes calldata sig
) external {
    PERMIT2.permitTransferFrom(
        permit,
        details,
        msg.sender,   // ← WRONG when called via trusted forwarder
        sig
    );
}

// CORRECT: uses _msgSender() from ERC2771Context
function depositWithPermit(
    IPermit2.PermitTransfer
From,
    address recipient,
    uint256 deadline
) external {
    require(block.timestamp <= deadline, "Permit expired");

    // Pull tokens via Permit2 — validates the signed transfer
    permit2.permitTransferFrom(
        IPermit2.PermitTransferFrom({
            permitted: IPermit2.TokenPermissions({
                token: address(token),
                amount: amount
            }),
            nonce: nonce,
            deadline: deadline
        }),
        IPermit2.SignatureTransferDetails({
            to: address(this),
            requestedAmount: amount
        }),
        msg.sender,
        signature
    );

    // Account using the ERC2771 original sender, not msg.sender
    _balances[recipient] += amount;
}

Using _msgSender() from ERC2771Context instead of msg.sender ensures that the original signer — not the relayer — is credited. The Permit2 signature already binds the transfer to the original signer’s address, so the accounting and the authorization are consistent.


Permit2 and Gasless Transaction Checklist

Permit2 integration

  • permitTransferFrom is used instead of raw transferFrom for gasless flows
  • Nonce is validated and consumed atomically — no nonce can be reused
  • Deadline is checked before any state change
  • The permitted.amount matches the actual amount credited to the user

ERC-2771 / meta-transaction

  • _msgSender() is used everywhere the original signer’s identity matters
  • msg.sender is used only to identify the relayer, never the user
  • The trusted forwarder address is immutable or governance-gated
  • The contract verifies that msg.sender == trustedForwarder before extracting the appended sender

Signature security

  • Domain separator includes chainId and address(this)
  • Nonces are per-user and per-token to prevent cross-token replay
  • Signatures include a deadline — zero deadline is rejected
  • OpenZeppelin ECDSA is used, not raw ecrecover

Relayer trust

  • The relayer cannot modify the signed parameters — only submit them
  • Gas griefing (relayer under-forwarding gas) is mitigated by minimum gas checks
  • Relayer failure does not leave user funds in an inconsistent state