This project implements a two-phase migration system from OldToken (TEL v2, 2 decimals) to Telcoin V3 (18 decimals) at a 1:1 exchange rate.
- Phase 1 (TokenMigration): A 1–2 year migration window where all holders can migrate their full TEL v2 balance via a mint-based contract. Uses CREATE3 for deterministic deployment.
- Phase 2 (MigrationVault): After Phase 1 closes, the remaining unminted TEL v3 supply is deposited into a one-way MigrationVault. Late TEL v2 holders can swap at 1:1 value until the vault's reserves are depleted.
TEL is bridged through a LayerZero V2 OFT mesh. Every satellite chain (Ethereum, Base, Polygon, etc.) runs a TelcoinBridge; TelcoinNetwork runs a single NativeBridge. All bridges communicate through the LayerZero protocol — no direct chain-to-chain connections exist outside of it.
graph LR
LZ{{"LayerZero V2\nDVN + Executor"}}
TN(["TelcoinNetwork\nNativeBridge\nlock / credit native TEL"])
ETH(["Ethereum\nTelcoinBridge\nmint / burn ERC-20 TEL"])
BASE(["Base\nTelcoinBridge\nmint / burn ERC-20 TEL"])
POLY(["Polygon\nTelcoinBridge\nmint / burn ERC-20 TEL"])
TN <--> LZ
ETH <--> LZ
BASE <--> LZ
POLY <--> LZ
- ERC-20 compliant token with 18 decimals
- Hard supply cap: 100 billion tokens (
MIGRATION_SUPPLY_CAP) — enforced in constructor andmint() - Minted on demand by the migration contract (no pre-funding required)
- Role-based access:
MINTER_ROLE,BURNER_ROLE,PAUSER_ROLE,UNPAUSER_ROLE - Pause only blocks transfers between non-zero addresses; mints and burns remain active
burn()requires prior approval: the token holder mustapprovethe caller (e.g.MintBurnWrapper) before their tokens can be burned — protects against a compromised BURNER_ROLE draining arbitrary walletsrescueBurn(from, amount): gated byDEFAULT_ADMIN_ROLE; burns from any wallet without approval — reserved for governance emergency response (e.g. burning hacker balances)renounceRole()disabled: no role holder, includingDEFAULT_ADMIN_ROLE, can voluntarily renounce their role — roles may only be revoked by an admin- EIP-2612 (permit): gasless approvals via signed EIP-712 messages — users can authorize a spender without an on-chain
approve()transaction - EIP-3009 (transferWithAuthorization): gasless transfers via signed EIP-712 messages —
transferWithAuthorization(anyone can submit),receiveWithAuthorization(only payee can submit, prevents front-running), andcancelAuthorization(revoke unused nonces) - EIP-1271 smart contract wallet support: all signature-verified functions provide both
(v, r, s)overloads (EIP-2612/3009 standard) andbytes signatureoverloads for full EIP-1271 compatibility. The(v, r, s)versions delegate to thebytesversions internally. Thebytesoverloads accept arbitrary-length signature blobs (e.g. Gnosis Safe multi-sig concatenated signatures, ERC-4337 account signatures) and forward them toSignatureChecker, which routes toECDSA.recoverfor EOAs orIERC1271.isValidSignaturefor contract wallets - Independent nonce systems: EIP-2612 uses sequential
uint256nonces (via OZNonces); EIP-3009 uses randombytes32nonces tracked in a separate mapping — no interference between the two
- 1:1 exchange rate with automatic decimal conversion (2 → 18)
- Mint-based: mints TelcoinV3 directly; does not hold a pre-funded token reserve
- Whole-balance migration:
migrate()exchanges the caller's entire OldToken balance in one call - Escrow model: OldToken is held in the migration contract (not burned) so that legacy liquidity pool positions can be unwound after migration concludes
- Delayed withdrawal: owner can withdraw all escrowed legacy tokens via
withdrawOldTokens()only aftermigrationExpiry + withdrawalDelay - Pausable by owner for emergency situations
- Time-bounded: migrations revert at or after
migrationExpiry - Ownable2Step: ownership transfers require acceptance by the new owner
- Owner functions:
- Pause/unpause migrations
- Extend migration expiry via
setMigrationExpiry() - Withdraw escrowed legacy tokens via
withdrawOldTokens(destination)(after withdrawal delay) - Recover accidentally sent tokens (excluding legacy token) via
recoverERC20(destination, tokenAddress, amount)
After the Phase 1 migration window closes, the remaining unminted TEL v3 supply is minted to the MigrationVault (deployed with TEL v2 / TEL v3 pair). Any remaining TEL v2 holders can swap their tokens at a 1:1 value rate, depleting the vault's TEL v3 balance.
Originally designed as a Peg Stability Vault (PSV) for bi-directional stablecoin swaps, the contract has been adapted for one-way migration:
- One-way only: TEL v2 → TEL v3 swaps only; reverse direction is not permitted
- 1:1 value rate with WAD-normalized decimal conversion (2 → 18 decimals)
- Reserve-based: holds a pre-funded TEL v3 balance; does not mint on demand
- Fee-free: no swap fees (original PSV fee system removed)
- No rate limiting: original per-transaction and per-block caps removed
- UUPS upgradeable: proxy-based deployment with admin-controlled upgrades
- Pausable:
PAUSER_ROLE/UNPAUSER_ROLEfor emergency situations - Treasury withdrawal:
TREASURY_ROLEcan withdraw tokens from the vault (e.g., to sell off legacy liquidity from constant product pools) - Reentrancy protected via transient storage guard
- Access controlled via OpenZeppelin
AccessControlUpgradeable - Roles:
DEFAULT_ADMIN_ROLE: manage roles, upgrade contractTREASURY_ROLE: withdraw tokensPAUSER_ROLE: pause migration operationsUNPAUSER_ROLE: unpause migration operations
- LayerZero V2
MintBurnOFTAdapterdeployed on each satellite chain (Ethereum, Polygon, Base, etc.) - Mint/burn operations are delegated to
MintBurnWrapper— the bridge itself holds no token roles - On send: wrapper burns ERC20 TEL from the sender; on receive: wrapper mints ERC20 TEL to the recipient
- Compatible with
NativeBridgeon TelcoinNetwork — both encode messages viaOFTMsgCodec sharedDecimals = 6,decimalConversionRate = 1e12; sub-1e12 wei dust is stripped before send- Ownable2Step: ownership transfers require acceptance;
renounceOwnership()is permanently disabled - Owner functions:
- Pause/unpause bridge
- Rescue accidentally sent tokens via
rescueTokens(token, amount) - Configure LayerZero delegate via
setDelegate()
- LayerZero V2
NativeOFTAdapterdeployed on TelcoinNetwork where TEL is the native gas token - On send: locks native TEL in the contract (reserve increases); on receive: credits native TEL to recipient
- Requires
msg.value == fee + bridgeAmounton every send call - Funded at deployment with a native TEL reserve to cover inbound credits; owner tops up via direct ETH transfer to
receive() - Accepts direct ETH via
receive()for reserve top-ups; emitsReserveFunded(funder, amount) sharedDecimals = 6, matching all satelliteTelcoinBridgedeployments- Only one NativeBridge should exist across the entire OFT mesh
- Ownable2Step: ownership transfers require acceptance;
renounceOwnership()is permanently disabled - Owner functions:
- Pause/unpause bridge
- Rescue accidentally sent ERC20 tokens via
rescueTokens(token, amount) - Configure LayerZero delegate via
setDelegate()
- Adapter contract that satisfies the
IMintableBurnableinterface required byMintBurnOFTAdapter - Holds
MINTER_ROLEandBURNER_ROLEon TelcoinV3;TelcoinBridgeholds neither role directly - Decouples bridge upgrades from token role management: swap bridges by calling
revokeBridge/authorizeBridge— no TelcoinV3 role changes needed - Tracks a single authorized bridge via
address public bridge; only that address may callmintorburn - Idempotency guards:
authorizeBridgereverts if the address is already set (BridgeAlreadySet);revokeBridgereverts if nothing is set (BridgeNotSet) or the wrong address is supplied (UnauthorizedBridge) - Emits
BridgeMinted(bridge, to, amount)andBridgeBurned(bridge, from, amount)on every mint/burn for on-chain observability - Ownable2Step:
renounceOwnership()is permanently disabled - Owner functions:
- Authorize the bridge via
authorizeBridge(bridge) - Revoke the bridge via
revokeBridge(bridge)(must supply the currently-set address to confirm intent)
- Authorize the bridge via
- Install Foundry: https://book.getfoundry.sh/getting-started/installation
- Set up environment variables:
export PRIVATE_KEY="private-key"
export RPC_URL="ethereum-rpc-url"
export ETHERSCAN_API_KEY="etherscan-api-key" # For verificationEdit the deployment script and replace the placeholder with OldToken token address:
address constant OLDTOKEN_ADDRESS = 0x... // old token addressRun the deployment script:
# Deploy with random salts
forge script script/DeployScript.s.sol:DeployScript --rpc-url $RPC_URL --broadcast --verify
# Or deploy with custom salts for more control
forge script script/DeployScript.s.sol:DeployWithCustomSalt --rpc-url $RPC_URL --broadcast --verify --sig "run(string,string)" "my-telcoin-v3-salt" "my-migration-salt"After deployment, verify:
- TelcoinV3 total supply matches chain allocation (up to 100B tokens, 10^29 base units)
- Migration contract has
MINTER_ROLEon TelcoinV3 - Migration contract has correct OldToken and TelcoinV3 addresses
migrationExpiryis set to the intended deadlineMintBurnWrapperholdsMINTER_ROLEandBURNER_ROLEon TelcoinV3TelcoinBridgeis authorized onMintBurnWrapper(wrapper.bridge() == bridgeAddress)NativeBridgeis funded with sufficient native TEL reserveTelcoinBridgeandNativeBridgepeers are set correctly on both sides (setPeer)
Test with a small amount first:
# Run tests
forge test -vvv
# Test on testnet first
forge script script/DeployScript.s.sol:DeployScript --rpc-url $TESTNET_RPC_URL --broadcast- Approve the migration contract to spend your entire OldToken balance
- Call
migrate()— no arguments required; migrates your entire OldToken balance - Receive TelcoinV3 tokens automatically (OldToken balance × 10^16)
Example using Etherscan:
- Go to OldToken token contract
- Call
approve(migrationAddress, yourFullBalance) - Go to Migration contract
- Call
migrate()
migration.pause() // Stop all migrations
migration.unpause() // Resume migrationsmigration.setMigrationExpiry(newTimestamp) // Must be greater than current expirymigration.recoverERC20(destination, tokenAddress, amount)- OldToken (2 decimals): 100B = 10,000,000,000.00 = 10^13 base units
- Telcoin V3 (18 decimals): 100B = 100,000,000,000.000000000000000000 = 10^29 base units
- Conversion multiplier: 10^16 (to convert from 2 to 18 decimals)
- User has: 1,000 OldToken (2 decimals) = 100,000 base units
- User receives: 1,000 Telcoin V3 (18 decimals) = 1,000,000,000,000,000,000,000 base units
- Reentrancy Protection: Migration and recovery functions use OpenZeppelin's ReentrancyGuard
- Pausable: Owner can pause migrations or bridging in case of emergency; both
sendand_lzReceiveare gated on all bridge contracts - Immutable Token Addresses: Token addresses cannot be changed after deployment
- Two-Step Ownership: All contracts use
Ownable2Step; ownership transfers require explicit acceptance.renounceOwnership()is permanently disabled onTelcoinBridge,NativeBridge, andMintBurnWrapper - Role Non-Renouncement:
TelcoinV3overridesrenounceRole()to always revert — roles can only be revoked by an admin, never voluntarily surrendered - Access Control: Critical functions restricted to owner or role holders
- Safe Math: Solidity 0.8+ automatic overflow protection
- Burn Approval Requirement:
TelcoinV3.burn()requires the token holder to have approved the caller. A compromisedBURNER_ROLE(e.g.MintBurnWrapper) cannot drain wallets that have not explicitly approved it - Emergency rescueBurn:
TelcoinV3.rescueBurn()allowsDEFAULT_ADMIN_ROLEto burn from any wallet without approval — scoped exclusively to governance for hack response; not accessible toBURNER_ROLE - Bridge Role Decoupling:
TelcoinBridgeholds no direct roles onTelcoinV3. Mint/burn capability is managed throughMintBurnWrapper, so bridges can be upgraded or revoked without modifying TelcoinV3's access control - Single Active Bridge:
MintBurnWrappertracks one bridge address at a time. Replacing a bridge requiresrevokeBridge+authorizeBridgeon the wrapper andsetPeerupdates — no token governance action required - Single NativeBridge Constraint: Only one
NativeBridgeshould exist across the OFT mesh; deploying multiple would break lock/credit accounting - EIP-712 Domain Separation: EIP-2612 and EIP-3009 share a single EIP-712 domain separator (via
ERC20Permit/EIP712), but use distinct type hashes — a permit signature cannot be replayed as atransferWithAuthorizationor vice versa. Cross-chain replay is prevented bychainIdin the domain separator - EIP-3009 Replay Protection: Each authorization nonce is a random
bytes32that transitionsfalse → true(one-shot latch) and can never revert tofalse.cancelAuthorizationmarks a nonce as used without executing a transfer - EIP-1271 Signature Verification:
SignatureChecker.isValidSignatureNow()issues astaticcallto the signer contract for EIP-1271 validation — no state modification is possible during the callback - Dual Signature Overloads: Each signature-verified function (
permit,transferWithAuthorization,receiveWithAuthorization,cancelAuthorization) has both a standard(v, r, s)overload (for EIP spec compliance and EOA convenience) and abytes signatureoverload (for multi-sig wallets and arbitrary EIP-1271 blobs). The(v, r, s)versions pack and delegate to thebytesversions
- Migration transaction: ~100,000 gas
- Deployment: ~2,000,000 gas (both contracts)
Update these after deployment:
OldToken Token: 0x... (existing)
Telcoin V3 Token: 0x... (new)
Migration Contract: 0x...
Run the full test suite:
# Run all tests
forge test
# Run specific test
forge test --match-test testMigration -vvv
# Gas report
forge test --gas-reportUsing CREATE3 provides:
- Deterministic addresses before deployment
- Cross-chain same addresses if using same salt
- Deployment order independence between Telcoin V3 and migration contracts
For issues or questions:
- Check contract events for migration history
- Use read functions to check balances and rates
- Ensure sufficient gas for transactions (recommend 150,000 gas limit)
MIT
Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.
Foundry consists of:
- Forge: Ethereum testing framework (like Truffle, Hardhat and DappTools).
- Cast: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data.
- Anvil: Local Ethereum node, akin to Ganache, Hardhat Network.
- Chisel: Fast, utilitarian, and verbose solidity REPL.
$ forge build$ forge test$ forge fmt$ forge snapshot$ anvil$ forge script script/DeployScript.s.sol:DeployScript --rpc-url <RPC_URL> --broadcast --verify$ cast <subcommand>$ forge --help
$ anvil --help
$ cast --help