
Building a Safe ERC20 Token Rescue Function in Solidity
Why a token rescue function is useful
Many contracts are not meant to custody arbitrary ERC20 tokens. Examples include:
- staking contracts that only track deposits internally
- fee collectors that should forward assets elsewhere
- governance contracts that may receive dust tokens by mistake
- upgradeable systems that accumulate legacy assets after migration
A rescue function is especially useful when the contract itself cannot safely use the tokens it receives. Instead of leaving them stranded, an authorized account can transfer them out to a designated recipient.
The key challenge is safety. A poorly designed rescue function can become a backdoor that drains legitimate funds. Good design must answer three questions:
- Who can call it?
- Which tokens can be rescued?
- Where can rescued tokens go?
Design goals
A secure rescue mechanism should:
- restrict access to trusted operators
- prevent rescuing the contract’s core asset by mistake
- emit clear events for off-chain monitoring
- support standard ERC20 tokens
- avoid assumptions about token behavior beyond the ERC20 interface
In many systems, the safest approach is to allow rescue only for tokens that are not part of the contract’s primary accounting model. For example, if your contract manages a specific staking token, the rescue function should not allow that token to be withdrawn arbitrarily.
A practical rescue pattern
The example below uses OpenZeppelin building blocks:
Ownablefor access controlSafeERC20for safe token transfersIERC20for the token interface
This design is intentionally conservative. Only the owner can rescue tokens, and the contract explicitly blocks rescue of the configured “core” token.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract TokenRescueVault is Ownable {
using SafeERC20 for IERC20;
IERC20 public immutable coreToken;
event TokensRescued(address indexed token, address indexed to, uint256 amount);
error RescueNotAllowedForCoreToken();
error InvalidRecipient();
error ZeroAmount();
constructor(address initialOwner, IERC20 _coreToken) Ownable(initialOwner) {
require(address(_coreToken) != address(0), "core token required");
coreToken = _coreToken;
}
function rescueERC20(
IERC20 token,
address to,
uint256 amount
) external onlyOwner {
if (to == address(0)) revert InvalidRecipient();
if (amount == 0) revert ZeroAmount();
if (address(token) == address(coreToken)) revert RescueNotAllowedForCoreToken();
token.safeTransfer(to, amount);
emit TokensRescued(address(token), to, amount);
}
function rescueERC20Balance(
IERC20 token,
address to
) external onlyOwner {
if (to == address(0)) revert InvalidRecipient();
if (address(token) == address(coreToken)) revert RescueNotAllowedForCoreToken();
uint256 balance = token.balanceOf(address(this));
if (balance == 0) revert ZeroAmount();
token.safeTransfer(to, balance);
emit TokensRescued(address(token), to, balance);
}
}How the contract works
coreToken is immutable
The contract stores one token address as the core asset. This is the token the contract is expected to manage in its main business logic. Making it immutable reduces configuration risk and makes the contract easier to reason about.
If your contract does not have a single core token, you can still use this pattern by replacing the single-token guard with a mapping of protected tokens.
onlyOwner limits access
Only the owner can call rescue functions. This is the simplest and most common control model for administrative recovery operations.
For more complex systems, you may prefer:
- a multisig owner
- a timelocked admin
- role-based access control
The important point is that rescue should never be publicly callable.
SafeERC20 handles non-standard tokens
Not all ERC20 tokens behave perfectly. Some return false instead of reverting, while others have edge-case behavior. SafeERC20 wraps transfers and reverts on failure, which is safer than calling transfer directly.
Two rescue modes are provided
The contract includes:
rescueERC20(token, to, amount)for partial recoveryrescueERC20Balance(token, to)for rescuing the full balance
This gives operators flexibility while keeping the implementation small.
When to use partial vs full rescue
| Function | Best for | Risk profile |
|---|---|---|
rescueERC20(token, to, amount) | Recovering a specific mistaken transfer | Lower risk, more precise |
rescueERC20Balance(token, to) | Clearing accidental dust balances | Higher risk if used carelessly |
In production, partial rescue is often preferable because it forces the operator to specify an exact amount. Full-balance rescue is convenient, but it should be used only when the contract is known to hold no legitimate balance of that token.
Best practices for safe token recovery
1. Protect the contract’s primary asset
If the contract is meant to hold or account for a specific token, do not allow that token to be rescued through the admin path. Otherwise, the rescue function can bypass the contract’s intended accounting rules.
If the contract manages multiple legitimate tokens, maintain an allowlist or denylist of protected assets.
2. Emit structured events
Events are essential for monitoring and audit trails. The TokensRescued event records:
- token address
- recipient address
- amount
This makes it easy for off-chain systems to detect administrative recovery actions.
3. Validate recipient addresses
Never allow rescue to the zero address. While some ERC20 tokens may not revert on transfer to address(0), doing so would burn the assets and defeat the purpose of recovery.
4. Prefer explicit amounts
Whenever possible, rescue a specific amount rather than “everything.” Explicit amounts reduce the chance of accidentally moving legitimate balances.
5. Keep the function narrow
A rescue function should do one thing: move accidental tokens out. Avoid combining it with unrelated admin logic such as minting, fee changes, or pausing. Narrow functions are easier to audit and safer to operate.
Extending the pattern for role-based administration
In larger systems, ownership may be too coarse. You might want separate permissions for:
- rescuing tokens
- pausing the contract
- upgrading implementation logic
A role-based version can use AccessControl instead of Ownable. The rescue function would then require a dedicated RESCUER_ROLE. This is useful when you want a security team or operations multisig to handle recovery without granting full administrative power.
A simple policy matrix might look like this:
| Permission | Suggested holder | Purpose |
|---|---|---|
RESCUER_ROLE | Ops multisig | Recover accidental token transfers |
PAUSER_ROLE | Security multisig | Stop contract activity during incidents |
DEFAULT_ADMIN_ROLE | Governance or timelock | Manage roles and configuration |
This separation reduces blast radius and improves operational clarity.
Common mistakes to avoid
Rescuing the wrong token
The most dangerous mistake is treating the rescue function as a generic withdrawal mechanism. If the contract’s core token can be rescued, the function may undermine the entire protocol.
Using transfer without safety wrappers
Direct ERC20 calls can fail silently on non-compliant tokens. Use SafeERC20 to normalize behavior.
Allowing arbitrary recipients without checks
Even if the owner is trusted, a zero-address transfer or malformed recipient should still be rejected. Defensive checks reduce operational errors.
Forgetting event emission
Without events, it is harder to track recovery actions in logs, dashboards, and audits.
Overcomplicating the rescue path
A rescue function should not contain loops, external callbacks, or token-specific branching unless absolutely necessary. Simplicity is a security feature.
Testing the rescue flow
A good test suite should verify the following cases:
- Owner can rescue unrelated tokens
- Non-owner cannot call rescue
- Core token rescue is blocked
- Zero recipient is rejected
- Zero amount is rejected
- Event is emitted with correct parameters
- Full-balance rescue transfers the exact balance
If you use Foundry or Hardhat, mock an ERC20 token and simulate accidental transfers into the contract. Then confirm that the rescue function returns those tokens to the intended recipient.
A useful test scenario is:
- deploy the vault
- transfer a mock token to the vault
- call
rescueERC20Balance - verify the vault balance is zero
- verify the recipient balance increased by the same amount
This validates both the transfer logic and the event trail.
Operational guidance
A rescue function should be paired with a clear internal policy. Before deploying, define:
- who is authorized to rescue tokens
- which tokens are protected
- whether rescue actions require approval
- how rescue events are monitored
- whether rescue operations are time-locked
For high-value systems, a multisig owner is strongly recommended. If your protocol is governed on-chain, a timelock can add an additional review window before recovery actions execute.
Conclusion
A safe ERC20 token rescue function is a small but valuable addition to many Solidity contracts. It helps recover accidental transfers without compromising the contract’s core accounting model. The most important principles are straightforward: restrict access, protect the primary asset, use safe transfer wrappers, and emit clear events.
When implemented carefully, token rescue improves operational resilience while preserving trust in the contract’s security boundaries.
