Merge Logic (Alien Token Merging)
What is Merge
Fragmentation Problem
When the same token (e.g., USDT) exists on different EVM networks and is bridged to a TVM network, a separate alien representation is created in TVM for each EVM variant:
- USDT from Ethereum network → alien token A in TVM
- USDT from BSC network → alien token B in TVM
- USDT from Avalanche network → alien token C in TVM
This creates a liquidity fragmentation problem: users cannot directly use token A instead of token B, even though they are essentially the same asset.
Solution via Merge Pool
Merge Pool allows combining these alien representations of one asset into a single pool, providing:
- Swap between alien representations: exchange token A for token B at a 1:1 ratio (with decimals normalization)
- Withdraw with conversion: withdraw any token from the pool to EVM with decimals conversion
Architecture
Components
| Component | Description |
|---|---|
| MergePool | Main pool contract. Stores token list with their decimals and enabled status. Handles token burns and performs swap or withdraw. |
| MergeRouter | Router contract associated with a specific token. Stores the MergePool address to which the token belongs. Deployed separately for each alien token. |
| MergePoolPlatform | Platform contract for deploying MergePool via TVM state init mechanism. Accepts MergePool code and initializes it with given parameters. |
| ProxyMultiVaultAlien | Proxy contract that deploys MergePool and MergeRouter, manages versions, handles mint/withdraw requests from MergePool. |
Interaction Diagram
Flow Description:
- User burns token A, passing payload with operation type (Swap/Withdraw) and target token
- MergePool receives
onAcceptTokensBurncallback, decodes payload, converts decimals - Depending on operation type:
- Swap: MergePool calls
mintTokensByMergePoolon Proxy to mint target token - Withdraw: MergePool calls
withdrawTokensToEvmByMergePoolon Proxy to create withdraw event
- Swap: MergePool calls
- Proxy executes the corresponding action
Canonical Token (Canon Token)
Definition
Canonical token (canon token) is a designated token in MergePool that serves as the pool's reference point. Usually chosen as the token from the main/most liquid network (e.g., USDT from Ethereum for USDT pool).
Purpose
Canon token is used as the pool identifier and configuration anchor. The canon token cannot be removed from the pool — this protects configuration integrity.
Canon Token Management
Canon token is set during MergePool deployment via the _canonId parameter — index in the tokens array.
Canon token can be changed via the setCanon(address _token) method. Accessible to owner or manager. Validates that the token exists in the pool and is enabled.
Swap Operation
Swap Operation Flow
Step 1: User initiates burn
User burns token A, passing payload with burnType = Swap and the target token address.
Step 2: MergePool decodes and converts
MergePool calls _convertDecimals to normalize the amount between source and target tokens.
Step 3: Checks and mint
MergePool validates conditions (see Checks and Restrictions) and on success calls _mintTokens(targetToken, amount, walletOwner, remainingGasTo, operationPayload) on Proxy.
Decimals Normalization
The _convertDecimals function normalizes amounts when exchanging tokens with different decimals:
- If decimals match — amount stays the same
- If source token has more decimals — amount is divided by
10^(difference) - If source token has fewer decimals — amount is multiplied by
10^(difference)
Examples:
- USDT (Ethereum, 6 decimals) → USDT (BSC, 18 decimals): amount × 10^12
- USDT (BSC, 18 decimals) → USDT (Ethereum, 6 decimals): amount / 10^12
- USDT (Ethereum, 6 decimals) → USDT (Avalanche, 6 decimals): amount (no change)
Swap Checks and Restrictions
1. Token is disabled
If target or source token is disabled, swap is not executed, burned tokens are returned instead
2. Amount after conversion = 0
If _convertDecimals results in 0, swap is not executed, original tokens are returned
This can occur when exchanging very small amounts from a token with large decimals to a token with small decimals.
Edge case example:
- Burn 1 wei of token with 18 decimals
- Target token with 6 decimals
- Conversion: 1 / 10^12 = 0 (integer division)
- Result: mint back 1 wei of original token
3. Token does not exist in pool
If a token not in MergePool is burned, transaction reverts via tokenExists modifier
Withdraw Operation via Merge Pool
EVM Withdraw Flow
Step 1: User initiates burn
User burns token A, passing payload with burnType = Withdraw, the target token address, and EVM data (recipient, callback).
Step 2: MergePool decodes and converts
MergePool decodes the payload and calls _convertDecimals to normalize the amount between source and target tokens.
Step 3: MergePool calls withdraw on Proxy
When burnType == BurnType.Withdraw and network == Network.EVM:
MergePool calls withdrawTokensToEvmByMergePool on Proxy, passing:
tokenandamount— target token and converted amountrecipient— EVM recipient addresscallback— callback data after withdrawal
The method is protected by the onlyMergePool modifier.
Step 4: Proxy deploys EVM Event
Proxy creates an EVM event with the target token and converted amount. Relay nodes then sign the event and the user calls saveWithdraw*() on EVM (see Limits).
Difference from Regular Withdraw
| Aspect | Regular Withdraw | Withdraw via MergePool |
|---|---|---|
| Target token | Always the same token being burned | Can be any token from the pool |
| Decimals conversion | Not required | Performed when necessary |
| Entry point | Direct burn → event | Burn → MergePool → Proxy → event |
Limits
MergePool does not check limits on the TVM side. Withdrawal limits are checked on the EVM side when saveWithdraw*() is called on MultiVault — see Limits.
Data Structures
Token struct
| Field | Type | Description |
|---|---|---|
decimals | uint8 | Number of decimal places for the token |
enabled | bool | Flag allowing swap/withdraw for the token |
Getting decimals:
When adding a token, MergePool requests information via JettonUtils.getInfo. Token responds with takeInfo callback (for EVM alien tokens) or takeInfoAlienTvm (for TVM alien tokens).
BurnType enum
| Value | Name | Description |
|---|---|---|
| 0 | Withdraw | Withdraw to another network |
| 1 | Swap | Exchange within TVM |
Payload Structures
Base payload for onAcceptTokensBurn:
| Field | Type | Description |
|---|---|---|
nonce | uint32 | Unique operation identifier |
burnType | BurnType | Operation type (Withdraw or Swap) |
targetToken | address | Target token from pool |
operationPayload | TvmCell | Additional data (empty cell for Swap, or target network data for Withdraw) |
remainingGasTo | address | Address for gas return |
For Withdraw, operationPayload contains the target network type and network-specific data (withdrawPayload).
MergePool Management
Deployment
Deploy MergePool:
The deployMergePool(uint256 _nonce, address[] _tokens, uint256 _canonId) function creates a new MergePool with the specified token list and canonical token.
Called only by owner or manager.
Deploy MergeRouter:
The deployMergeRouter(address _token) function deploys a MergeRouter for a specific alien token.
One MergeRouter is deployed for each alien token. After deployment, setPool is called on MergeRouter to bind it to MergePool.
Deployment sequence:
- Deploy alien tokens (if not already deployed)
- Deploy MergeRouter for each token
- Deploy MergePool with token list and canon token specification
- Bind MergeRouter to MergePool via
setPool - Enable all tokens via
enableAll
Token Management
| Function | Access | Description |
|---|---|---|
addToken(address _token) | owner/manager | Add token to pool. Token must not exist in pool. Automatically requests decimals. |
removeToken(address _token) | owner/manager | Remove token from pool. Token must exist and not be canon token. |
enableToken(address _token) | owner/manager | Enable token (allow swap/withdraw). Requires decimals > 0. |
disableToken(address _token) | owner/manager | Disable token (prohibit swap/withdraw). |
enableAll() | owner/manager | Enable all pool tokens. All tokens must have decimals > 0. |
disableAll() | owner/manager | Disable all pool tokens. |
setCanon(address _token) | owner/manager | Set canonical token. Token must be enabled. |
Access Rights
| Role | Address | Capabilities |
|---|---|---|
| owner | Set during deployment via Platform | Full access: token management, change manager, upgrade contract |
| manager | Set during deployment, changed via setManager | Token management (add/remove/enable/disable), set canon |
| proxy | Static variable, set during deployment | Call acceptUpgrade, call mint/withdraw callbacks |
Access modifiers:
onlyOwner- owner onlyonlyOwnerOrManager- owner or manageronlyProxy- proxy contract only
MergeRouter rights:
| Role | Capabilities |
|---|---|
| owner | setPool, disablePool, setManager |
| manager | setPool, disablePool |
Payload Encoding
Swap Payload
TIP-3:
The encodeMergePoolBurnSwapPayload(address _targetToken) function encodes the Swap payload:
burnType= Swap- target token
- empty
operationPayload
Jetton:
The encodeMergePoolBurnJettonSwapPayload(address _targetToken, address _remainingGasTo) function encodes the same payload with added nonce and remainingGasTo:
nonce(uint32)burnType= Swap- target token
- empty
operationPayload remainingGasTo— address for gas return
Withdraw EVM Payload
TIP-3:
The encodeMergePoolBurnWithdrawPayloadEvm(address _targetToken, uint160 _recipient, EvmCallback _callback) function encodes the Withdraw EVM payload:
nonce(uint32)burnType= Withdraw- target token
operationPayloadcontaining Network.EVM, recipient, and callback
Jetton:
The encodeMergePoolBurnJettonWithdrawPayloadEvm(address _targetToken, uint160 _recipient, EvmCallback _callback, address _remainingGasTo) function encodes the same payload with added remainingGasTo.
Error Codes
| Code | Constant | Description |
|---|---|---|
| 2709 | WRONG_MERGE_POOL_NONCE | Incorrect MergePool nonce when calling Proxy methods (sender does not match deriveMergePool) |
| 2901 | WRONG_PROXY | Calling contract is not the proxy contract |
| 2902 | ONLY_OWNER_OR_MANAGER | Action available only to owner or manager |
| 2903 | TOKEN_NOT_EXISTS | Token does not exist in pool |
| 2904 | TOKEN_IS_CANON | Attempt to remove canonical token |
| 2905 | TOKEN_ALREADY_EXISTS | Token already exists in pool |
| 2906 | MERGE_POOL_IS_ZERO_ADDRESS | Attempt to set zero address as merge pool address in MergeRouter |
| 2907 | TOKEN_NOT_ENABLED | Token is not enabled (disabled) |
| 2908 | TOKEN_DECIMALS_IS_ZERO | Token decimals are 0 (not yet received) |
| 2909 | WRONG_CANON_ID | Incorrect canonical token index during deployment |
Risks and Edge Cases
| Risk/Case | Description | Mitigation |
|---|---|---|
| Decimals precision loss | When swapping from token with large decimals (18) to token with small decimals (6), small amounts may round to 0 | MergePool checks amount == 0 after conversion and returns original tokens |
| Token disabled mid-swap | Token is disabled between burn initiation and MergePool processing | MergePool checks enabled during processing and returns tokens when enabled == false |
| MergeRouter with incorrect pool | MergeRouter points to non-existent or incorrect MergePool | Validation during setup: MERGE_POOL_IS_ZERO_ADDRESS . Administrative responsibility. |
| Upgrade MergePool without state migration | Token data is lost during upgrade | acceptUpgrade mechanism preserves state |
| Race condition in enableAll | If not all decimals are received, enableAll reverts | Requirement decimals > 0 for all tokens |
| Burn non-existent token | User burns token not in pool | tokenExists modifier reverts transaction |
| Withdraw call from unauthorized contract | Attempt to call withdrawTokensToEvmByMergePool not from MergePool | onlyMergePool modifier checks nonce correspondence |