
Building a Safe ERC20 Permit-Driven Deposit Contract in Solidity
Why use permit for deposits?
Traditional ERC20 flows require two transactions:
approve(spender, amount)deposit(amount)
With permit, the user signs a message off-chain, and the contract submits that signature on-chain as part of the deposit. The result is a single transaction from the user’s perspective, or even a gas-sponsored transaction if a relayer submits it.
Common use cases
- User deposits into a vault without a separate approval step
- One-click onboarding for DeFi applications
- Gasless or relayed token deposits
- Batch deposit flows where UX matters more than manual approval management
What this pattern is not
This is not a generic token rescue function, a vesting contract, or a multi-step allowance manager. The goal here is narrow: safely combine permit with a token pull operation.
How ERC20 permit works
EIP-2612 extends ERC20 with a permit function that sets allowance using a signed authorization. The signature includes:
- owner address
- spender address
- value
- nonce
- deadline
The token contract verifies the signature and updates allowance if it is valid and not expired.
Important security properties
- Nonce-based replay protection: each signature can only be used once
- Deadline: signatures expire, limiting long-lived risk
- Domain separation: signatures are bound to a specific token contract and chain
Because the token contract enforces these rules, your deposit contract should focus on correct integration and safe token transfer handling.
Contract design goals
A good permit-driven deposit contract should:
- accept a token address at deployment or per call
- verify that the signature is used only for the intended deposit
- transfer tokens using
transferFromafter permit succeeds - track deposits per user
- avoid unsafe assumptions about token behavior
- use checked external calls and clear error handling
The contract below uses OpenZeppelin interfaces and a minimal internal accounting model.
Example: permit-based deposit vault
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20 {
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
interface IERC20Permit {
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external;
}
contract PermitDepositVault {
IERC20 public immutable token;
mapping(address => uint256) public deposited;
event Deposited(address indexed user, uint256 amount);
error ZeroAmount();
error TransferFailed();
constructor(address tokenAddress) {
token = IERC20(tokenAddress);
}
function depositWithPermit(
uint256 amount,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external {
if (amount == 0) revert ZeroAmount();
// 1. Set allowance using the user's signature
IERC20Permit(address(token)).permit(
msg.sender,
address(this),
amount,
deadline,
v,
r,
s
);
// 2. Pull tokens into the vault
bool ok = token.transferFrom(msg.sender, address(this), amount);
if (!ok) revert TransferFailed();
// 3. Record the deposit
deposited[msg.sender] += amount;
emit Deposited(msg.sender, amount);
}
}This contract is intentionally small. It demonstrates the core flow without adding unrelated withdrawal logic or governance features.
Step-by-step walkthrough
1. Store the token as immutable
The vault accepts one ERC20 token, set during deployment:
IERC20 public immutable token;Using immutable saves gas and prevents accidental token replacement after deployment.
2. Use the token’s permit interface
The contract casts the token address to IERC20Permit and calls permit before pulling funds:
IERC20Permit(address(token)).permit(...);This assumes the token supports EIP-2612. If it does not, the call reverts. In production, document the supported token explicitly.
3. Pull tokens after approval is set
After permit succeeds, the vault calls:
token.transferFrom(msg.sender, address(this), amount);This is the actual token movement. The allowance created by permit enables the transfer.
4. Track deposits internally
The contract records how much each user deposited:
deposited[msg.sender] += amount;This is useful for later withdrawals, reward calculations, or application-specific accounting.
Why this pattern is safer than manual approval flows
A permit-based deposit reduces the time window between approval and transfer. With a traditional approval, a user may approve a contract and leave the allowance open until they later call deposit. If the spender address is compromised or the user misconfigures the allowance, tokens may be at risk.
With permit, the approval is tied to a specific signature and deadline. The contract can immediately consume the allowance in the same transaction, minimizing exposure.
Comparison table
| Approach | User transactions | Approval lifetime | UX | Risk profile |
|---|---|---|---|---|
approve then deposit | 2 | Can remain open until changed | Moderate | Higher if allowance is left unused |
permit then deposit | 1 | Short, signature-bound | Better | Lower exposure window |
| Infinite approval | 2 | Long-lived | Convenient | Highest allowance risk |
Best practices for production use
Validate token compatibility
Not every ERC20 token supports permit. Some tokens implement non-standard variants, and some do not support it at all. Before integrating, confirm:
- the token implements EIP-2612
- the
DOMAIN_SEPARATORis correct - the nonce behavior matches the standard
- the signature format is
v, r, s
If you need broader compatibility, consider a fallback path that supports plain approve plus deposit.
Keep the permit and transfer in one transaction
The main safety advantage comes from atomic execution. Do not split permit and transferFrom into separate user interactions unless you have a strong reason.
Reject zero-value deposits
Zero-value calls can create noise in event logs and accounting. Reverting on zero amounts keeps the state cleaner.
Prefer explicit errors
Custom errors are cheaper and clearer than generic require strings. They also make debugging easier in tests.
Be careful with non-standard ERC20 behavior
Some ERC20 tokens do not return a boolean from transferFrom, while others may behave inconsistently. If you need maximum compatibility, use a well-tested safe transfer library. For a focused example, the interface above assumes standard behavior.
Handling replay and deadline concerns
The contract itself does not need to manage signature replay protection. That responsibility belongs to the token’s permit implementation through nonces.
However, your application should still treat deadlines seriously:
- short deadlines reduce signature reuse risk
- very long deadlines increase exposure if a signature is leaked
- relayers should submit signed permits promptly
A practical policy is to use deadlines measured in minutes or hours, not days, unless the business case requires otherwise.
Extending the pattern safely
You can extend this vault in several directions without changing the core deposit flow.
Add per-user withdrawal balances
If the vault is meant to hold user funds, you can add a withdrawal function that decreases deposited[user] and transfers tokens back. Make sure withdrawals are protected against reentrancy and use checks-effects-interactions.
Support multiple tokens
You can generalize the contract by storing balances per token and per user:
mapping(address => mapping(address => uint256)) public deposited;Here, the first key is the token address and the second key is the user. This is useful for multi-asset vaults, but it increases complexity and testing scope.
Add relayer support
If a relayer submits the transaction, the signer may not be msg.sender. In that case, the contract should accept the owner address as an argument and use it in the permit call and transferFrom source.
A relayer-friendly signature flow often looks like this:
- user signs permit off-chain
- relayer submits
depositWithPermit(owner, amount, deadline, v, r, s) - contract uses
owneras the token holder
This is a common pattern for gasless UX.
Testing scenarios you should cover
A permit-driven deposit contract should be tested with realistic edge cases.
Suggested test cases
- valid permit and successful deposit
- expired deadline reverts
- invalid signature reverts
- reused signature fails because nonce changed
- zero amount reverts
- token without permit support reverts
- transferFrom failure reverts
- deposit accounting updates correctly
Example test focus areas
| Test case | Expected outcome |
|---|---|
| Valid signature | Deposit succeeds and balance increases |
| Expired signature | Transaction reverts |
| Wrong spender in signature | Transaction reverts |
| Reused permit | Transaction reverts |
| Insufficient token balance | Transfer fails |
| Zero amount | Custom error ZeroAmount() |
Testing against a local fork or a mock token with EIP-2612 support is especially valuable because signature handling is easy to get wrong.
Common mistakes to avoid
Assuming all ERC20s support permit
This is the most common integration error. Always verify token support before deployment.
Forgetting to bind the spender correctly
The signed permit must authorize the vault contract, not a different address. If the spender is wrong, the transfer will fail.
Using overly permissive deadlines
Long deadlines weaken the security benefit of permit. Keep them short unless there is a user experience reason not to.
Ignoring accounting after transfer
Do not update internal balances before the token transfer succeeds. If the transfer fails, state should remain unchanged.
Mixing unrelated logic into the deposit function
Keep the deposit path simple. Complex side effects make it harder to reason about signature usage and failure modes.
When to choose this pattern
Use a permit-driven deposit contract when:
- you want a smoother ERC20 deposit UX
- the token supports EIP-2612
- you want to avoid a separate approval transaction
- you need atomic approval plus transfer in one call
Avoid it when:
- the token does not support permit
- you need to support many legacy tokens
- your application requires a more complex authorization model
- the deposit flow is already abstracted by a router or aggregator that handles approvals elsewhere
Summary
A safe ERC20 permit-driven deposit contract combines off-chain authorization with on-chain token movement. The key idea is simple: verify the signed permit, immediately pull the tokens, and record the deposit in the same transaction. This reduces friction for users while keeping the approval window narrow.
For production systems, focus on token compatibility, deadline discipline, and precise accounting. If you keep the deposit path atomic and well-tested, permit can significantly improve both usability and security.
