Imagine: you've deployed a new ERC-20 token with improved tokenomics or fixed a critical vulnerability in an old contract. Now you need all holders to switch to the new token. Without a deadline mechanism, some users never migrate—old tokens remain in circulation, the protocol must hold liquidity forever, and the market suffers from parallel circulation of two assets. We develop migration systems that solve this problem completely: automatic migration with deadline and burning. Across 50+ projects, we've developed a standard architecture covering 90% of scenarios. Gas optimization can save holders up to $0.50 per transaction and the project up to $2,000 on contract architecture. Evaluate your project in one day—just contact us.
How the Migration System Works: Contract Architecture
The system consists of three participants: OldToken — the existing ERC-20, NewToken — the new token with a mint function or sufficient supply, and MigrationContract — an intermediary contract managing the swap, deadline, and burning.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract TokenMigration is Ownable2Step, ReentrancyGuard {
IERC20 public immutable oldToken;
IERC20 public immutable newToken;
uint256 public immutable migrationDeadline;
uint256 public immutable migrationRatio; // новых токенов за 1 старый (18 decimals)
uint256 public totalMigrated;
bool public unmigatedBurned;
event Migrated(address indexed user, uint256 oldAmount, uint256 newAmount);
event UnmigratedBurned(uint256 amount);
constructor(
address _oldToken,
address _newToken,
uint256 _deadline, // Unix timestamp
uint256 _ratio // 1e18 = 1:1, 2e18 = 2 новых за 1 старый
) Ownable2Step(msg.sender) {
require(_deadline > block.timestamp + 30 days, "Deadline too soon");
oldToken = IERC20(_oldToken);
newToken = IERC20(_newToken);
migrationDeadline = _deadline;
migrationRatio = _ratio;
}
function migrate(uint256 amount) external nonReentrant {
require(block.timestamp < migrationDeadline, "Migration closed");
require(amount > 0, "Zero amount");
uint256 newAmount = amount * migrationRatio / 1e18;
require(newAmount > 0, "Below minimum");
totalMigrated += amount;
// Получаем старые токены от пользователя
oldToken.transferFrom(msg.sender, address(this), amount);
// Выдаём новые токены
newToken.transfer(msg.sender, newAmount);
emit Migrated(msg.sender, amount, newAmount);
}
}
Why Ownable2Step Is Important for Migration Contracts?
Regular Ownable allows transferring ownership in one step: transferOwnership(newOwner). If you mis-enter the address, the contract is lost forever. Ownable2Step (OpenZeppelin documentation) requires the new owner to accept rights in a separate transaction. According to OpenZeppelin docs, two-step ownership prevents accidental loss of control. For a contract managing token migration with a deadline, this is critical—a mistake could cost control over the entire migration.
Burn Mechanism After Deadline
After the deadline expires, all unmigrated old tokens that have accumulated on the contract must be burned. Also, unused new tokens should be returned or burned.
function burnUnmigrated() external onlyOwner {
require(block.timestamp >= migrationDeadline, "Deadline not reached");
require(!unmigatedBurned, "Already burned");
unmigatedBurned = true;
// Сжигаем старые токены, которые пришли через migrate()
uint256 oldBalance = oldToken.balanceOf(address(this));
if (oldBalance > 0) {
IBurnable(address(oldToken)).burn(oldBalance);
// Если старый токен не имеет burn() — отправляем на dead address
// oldToken.transfer(address(0xdead), oldBalance);
}
// Возвращаем нераспределённые новые токены в treasury
uint256 newBalance = newToken.balanceOf(address(this));
if (newBalance > 0) {
newToken.transfer(owner(), newBalance);
}
emit UnmigratedBurned(oldBalance);
}
What if the Old Token Doesn't Have a burn() Function?
Most legacy tokens lack a burn function. Options:
- Send to
0x000...dEaD— unofficial burn address, tokens permanently inaccessible. - Send to
address(0)— only if the token allows transfer to zero address (many checkto != address(0)). - Custom burn function in MigrationContract via
IUpgradeableToken(oldToken).burnFrom()— only if the migration contract has BURNER_ROLE.
Comparison of Burning Options for Tokens Without burn
| Method | Reversibility | Address | Risks |
|---|---|---|---|
| Transfer to dead address | No | 0x000...dEaD | Unofficial, could be cleared |
| Transfer to address(0) | No | 0x000...000 | Many contracts check != 0 |
| Call burnFrom with role | Yes, if role revoked | Internal burn | Requires role setup |
Comparison of Migration Methods
| Method | Gas for User | Approve Required? | Deadline Risk | Suitable For |
|---|---|---|---|---|
| Direct (transferFrom) | High (2 tx) | Yes | Stale tokens remain with user | Simple cases, ERC-20 with burn |
| Snapshot + Merkle Proof | Low (1 tx) | No | Tokens not withdrawn, trust required | Post-hacks, upgrades without time window |
| Burning via dead address | Medium (1 tx) | No | Irreversible | When no burn function |
How Snapshot Migration Works (Merkle Proof)
If migration is based on a snapshot (balances at a specific block before deploying the new contract), users do not send old tokens—they prove entitlement to new tokens via Merkle Proof. This reduces gas costs by 40-60% compared to direct migration. Direct migration with transferFrom requires two transactions—approve and migrate. Snapshot migration with Merkle Proof is 2x faster, needing only one claim transaction, and gas costs are reduced 2-3x. We use the OpenZeppelin library MerkleProof for verification.
contract SnapshotMigration is Ownable2Step {
bytes32 public immutable merkleRoot;
mapping(address => bool) public claimed;
constructor(bytes32 _merkleRoot, uint256 _deadline) {
merkleRoot = _merkleRoot;
migrationDeadline = _deadline;
}
function claim(uint256 amount, bytes32[] calldata proof) external {
require(block.timestamp < migrationDeadline, "Expired");
require(!claimed[msg.sender], "Already claimed");
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
claimed[msg.sender] = true;
newToken.transfer(msg.sender, amount);
emit Claimed(msg.sender, amount);
}
}
Generate the Merkle Tree off-chain using @openzeppelin/merkle-tree or a custom script based on a balance snapshot. The snapshot is taken via The Graph subgraph or archival node query.
How Tokens in Vesting Contracts Are Handled During Migration
If old tokens are held in vesting contracts, they cannot be migrated directly by users. Options:
- A special admin function that migrates tokens directly from the vesting contract (requires integration with the specific vesting contract).
- Automatic migration via Tenderly Web3 Actions or a keeper after vesting expires.
User Notifications and Progress Monitoring
The contract should emit events with sufficient information to build a dashboard:
event MigrationProgress(
uint256 totalMigrated,
uint256 totalOldSupply,
uint256 deadline,
uint256 timestamp
);
A subgraph on The Graph indexes events and provides a GraphQL API for the frontend: percentage of migration completed, number of unique addresses migrated, kinetics over time.
An important practical point: large holders (>1% supply) need to be notified directly before the public migration launch. Exchanges, protocols, funds—they may have internal processes that take time. The deadline should allow at least 90 days even for simple migrations.
What We Deliver: Full Package
The work includes:
- Development of migration smart contracts (Solidity 0.8.x, OpenZeppelin).
- Integration with the existing token (OldToken) and deployment of the new one (NewToken).
- Configuration of deadline and burning mechanism.
- Development of snapshot-based migration (Merkle Proof) if needed.
- Subgraph for monitoring and frontend dashboard (React + The Graph).
- Contract audit with report (in partnership with certified auditors).
- Post-migration support for 30 days.
- Documentation for users and integration instructions.
Timelines and Cost
Development timelines: 3-5 business days for a basic migration system, 7-10 days for snapshot-based with Merkle Proof and subgraph. Cost is calculated individually—request a commercial proposal. Evaluate your project for free—our engineers with 10+ years of experience in blockchain development will analyze your architecture and suggest the optimal solution. Request a consultation right now.







