Skip to content

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:

  1. Swap between alien representations: exchange token A for token B at a 1:1 ratio (with decimals normalization)
  2. Withdraw via canonical token: withdraw any token from the pool using the canonical token for daily limits verification

Architecture

Components

ComponentDescription
MergePoolMain pool contract. Stores token list with their decimals and enabled status. Handles token burns and performs swap or withdraw.
MergeRouterRouter contract associated with a specific token. Stores the MergePool address to which the token belongs. Deployed separately for each alien token.
MergePoolPlatformPlatform contract for deploying MergePool via TVM state init mechanism. Accepts MergePool code and initializes it with given parameters.
ProxyMultiVaultAlienProxy contract that deploys MergePool and MergeRouter, manages versions, handles mint/withdraw requests from MergePool.

Interaction Diagram



Flow Description:

  1. User burns token A, passing payload with operation type (Swap/Withdraw) and target token
  2. MergePool receives onAcceptTokensBurn callback, decodes payload, converts decimals
  3. Depending on operation type:
    • Swap: MergePool calls mintTokensByMergePool on Proxy to mint target token
    • Withdraw: MergePool calls withdrawTokensToEvmByMergePool on Proxy to create withdraw event
  4. Proxy executes the corresponding action

MergePool Versions

VersionChanges from Previous
V1Base version. EVM and SVM withdraw support.
V2Added TVM-TVM transfer support (withdrawTokensToTvmByMergePool method, ITakeInfoAlienTvm interface).
V3Passing _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 (from msg.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:

  1. 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.

  2. 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:

solidity
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:

typescript
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:

solidity
uint128 amount = _convertDecimals(_amount, msg.sender, targetToken);

Step 4: Checks

MergePool validates conditions:

  1. Target token is enabled
  2. Source token is enabled
  3. Converted amount > 0

Step 5: Mint target token

If checks pass and burnType == BurnType.Swap:

solidity
_mintTokens(targetToken, amount, _walletOwner, remainingGasTo, operationPayload);

MergePool calls on Proxy

Decimals Normalization

The _convertDecimals function ensures correct exchange of tokens with different decimals:

solidity
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:

typescript
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:

solidity
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:

solidity
(
    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:

solidity
_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-93
bridge-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-485
bridge-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

AspectRegular WithdrawWithdraw via MergePool
Target tokenAlways the same token being burnedCan be any token from the pool
Daily limitsChecked for burned tokenChecked for canonical token
Decimals conversionNot requiredPerformed when necessary
Entry pointDirect burn → eventBurn → 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:

  1. MergePool contains: USDT-Ethereum (canon, 6 decimals), USDT-BSC (18 decimals), USDT-Avalanche (6 decimals)
  2. Daily outgoing limit for USDT-Ethereum: 100,000 tokens
  3. 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
  4. User B withdraws 40,000 USDT-Avalanche → EVM
    • Canon amount: 40,000 * 10^6
    • Daily volume increases by 40,000 USDT-Ethereum
  5. 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:

solidity
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 via takeInfo or takeInfoAlienTvm callback)
  • 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-151
bridge-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:

solidity
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 event
  • Swap (1) - exchange for another token from the pool within TVM

Payload Structures

Base payload for onAcceptTokensBurn:

solidity
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:9
bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:22

operationPayload for Withdraw:

solidity
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:

solidity
function deployMergePool(
    uint256 _nonce,      // unique nonce for deployment
    address[] _tokens,   // pool token list
    uint256 _canonId     // canonical token index in _tokens
) external override reserveAtLeastTargetBalance

bridge-ton-contracts/contracts/bridge/proxy/multivault/alien-ton/V3/ProxyMultiVaultAlienJetton_V3_MergePool.tsol:30-52

Called only by owner or manager.

Deploy MergeRouter:

solidity
function deployMergeRouter(address _token) external override reserveAtLeastTargetBalance

bridge-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):

  1. Deploy alien tokens (if not already deployed)
  2. Deploy MergeRouter for each token
  3. Deploy MergePool with token list and canon token specification
  4. Bind MergeRouter to MergePool via setPool
  5. Enable all tokens via enableAll

bridge-ton-contracts/scripts/bootstrap/5-setup-merge-routers.ts:86-391

Token Management

FunctionAccessDescription
addToken(address _token)owner/managerAdd token to pool. Token must not exist in pool. Automatically requests decimals.
removeToken(address _token)owner/managerRemove token from pool. Token must exist and not be canon token.
enableToken(address _token)owner/managerEnable token (allow swap/withdraw). Requires decimals > 0.
disableToken(address _token)owner/managerDisable token (prohibit swap/withdraw).
enableAll()owner/managerEnable all pool tokens. All tokens must have decimals > 0.
disableAll()owner/managerDisable all pool tokens.
setCanon(address _token)owner/managerSet canonical token. Token must be enabled.

bridge-ton-contracts/contracts/bridge/alien-token-merge/merge-pool/MergePool_V3.tsol:198-304

Access Rights

RoleAddressCapabilities
ownerSet during deployment via PlatformFull access: token management, change manager, upgrade contract
managerSet during deployment, changed via setManagerToken management (add/remove/enable/disable), set canon
proxyStatic variable, set during deploymentCall acceptUpgrade, call mint/withdraw callbacks

bridge-ton-contracts/contracts/bridge/alien-token-merge/merge-pool/MergePool_V3.tsol:41-65

Access modifiers:

  • onlyOwner - owner only
  • onlyOwnerOrManager - owner or manager
  • onlyProxy - 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:

RoleCapabilities
ownersetPool, disablePool, setManager
managersetPool, 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):

solidity
function encodeMergePoolBurnSwapPayload(address _targetToken) public pure returns (TvmCell)

Encodes:

uint8(1)          // burnType = Swap
_targetToken      // target token
empty             // empty operationPayload

bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:8-16

For jettons with remainingGasTo:

solidity
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 return

bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:18-31

Withdraw EVM Payload

Without remainingGasTo:

solidity
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))  // operationPayload

bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:33-48

With remainingGasTo:

solidity
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:

solidity
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:

solidity
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

solidity
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 return

bridge-ton-contracts/contracts/utils/cell-encoder/MergePoolCellEncoder.tsol:123-141

Frontend Integration

TransitOperation

Enum defining transit operation type:

typescript
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:

typescript
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

CodeConstantDescription
2709WRONG_MERGE_POOL_NONCEIncorrect MergePool nonce when calling Proxy methods (sender does not match deriveMergePool)
2901WRONG_PROXYCalling contract is not the proxy contract
2902ONLY_OWNER_OR_MANAGERAction available only to owner or manager
2903TOKEN_NOT_EXISTSToken does not exist in pool
2904TOKEN_IS_CANONAttempt to remove canonical token
2905TOKEN_ALREADY_EXISTSToken already exists in pool
2906MERGE_POOL_IS_ZERO_ADDRESSAttempt to set zero address as merge pool address in MergeRouter
2907TOKEN_NOT_ENABLEDToken is not enabled (disabled)
2908TOKEN_DECIMALS_IS_ZEROToken decimals are 0 (not yet received)
2909WRONG_CANON_IDIncorrect canonical token index during deployment

Risks and Edge Cases

Risk/CaseDescriptionMitigation
Decimals precision lossWhen swapping from token with large decimals (18) to token with small decimals (6), small amounts may round to 0MergePool checks amount == 0 after conversion and returns original tokens
Token disabled mid-swapToken is disabled between burn initiation and MergePool processingMergePool checks enabled during processing and returns tokens when enabled == false
Incorrect canon for limitsIf canon token has small decimals and withdrawn token has large decimals, limits may be bypassedTODO: requires clarification of canonAmount calculation mechanism
MergeRouter with incorrect poolMergeRouter points to non-existent or incorrect MergePoolValidation during setup: MERGE_POOL_IS_ZERO_ADDRESS . Administrative responsibility.
Upgrade MergePool without state migrationToken data is lost during upgradeacceptUpgrade mechanism preserves state
Race condition in enableAllIf not all decimals are received, enableAll revertsRequirement decimals > 0 for all tokens
Burn non-existent tokenUser burns token not in pooltokenExists modifier reverts transaction
Withdraw call from unauthorized contractAttempt to call withdrawTokensToEvmByMergePool not from MergePoolonlyMergePool modifier checks nonce correspondence

ChainConnect Bridge Documentation