
Building a Safe ERC20 Permit-Based Approval Flow in Solidity
Why use permit?
A standard ERC20 flow often looks like this:
- User calls
approve(spender, amount). - User calls the application contract.
- The application contract transfers tokens with
transferFrom().
With permit, steps 1 and 2 can be combined. The user signs a message off-chain, and the application contract submits that signature on-chain together with the action.
Common use cases
- One-click token deposits into vaults or staking contracts
- Gasless onboarding for dApps
- UX improvements for payment flows
- Batch operations where approval and execution happen together
What permit does not solve
permit does not replace token transfers, accounting, or access control. It only replaces the separate approval transaction. Your contract still needs to validate inputs, handle token behavior carefully, and protect against replay or misuse.
EIP-2612 in practice
A token that supports permit usually exposes this function:
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external;When called successfully, it sets an allowance for spender without requiring the owner to send an on-chain transaction. The signature is bound to:
- the token contract
- the owner
- the spender
- the allowance value
- a deadline
- a nonce
That nonce is critical. It ensures a signature can only be used once.
A safe deposit contract using permit
The example below implements a simple token vault. Users can deposit ERC20 tokens in a single transaction by supplying a valid permit signature.
Design goals
- Accept only tokens that implement
permit - Use
permitimmediately beforetransferFrom - Verify the deposit amount
- Emit clear events
- Avoid unnecessary state complexity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
interface IERC20Permit {
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external;
}
contract PermitDepositVault {
mapping(address => mapping(address => uint256)) public deposits;
event Deposited(address indexed token, address indexed user, uint256 amount);
error ZeroAmount();
error TransferFailed();
error InvalidTokenBalance();
function depositWithPermit(
address token,
uint256 amount,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
if (amount == 0) revert ZeroAmount();
// Grant this vault permission to pull exactly `amount` tokens.
IERC20Permit(token).permit(
msg.sender,
address(this),
amount,
deadline,
v,
r,
s
);
uint256 beforeBalance = IERC20(token).balanceOf(address(this));
bool ok = IERC20(token).transferFrom(msg.sender, address(this), amount);
if (!ok) revert TransferFailed();
uint256 afterBalance = IERC20(token).balanceOf(address(this));
if (afterBalance - beforeBalance != amount) revert InvalidTokenBalance();
deposits[token][msg.sender] += amount;
emit Deposited(token, msg.sender, amount);
}
}How the flow works
- The user signs a permit message off-chain.
- The user submits
depositWithPermit(...)to the vault. - The vault calls
permit(...)on the token. - The vault pulls tokens with
transferFrom(...). - The vault records the deposit.
This pattern is useful because the user does not need to pre-approve the vault in a separate transaction.
Important implementation details
1. Use the permit before the transfer
The permit should be executed immediately before transferFrom. This reduces the time window in which allowance exists and keeps the flow atomic.
2. Prefer exact-value approvals
The example approves exactly amount, not an unlimited allowance. This is a safer default because it limits the blast radius if the contract is later upgraded or misused.
3. Check token transfer behavior
Not all ERC20 tokens behave identically. Some return false instead of reverting, while others revert on failure. The example checks the return value explicitly.
4. Verify actual balance changes when needed
Some tokens are fee-on-transfer or deflationary. In those cases, the amount transferred may be less than the requested amount. If your application requires exact accounting, compare balances before and after the transfer.
Supporting permit-capable tokens only
Not every ERC20 token supports permit. If your contract is meant to work only with permit-enabled tokens, document that clearly. If you want broader compatibility, add a fallback path using standard approval.
| Approach | Pros | Cons |
|---|---|---|
permit only | Best UX, single transaction | Works only with EIP-2612 tokens |
approve + transferFrom | Universal ERC20 compatibility | Requires two transactions |
| Dual support | Flexible for users | More code paths to test |
A dual-support design often exposes two deposit functions:
depositWithPermit(...)deposit(...)after prior approval
This gives users the best available path without forcing a single token standard.
Handling replay and signature safety
EIP-2612 signatures are protected by nonces, but your contract should still follow a few rules.
Never reuse signatures
A valid permit signature should be consumed once. The token contract handles this by incrementing the nonce. Your contract should not cache signatures or attempt to replay them.
Respect deadlines
The deadline parameter limits how long a signature remains valid. Encourage short deadlines in client code to reduce the risk of signature leakage.
Avoid signature forwarding mistakes
The spender in the signed message must match the contract that will actually call transferFrom. If a frontend signs a permit for the wrong spender, the deposit will fail or approve the wrong address.
A safer variant with explicit token allowlisting
If your vault is intended for a small set of known assets, consider allowlisting tokens. This reduces the risk of interacting with non-standard or malicious tokens.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20Minimal {
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
interface IERC20PermitMinimal {
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external;
}
contract AllowlistedPermitVault {
mapping(address => bool) public supportedToken;
mapping(address => mapping(address => uint256)) public deposits;
address public owner;
event TokenSupported(address indexed token, bool supported);
event Deposited(address indexed token, address indexed user, uint256 amount);
error NotOwner();
error UnsupportedToken();
error ZeroAmount();
error TransferFailed();
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
function setTokenSupport(address token, bool supported) external onlyOwner {
supportedToken[token] = supported;
emit TokenSupported(token, supported);
}
function depositWithPermit(
address token,
uint256 amount,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
if (!supportedToken[token]) revert UnsupportedToken();
if (amount == 0) revert ZeroAmount();
IERC20PermitMinimal(token).permit(
msg.sender,
address(this),
amount,
deadline,
v,
r,
s
);
bool ok = IERC20Minimal(token).transferFrom(msg.sender, address(this), amount);
if (!ok) revert TransferFailed();
deposits[token][msg.sender] += amount;
emit Deposited(token, msg.sender, amount);
}
}This version is more restrictive, but it is often a better fit for production vaults, payment systems, or treasury tools.
Best practices for frontend integration
A secure contract is only part of the story. The client application must build the permit correctly.
Recommended frontend steps
- Read the token’s current nonce for the user.
- Build the EIP-712 typed data payload.
- Set a short deadline.
- Ask the user to sign.
- Submit the signed permit together with the deposit transaction.
Practical tips
- Use the chain ID from the connected network, not a hardcoded value.
- Verify the token contract address carefully.
- Show the exact spender and amount in the signing UI.
- Prefer small, explicit approvals over unlimited ones.
If the frontend signs for the wrong chain or token address, the signature will fail. If it signs for the wrong spender, it may approve an unintended contract.
Testing scenarios you should not skip
A permit flow has several failure modes that are easy to miss.
Test cases
- Valid permit and successful deposit
- Expired deadline
- Incorrect signature values
- Reused signature with the same nonce
- Token without
permit - Token transfer returning
false - Fee-on-transfer token behavior
- Zero-amount deposit rejection
Example test checklist
| Scenario | Expected result |
|---|---|
| Valid signature | Deposit succeeds |
| Expired signature | Transaction reverts |
| Wrong spender | Permit or deposit fails |
| Replayed signature | Reverts due to nonce mismatch |
| Transfer failure | Reverts cleanly |
Testing against a real EIP-2612 token implementation is especially valuable because signature encoding bugs often appear only in integration tests.
When not to use permit
permit is not always the right choice.
Avoid it when:
- You need to support many legacy ERC20 tokens
- Your users already rely on wallet-based approval flows
- The token does not implement EIP-2612
- Your application requires a custom authorization model
In those cases, a standard approve flow may be simpler and more compatible.
Production hardening checklist
Before deploying a permit-based contract, review the following:
- Use exact-value approvals when possible
- Validate token support or provide a fallback
- Handle failed transfers explicitly
- Keep deposit accounting separate from token balances
- Emit events for off-chain indexing
- Document supported token standards
- Test against real token contracts, not only mocks
If your contract is upgradeable, be especially careful with spender addresses and storage layout. A permit signature is only as safe as the contract that consumes it.
Conclusion
A permit-based approval flow is one of the most effective ways to improve ERC20 UX without sacrificing on-chain security. By combining signature-based approval with an immediate token transfer, you can reduce friction, lower user error, and keep interactions atomic.
The key is to treat permit as a convenience layer, not a security boundary. Use exact approvals, validate transfers, and test against real token behavior. With those practices in place, permit-enabled contracts can provide a clean and reliable developer experience.
