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 via canonical token: withdraw any token from the pool using the canonical token for daily limits verification
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
MergePool Versions
| Version | Changes from Previous |
|---|---|
| V1 | Base version. EVM and SVM withdraw support. |
| V2 | Added TVM-TVM transfer support (withdrawTokensToTvmByMergePool method, ITakeInfoAlienTvm interface). |
| V3 | Passing _canonToken and _canonAmount in withdraw methods for correct daily limits accounting. _sender parameter instead of _walletOwner in withdraw methods. |
V3 Changes (relative to V2):
In V3, when calling withdrawTokensToEvmByMergePool, the following are passed:
_canonToken- canonical token address (frommsg.sender- burned token)_canonAmount- original amount before conversion_token- target token address_amount- converted amount
This allows Proxy to use the canonical token for daily limits verification instead of the target token.
Canonical Token (Canon Token)
Definition
Canonical token (canon token) is a designated token in MergePool that is used as a reference for withdraw operations.
Purpose
Canon token performs the following roles:
Daily limits calculation: when withdrawing through MergePool, daily limits are checked for the canonical token, not for the withdrawal target token. This allows unifying limits for all tokens in the pool.
Reference point: canon token is usually chosen as the token from the main/most liquid network (e.g., USDT from Ethereum for USDT pool).
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() method:
function setCanon(address _token)
external
override
onlyOwnerOrManager
tokenExists(_token)
cashBack
{
require(tokens[_token].enabled, ErrorCodes.TOKEN_NOT_ENABLED);
canon = _token;
}Restrictions:
- Canon token must exist in the pool
- Canon token must be enabled
- Only owner or manager can change canon
Cannot remove the current canon token from the pool
Swap Operation
Swap Operation Flow
Step 1: User initiates burn
User burns token A, passing payload:
payload = abi.encode(
nonce: uint32,
burnType: BurnType.Swap, // = 1
targetToken: address, // token B address
operationPayload: TvmCell, // empty cell for swap
remainingGasTo: address
)Step 2: MergePool receives callback
MergePool decodes payload in onAcceptTokensBurn
Step 3: Decimals conversion
MergePool calls _convertDecimals to normalize the amount:
uint128 amount = _convertDecimals(_amount, msg.sender, targetToken);Step 4: Checks
MergePool validates conditions:
- Target token is enabled
- Source token is enabled
- Converted amount > 0
Step 5: Mint target token
If checks pass and burnType == BurnType.Swap:
_mintTokens(targetToken, amount, _walletOwner, remainingGasTo, operationPayload);MergePool calls on Proxy
Decimals Normalization
The _convertDecimals function ensures correct exchange of tokens with different decimals:
function _convertDecimals(
uint128 _amount,
address _from,
address _to
) internal view returns (uint128) {
uint8 from_decimals = tokens[_from].decimals;
uint8 to_decimals = tokens[_to].decimals;
uint128 base = 10;
if (from_decimals == to_decimals) {
return _amount;
} else if (from_decimals > to_decimals) {
return _amount / (base ** uint128(from_decimals - to_decimals));
} else {
return _amount * (base ** uint128(to_decimals - from_decimals));
}
}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:
payload = abi.encode(
nonce: uint32,
burnType: BurnType.Withdraw, // = 0
targetToken: address, // target alien token address
operationPayload: TvmCell, // = abi.encode(Network.EVM, withdrawPayload)
remainingGasTo: address
)
where withdrawPayload = abi.encode(recipient: uint160, callback: EvmCallback)Step 2: MergePool decodes and converts
Step 3: MergePool calls withdraw on Proxy
When burnType == BurnType.Withdraw and network == Network.EVM:
IProxyMultiVaultAlienJetton_V3(proxy)
.withdrawTokensToEvmByMergePool{
bounce: false,
value: 0,
flag: MsgFlag.ALL_NOT_RESERVED
}(
_randomNonce, // merge pool nonce
nonce, // operation nonce
msg.sender, // canon token (burned token)
_amount, // canon amount (original amount)
targetToken, // token (target token)
amount, // amount (converted amount)
recipient, // EVM recipient
remainingGasTo,
_walletOwner, // sender
callback
);Step 4: Proxy checks daily limits
Proxy receives the withdrawTokensToEvmByMergePool method with onlyMergePool modifier
Key point: daily limits are checked for _canonToken and _canonAmount, not for _token and _amount:
(
bool isLimitReached,
optional(DailyLimits) newLimits
) = _isOutgoingDailyLimitReached(_canonToken, _canonAmount);Step 5: Deploy EVM Event
If limits are not reached, Proxy deploys EVM event with target token:
_deployEvmEvent(
_nonce,
_token, // target token (not canonical!)
_amount, // converted amount
_recipient,
_remainingGasTo,
_sender,
_callback
);If limit is reached:
Proxy mints back canonical token to sender
SVM Withdraw Flow
Similar to EVM Withdraw, but uses the withdrawTokensToSvmByMergePool method
Payload for SVM:
bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:69-93bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:95-121
TVM Withdraw Flow
For TVM-TVM transfers, the withdrawTokensToTvmByMergePool method is used:
bridge-ton-contracts/contracts/bridge/alien-token-merge/merge-pool/MergePool_V3.tsol:468-485bridge-ton-contracts/contracts/bridge/proxy/multivault/alien-ton/V3/ProxyMultiVaultAlienJetton_V3_MergePool.tsol:175-217
Payload for TVM:
bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:123-141
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 |
| Daily limits | Checked for burned token | Checked for canonical token |
| Decimals conversion | Not required | Performed when necessary |
| Entry point | Direct burn → event | Burn → MergePool → Proxy → event |
Interaction with Daily Limits
Key idea: Daily limits are checked for the canonical token regardless of which token is being withdrawn.
Scenario:
- MergePool contains: USDT-Ethereum (canon, 6 decimals), USDT-BSC (18 decimals), USDT-Avalanche (6 decimals)
- Daily outgoing limit for USDT-Ethereum: 100,000 tokens
- User A withdraws 50,000 USDT-BSC → EVM
- Canon amount: 50,000 * 10^6 (in canonical token decimals)
- Daily volume increases by 50,000 USDT-Ethereum
- User B withdraws 40,000 USDT-Avalanche → EVM
- Canon amount: 40,000 * 10^6
- Daily volume increases by 40,000 USDT-Ethereum
- User C attempts to withdraw 20,000 USDT-Ethereum → EVM
- Limit reached (50,000 + 40,000 + 20,000 > 100,000)
- Transaction rejected, tokens returned
TODO: requires clarification - how exactly is canonAmount calculated during decimals conversion? Is _amount or _canonAmount used for calculation?
bridge-ton-contracts/contracts/bridge/proxy/multivault/alien-ton/V3/ProxyMultiVaultAlienJetton_V3_MergePool.tsol:91-94
Data Structures
Token struct
Structure describing a token in MergePool:
struct Token {
uint8 decimals;
bool enabled;
}bridge-ton-contracts/contracts/bridge/interfaces/alien-token-merge/IMergePool.tsol:4-7
Fields:
decimals- number of decimal places for the token (obtained viatakeInfoortakeInfoAlienTvmcallback)enabled- flag indicating whether swap/withdraw is allowed for the token
bridge-ton-contracts/contracts/bridge/alien-token-merge/merge-pool/MergePool_V3.tsol:45
Getting decimals:
When adding a token, MergePool requests information via JettonUtils.getInfo:
bridge-ton-contracts/contracts/bridge/alien-token-merge/merge-pool/MergePool_V3.tsol:146-151bridge-ton-contracts/contracts/bridge/alien-token-merge/merge-pool/MergePool_V3.tsol:489-498
Token responds with takeInfo callback (for EVM/SVM alien tokens) or takeInfoAlienTvm (for TVM alien tokens):
bridge-ton-contracts/contracts/bridge/alien-token-merge/merge-pool/MergePool_V3.tsol:155-187
BurnType enum
Enumeration of operation types when burning a token:
enum BurnType {
Withdraw, // = 0
Swap // = 1
}bridge-ton-contracts/contracts/bridge/interfaces/alien-token-merge/IMergePool.tsol:9
Values:
Withdraw(0) - withdraw tokens to another network via bridge eventSwap(1) - exchange for another token from the pool within TVM
Payload Structures
Base payload for onAcceptTokensBurn:
payload = abi.encode(
nonce: uint32, // unique operation nonce
burnType: BurnType, // operation type (0 = Withdraw, 1 = Swap)
targetToken: address, // target token from pool
operationPayload: TvmCell, // additional data depending on operation
remainingGasTo: address // address for remaining gas return
)bridge-ton-contracts/contracts/bridge/alien-token-merge/merge-pool/MergePool_V3.tsol:357-369
operationPayload for Swap:
Empty cell:
bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:9bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:22
operationPayload for Withdraw:
operationPayload = abi.encode(
network: Network, // target network type (EVM/SVM/TVM)
withdrawPayload: TvmCell // data for specific network
)bridge-ton-contracts/contracts/bridge/alien-token-merge/merge-pool/MergePool_V3.tsol:409
MergePool Management
Deployment
Deploy MergePool:
function deployMergePool(
uint256 _nonce, // unique nonce for deployment
address[] _tokens, // pool token list
uint256 _canonId // canonical token index in _tokens
) external override reserveAtLeastTargetBalancebridge-ton-contracts/contracts/bridge/proxy/multivault/alien-ton/V3/ProxyMultiVaultAlienJetton_V3_MergePool.tsol:30-52
Called only by owner or manager.
Deploy MergeRouter:
function deployMergeRouter(address _token) external override reserveAtLeastTargetBalancebridge-ton-contracts/contracts/bridge/proxy/multivault/alien-ton/V3/ProxyMultiVaultAlienJetton_V3_MergeRouter.tsol:19-28
One MergeRouter is deployed for each alien token. After deployment, setPool is called on MergeRouter to bind it to MergePool.
bridge-ton-contracts/scripts/bootstrap/5-setup-merge-routers.ts:333-360
Deployment sequence (from script):
- 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
bridge-ton-contracts/scripts/bootstrap/5-setup-merge-routers.ts:86-391
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. |
bridge-ton-contracts/contracts/bridge/alien-token-merge/merge-pool/MergePool_V3.tsol:198-304
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 |
bridge-ton-contracts/contracts/bridge/alien-token-merge/merge-pool/MergePool_V3.tsol:41-65
Access modifiers:
onlyOwner- owner onlyonlyOwnerOrManager- owner or manageronlyProxy- proxy contract only
bridge-ton-contracts/contracts/bridge/alien-token-merge/merge-pool/MergePool_V3.tsol:189-196 (setManager - owner only)bridge-ton-contracts/contracts/bridge/alien-token-merge/merge-pool/MergePool_V3.tsol:219-233 (addToken - owner/manager)
MergeRouter rights:
| Role | Capabilities |
|---|---|
| owner | setPool, disablePool, setManager |
| manager | setPool, disablePool |
bridge-ton-contracts/contracts/bridge/alien-token-merge/MergeRouter.tsol:18-50
Payload Encoding
All encoding functions are in MergePoolCellEncoder.tsol:
bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:7
Swap Payload
For old jettons (without remainingGasTo):
function encodeMergePoolBurnSwapPayload(address _targetToken) public pure returns (TvmCell)Encodes:
uint8(1) // burnType = Swap
_targetToken // target token
empty // empty operationPayloadbridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:8-16
For jettons with remainingGasTo:
function encodeMergePoolBurnJettonSwapPayload(
address _targetToken,
address _remainingGasTo
) public pure returns (TvmCell)Encodes:
uint32(0) // nonce
uint8(1) // burnType = Swap
_targetToken // target token
empty // empty operationPayload
_remainingGasTo // address for gas returnbridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:18-31
Withdraw EVM Payload
Without remainingGasTo:
function encodeMergePoolBurnWithdrawPayloadEvm(
address _targetToken,
uint160 _recipient,
EvmCallback _callback
) public pure functionID(0x570f6016) returns (TvmCell)Encodes:
uint32(0) // nonce
uint8(0) // burnType = Withdraw
_targetToken // target token
abi.encode(Network.EVM, abi.encode(_recipient, _callback)) // operationPayloadbridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:33-48
With remainingGasTo:
function encodeMergePoolBurnJettonWithdrawPayloadEvm(
address _targetToken,
uint160 _recipient,
EvmCallback _callback,
address _remainingGasTo
) public pure functionID(0x07bc42ad) returns (TvmCell)bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:50-67
Withdraw SVM Payload
Without remainingGasTo:
function encodeMergePoolBurnWithdrawPayloadSvm(
address _targetToken,
uint256 _recipient,
ITvmSvmEvent.TvmSvmExecuteAccount[] _executeAccounts,
bool _executePayloadNeeded,
ITvmSvmEvent.TvmSvmExecuteAccount[] _executePayloadAccounts,
bytes _payload
) public pure functionID(0x79e64187) returns (TvmCell)bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:69-93
With remainingGasTo:
function encodeMergePoolBurnJettonWithdrawPayloadSvm(
address _targetToken,
uint256 _recipient,
ITvmSvmEvent.TvmSvmExecuteAccount[] _executeAccounts,
bool _executePayloadNeeded,
ITvmSvmEvent.TvmSvmExecuteAccount[] _executePayloadAccounts,
bytes _payload,
address _remainingGasTo
) public pure functionID(0x675e16bc) returns (TvmCell)bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:95-121
Withdraw TVM Payload
function encodeMergePoolBurnJettonWithdrawPayloadTvm(
address _targetToken,
address _recipient,
uint128 _expectedGas,
optional(TvmCell) _payload,
address _remainingGasTo
) public pure functionID(0x0f66760c) returns (TvmCell)Encodes:
uint32(0) // nonce
uint8(0) // burnType = Withdraw
_targetToken // target token
abi.encode(Network.TVM, abi.encode(_recipient, _expectedGas, _payload)) // operationPayload
_remainingGasTo // address for gas returnbridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:123-141
Frontend Integration
TransitOperation
Enum defining transit operation type:
export enum TransitOperation {
BurnToAlienProxy = '0',
BurnToMergePool = '1',
TransferToNativeProxy = '2',
}frontend/src/modules/Transfers/types.ts:49-53
Value BurnToMergePool = '1' corresponds to operation via MergePool.
getEvmTokenMergeDetails
Utility from @broxus/js-bridge-essentials library for getting token merge pool information.
Used in transfer stores:
frontend/src/modules/Transfers/stores/TvmEvmTransferStore.ts:322 (AlienProxyV8.Utils.getEvmTokenMergeDetails)frontend/src/modules/Transfers/stores/TvmEvmTvmTransferStore.ts:353 (AlienProxyV8.Utils.getEvmTokenMergeDetails)frontend/src/modules/Transfers/stores/TvmEvmTonTransferStore.ts:362 (AlienProxyV8.Utils.getEvmTokenMergeDetails)frontend/src/modules/Transfers/stores/TonEvmTransferStore.ts:340 (TonAlienProxyV1.Utils.getEvmTokenMergeDetails)frontend/src/modules/Transfers/stores/TonEvmTvmTransferStore.ts:361 (TonAlienProxyV1.Utils.getEvmTokenMergeDetails)
TODO: requires clarification - what data exactly does getEvmTokenMergeDetails return and how is it used?
Payload Decoding
When decoding burn payload on the frontend, the operation type is checked:
if (operationPayload.data.operation === TransitOperation.BurnToMergePool) {
const burnPayload = await connection.unpackFromCell({
allowPartial: true,
boc: operationPayload.data.payload,
structure: [
{ name: 'nonce', type: 'uint32' },
{ name: 'burnType', type: 'uint8' },
{ name: 'targetToken', type: 'address' },
{ name: 'payload', type: 'cell' },
] as const,
})
const callback = await connection.unpackFromCell({
allowPartial: true,
boc: burnPayload.data.payload,
structure: [
{ name: 'network', type: 'uint8' },
{ name: 'payload', type: 'cell' },
] as const,
})
const payload = await connection.unpackFromCell({
allowPartial: true,
boc: callback.data.payload,
structure: [
{ name: 'targetAddress', type: 'uint160' },
{
components: [
{ name: 'recipient', type: 'uint160' },
{ name: 'payload', type: 'cell' },
{ name: 'strict', type: 'bool' },
],
name: 'callback',
type: 'tuple',
},
] as const,
})
return `0x${BigNumber(payload.data.targetAddress).toString(16).padStart(40, '0')}`
}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 |
| Incorrect canon for limits | If canon token has small decimals and withdrawn token has large decimals, limits may be bypassed | TODO: requires clarification of canonAmount calculation mechanism |
| 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 |