Automated Token Migration System with Deadline
Token migration is necessary when updating tokenomics, merging protocols, or fixing errors in the original token contract. The task seems simple: holders exchange old tokens for new ones at a 1:1 ratio (or a specified ratio). But without a deadline and burn mechanism, the system becomes an eternal hanging obligation: the protocol must keep new tokens ready indefinitely, and old tokens circulate parallel to new ones, creating market confusion.
A deadline with automatic unmigrated token burning solves both problems.
Migration Contract Architecture
Three participants in the system:
OldToken — existing ERC-20 contract. The migration contract must get allowance from holders or work via transferFrom. You can't modify the existing contract — only interact with it.
NewToken — new ERC-20 contract. Must support mint or be pre-funded with enough tokens for all potential migrants. The migration contract must have MINTER_ROLE or hold new tokens.
MigrationContract — exchange logic, deadline management, burn mechanism.
// 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; // new tokens per 1 old (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 new per 1 old
) 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;
// Get old tokens from user
oldToken.transferFrom(msg.sender, address(this), amount);
// Issue new tokens
newToken.transfer(msg.sender, newAmount);
emit Migrated(msg.sender, amount, newAmount);
}
}
Why Ownable2Step is Important
Standard Ownable lets you transfer ownership in one step: transferOwnership(newOwner). Mistype the address — the contract is lost. Ownable2Step requires the new owner to accept ownership in a separate transaction. For a contract managing token migration with a deadline, this is critical.
Burn Mechanism After Deadline
After the deadline expires, all unmigrated old tokens accumulated on the contract should be burned. Also return or burn unused new tokens.
function burnUnmigrated() external onlyOwner {
require(block.timestamp >= migrationDeadline, "Deadline not reached");
require(!unmigatedBurned, "Already burned");
unmigatedBurned = true;
// Burn old tokens that came through migrate()
uint256 oldBalance = oldToken.balanceOf(address(this));
if (oldBalance > 0) {
IBurnable(address(oldToken)).burn(oldBalance);
// If old token doesn't have burn() — send to dead address
// oldToken.transfer(address(0xdead), oldBalance);
}
// Return undistributed new tokens to treasury
uint256 newBalance = newToken.balanceOf(address(this));
if (newBalance > 0) {
newToken.transfer(owner(), newBalance);
}
emit UnmigratedBurned(oldBalance);
}
When Old Token Doesn't Have burn()
Most legacy tokens don't have 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
Merkle Proof for Snapshot-Based Migration
If migration is based on a snapshot (balances at a specific block, before new contract deployment), users don't give tokens — they prove their right to receive new ones via Merkle Proof:
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 Merkle Tree off-chain via @openzeppelin/merkle-tree or a custom script based on balance snapshot. Snapshot via The Graph subgraph or archival node query.
Handling Vesting During Migration
If old tokens are in vesting contracts — they can't be directly migrated by the user. You need either:
- 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 keeper
User Notification 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 Graph subgraph indexes events and provides GraphQL API for frontend: what % of migration is complete, how many unique addresses migrated, momentum over time.
Important practical note: large holders (>1% supply) should be notified directly before public migration launch. Exchanges, protocols, funds — they may have internal processes requiring time. Deadline should provide minimum 90 days even for simple migrations.
Development timeline: 3-5 working days for basic migration system, 7-10 days for snapshot-based with Merkle Proof and subgraph. Cost calculated individually.







