Skip to content

TVM→EVM Transfer

Overview

A TVM→EVM transfer allows you to send tokens from a TVM network to an EVM network. The process includes:

  1. Build Payload — obtain transaction data via the API
  2. Burn/Transfer in TVM — send a transaction in TVM (burn Alien or transfer Native to Proxy)
  3. Obtain Event Contract Address — find the address of the deployed Event contract
  4. Wait for confirmations — relay nodes sign the event, the Event contract accumulates signatures
  5. Withdraw in EVM (non-credit) — read signatures from the Event contract and call saveWithdraw*() in MultiVault
  6. If withdrawal is delayed — when limits are exceeded or liquidity is insufficient, a Pending Withdrawal is created

Consensus mechanism (TVM→EVM)

For TVM→EVM, consensus is achieved through relay node signatures:

  • Relay nodes call confirm(signature, voteReceiver) with a cryptographic signature
  • The Event contract stores signatures in the signatures mapping
  • Signatures are passed to the EVM contract MultiVault.saveWithdraw*()
  • The EVM contract verifies signatures using relay node public keys
  • Consensus: confirms >= requiredVotes, where requiredVotes = keys.length * 2/3 + 1

Signatures are required because the EVM contract cannot read TVM state and must receive consensus proof.

Token types

TypeTVM operationEVM operationDescription
Alienburn on TokenWalletunlock in MultiVaultTokens are burned in TVM, unlocked in EVM
Nativelock on Proxymint in MultiVaultTokens are locked in TVM, minted in EVM

API Base URL

All API requests in this guide use one of two environments:

EnvironmentBase URL
Productionhttps://tetra-history-api.chainconnect.com/v2
Testnethttps://history-api-test.chainconnect.com/v2

Endpoints used in the guide:

EndpointMethodStep
/payload/buildPOSTStep 1 — build payload
/transfers/statusPOSTStep 4 — check status
/transfers/searchPOSTSearch transfers

Step 1: Prepare the transfer (Build Payload)

Goal: Obtain the payload for sending a transaction in TVM

API Endpoint

POST {BASE_URL}/payload/build
Content-Type: application/json

Request parameters

typescript
interface TransferPayloadRequest {
  fromChainId: number;          // TVM chain ID of the source network
  toChainId: number;            // EVM chain ID of the target network
  tokenAddress: string;         // Token address in TVM (format 0:...)
  recipientAddress: string;     // Recipient address in EVM (hex format 0x...)
  amount: string;               // Amount in nano-units (integer string)
  senderAddress: string;        // Sender address in TVM (format 0:...)

  useCredit?: boolean;          // true = Credit Backend pays gas. Default: false
  remainingGasTo?: string;      // Where to return remaining gas (for non-credit)
  payload?: string;             // Additional payload
  callback?: CallbackRequest;   // Callback on completion
  evmChainId?: number;          // Explicit EVM chain ID
  tokenBalance?: {              // For native currency (wrapped native token)
    nativeTokenAmount: string;  // Native currency balance (for wrapped native token transfers)
    wrappedNativeTokenAmount: string; // Wrapped native balance (for wrapped native token transfers)
  };
}
ParameterTypeRequiredDescription
fromChainIdnumberTVM chain ID of the source network
toChainIdnumberEVM chain ID of the target network
tokenAddressstringTIP-3 token address in TVM (format 0:...)
recipientAddressstringRecipient address in EVM (format 0x... lowercase)
amountstringAmount in smallest units (integer as string, e.g. "1000000" for 1 USDT with 6 decimals)
senderAddressstringSender address in TVM (format 0:...)
useCreditbooleanIf true — Gas Credit Backend automatically calls saveWithdraw in EVM. Default: false
remainingGasTostringAddress for returning remaining gas (used when useCredit=false)
callbackobjectCallback parameters (EVM contract address to call upon completion)
tokenBalance.nativeTokenAmountstring⚠️Gas token balance (native currency). Required when sending gas token
tokenBalance.wrappedNativeTokenAmountstring⚠️Wrapped gas token balance. Required when sending gas token

Request example

bash
curl -X POST '{BASE_URL}/payload/build' \
  -H 'Content-Type: application/json' \
  -d '{
    "fromChainId": -239,
    "toChainId": 1,
    "tokenAddress": "0:1111...1111",
    "recipientAddress": "0x742d...Ab12",
    "amount": "1000000000",
    "senderAddress": "0:2222...2222",
    "useCredit": false,
    "remainingGasTo": "0:2222...2222"
  }'

Response example

json
{
  "transferKind": "TvmToEvm",
  "tokensMeta": {
    "sourceTokenType": "Alien",
    "targetToken": {
      "tokenType": "Alien",
      "isDeployed": true,
      "address": "0xdAC1...1ec7"
    }
  },
  "tokenAmount": "999000000",
  "feesMeta": {
    "amount": "1000000",
    "numerator": "1",
    "denominator": "1000"
  },
  "gasEstimateAmount": "500000000",
  "abiMeta": {
    "tx": "te6ccgEBBwEA6AABiw/X8c8...",
    "abi": "{\"ABI version\": 2, \"functions\": [...]}",
    "abiMethod": "burn",
    "params": "{\"amount\": \"1000000000\", \"remainingGasTo\": \"0:2222...2222\", ...}",
    "executionAddress": "0:3333...3333",
    "attachedValue": "2500000000"
  },
  "trackingMeta": {
    "sourceProxy": "0:4444...4444",
    "sourceConfiguration": "0:5555...5555",
    "sourceMultivault": null,
    "targetProxy": null,
    "targetConfiguration": null,
    "targetMultivault": "0x6666...6666"
  },
  "payload": null
}

Response fields

FieldDescriptionUsage
transferKindTransfer type (TvmToEvm)Direction confirmation
tokensMeta.sourceTokenTypeToken type at source (Alien or Native)Determines method: burn vs transfer
tokenAmountFinal amount after fee deductionAmount the user will receive
feesMeta.amountBridge fee amountFee information
feesMeta.numerator / denominatorFee fractionFee percentage calculation
abiMeta.executionAddressTokenWallet address for the transactionTVM transaction recipient (Step 2)
abiMeta.txFull call BOC (base64)Ready body for TonConnect (Step 2)
abiMeta.abiContract ABI (JSON string)For calling via TVM SDK (Step 2)
abiMeta.abiMethodContract method (burn or transfer)Method name for the call (Step 2)
abiMeta.paramsMethod parameters (JSON string)Parameters for calling via TVM SDK (Step 2)
abiMeta.attachedValueAmount of nanoTON to sendTVM transaction gas (Step 2)
trackingMeta.sourceConfigurationEventConfiguration address in TVMFor obtaining Event Contract Address (Step 3)
trackingMeta.sourceProxyProxy contract address in TVMInformational
trackingMeta.targetMultivaultMultiVault address in EVMFor calling saveWithdraw*() (Step 5)

Step 2: Send transaction in TVM

Goal: Execute burn (Alien) or transfer (Native) in the TVM network and obtain the Transaction Hash

What you need from Step 1

ParameterDescriptionUsage
abiMeta.txFull burn/transfer call BOCFor TonConnect — ready message body
abiMeta.abiContract ABI (JSON)For TVM SDK — creating Contract
abiMeta.abiMethod"burn" or "transfer"For TVM SDK — method name
abiMeta.paramsMethod parameters (JSON)For TVM SDK — call arguments
abiMeta.executionAddressTokenWallet addressTransaction recipient
abiMeta.attachedValueGas in nanoTONTransaction amount

Difference between operations

OperationToken typeDescription
burnAlienToken is burned on TokenWallet. TokenRoot calls callback in ProxyMultiVaultAlien
transferNativeToken is transferred to Proxy wallet. TokenWallet calls callback in ProxyMultiVaultNative

Sending the transaction

typescript
import TonConnectUI from '@tonconnect/ui';
import { Cell } from '@ton/core';

interface TransferPayloadResponse {
  abiMeta: {
    tx: string;              // BOC base64
    executionAddress: string; // TokenWallet address
    attachedValue: string;   // nanoTON
    abiMethod: 'burn' | 'transfer';
  };
  trackingMeta: {
    sourceProxy: string | null;
    sourceConfiguration: string | null;
    targetMultivault: string | null;
  };
}

async function sendTvmToEvmTransaction(
  tonConnectUI: TonConnectUI,
  payloadResponse: TransferPayloadResponse
): Promise<string> {
  const { abiMeta } = payloadResponse;

  console.log(`Sending ${abiMeta.abiMethod} transaction...`);
  console.log('Contract address:', abiMeta.executionAddress);
  console.log('Gas:', abiMeta.attachedValue, 'nanoTON');

  // Build the transaction
  // abiMeta.tx — ready BOC with encoded burn/transfer call,
  // including token amount, recipient address and bridge payload.
  // abiMeta.attachedValue — gas (nanoTON), not the token amount.
  const transaction = {
    validUntil: Math.floor(Date.now() / 1000) + 600, // 10 minutes
    messages: [
      {
        address: abiMeta.executionAddress,  // TokenWallet address
        amount: abiMeta.attachedValue,      // Gas (nanoTON)
        payload: abiMeta.tx,                // BOC: burn/transfer + token amount inside
      },
    ],
  };

  // Send via TonConnect
  const result = await tonConnectUI.sendTransaction(transaction);

  // TonConnect returns a BOC (serialized message), not a transaction hash.
  // Extract message hash from the BOC — it is used in TonAPI to find the trace.
  const messageHash = Cell.fromBase64(result.boc).hash().toString('hex');
  console.log('Transaction sent, message hash:', messageHash);

  return messageHash;
}
typescript
import { Address, ProviderRpcClient } from 'everscale-inpage-provider';

interface TransferPayloadResponse {
  abiMeta: {
    tx: string;              // BOC base64 (full burn/transfer call — for TonConnect)
    abi: string;             // Contract ABI (JSON string)
    abiMethod: 'burn' | 'transfer';
    params: string;          // Method parameters (JSON string)
    executionAddress: string; // TokenWallet address
    attachedValue: string;   // nanoTON
  };
  trackingMeta: {
    sourceProxy: string | null;
    sourceConfiguration: string | null;
    targetMultivault: string | null;
  };
}

async function sendTvmToEvmTransaction(
  tvmClient: ProviderRpcClient,
  senderAddress: string,
  payloadResponse: TransferPayloadResponse
): Promise<string> {
  const { abiMeta } = payloadResponse;

  console.log(`Sending ${abiMeta.abiMethod} transaction...`);
  console.log('Contract address:', abiMeta.executionAddress);
  console.log('Gas:', abiMeta.attachedValue, 'nanoTON');

  // The API returns the contract ABI, method name and parameters —
  // use them directly for calling via TVM SDK.
  const contract = new tvmClient.Contract(
    JSON.parse(abiMeta.abi),
    new Address(abiMeta.executionAddress)
  );

  const params = JSON.parse(abiMeta.params);
  const { transaction } = await contract.methods[abiMeta.abiMethod](params).send({
    from: new Address(senderAddress),
    amount: abiMeta.attachedValue,
    bounce: true,
  });

  console.log('Transaction sent, hash:', transaction.id.hash);

  return transaction.id.hash;
}

Usage example

typescript
import TonConnectUI from '@tonconnect/ui';

// TonConnect manifest — a JSON file describing your dApp (name, icon, URL).
// Hosted on your domain. Format: { "url": "...", "name": "...", "iconUrl": "..." }
const tonConnectUI = new TonConnectUI({
  manifestUrl: 'https://your-app.com/tonconnect-manifest.json',
});

// payloadResponse obtained in Step 1
const messageHash = await sendTvmToEvmTransaction(tonConnectUI, payloadResponse);
console.log('Message hash obtained:', messageHash);
typescript
import { EverscaleStandaloneClient } from 'everscale-standalone-client';
import { ProviderRpcClient } from 'everscale-inpage-provider';

const tvmClient = new ProviderRpcClient({
  fallback: () => EverscaleStandaloneClient.create({
    connection: { type: 'jrpc', data: { endpoint: 'https://jrpc-ton.broxus.com' } },
  }),
});
await tvmClient.ensureInitialized();

// payloadResponse obtained in Step 1
const txHash = await sendTvmToEvmTransaction(
  tvmClient,
  '0:2222...2222',  // User's TVM wallet address
  payloadResponse
);
console.log('Transaction hash obtained:', txHash);

After a successful transaction

The obtained transaction hash (Transaction Hash or Message Hash) is used in the next step to find the Event contract via the transaction trace tree (Step 3).

After the transaction is confirmed in TVM, an Event contract is automatically created. Its address (Event Contract Address) is used as the transfer identifier for status tracking, signature collection, and diagnostics.

Step 3: Obtain Event Contract Address

Goal: Find the Event contract address for transfer tracking

Important

Unlike EVM→TVM and TVM→TVM (which use transaction hash), for TVM→EVM you need the Event Contract Address of the created Event contract.

How the Event contract is created

When a burn (Alien) or transfer (Native) transaction is executed:

  1. TokenWallet calls a callback in TokenRoot or ProxyMultiVaultAlien/ProxyMultiVaultNative
  2. Proxy deploys an Event contract via TvmEvmEventConfiguration
  3. The Event contract is created with transfer data
  4. EventConfiguration emits a NewEventContract event with the Event contract address

Obtaining Event Contract Address

typescript
// When the Event contract is deployed, EventConfiguration creates a child transaction
// on the new contract. We search the trace for a transaction on sourceConfiguration
// and take the address of its child transaction.

async function getEventAddressViaTonApi(
  walletTxHash: string,
  sourceConfiguration: string // trackingMeta.sourceConfiguration from Step 1
): Promise<string | null> {
  const response = await fetch(
    `https://tetra.tonapi.io/v2/traces/${walletTxHash}`
  );
  const trace = await response.json();

  function findEventContract(node: any): string | null {
    const account = node.transaction?.account?.address;
    const configAddr = sourceConfiguration.toLowerCase();

    if (account && account.toLowerCase() === configAddr) {
      // Found a transaction on EventConfiguration.
      // The Event contract is the child transaction (new contract deployment).
      for (const child of node.children || []) {
        const childAccount = child.transaction?.account?.address;
        if (childAccount) {
          return childAccount; // Address of the deployed Event contract
        }
      }
    }

    for (const child of node.children || []) {
      const result = findEventContract(child);
      if (result) return result;
    }

    return null;
  }

  return findEventContract(trace);
}
typescript
import { Address, ProviderRpcClient } from 'everscale-inpage-provider';

// Minimal TvmEvmEventConfiguration ABI — only the NewEventContract event
// Source: ../build/TvmEvmEventConfiguration.abi.json
const EVENT_CONFIG_EVENTS_ABI = {
  events: [
    {
      name: 'NewEventContract',
      inputs: [{ name: 'eventContract', type: 'address' }],
      outputs: [],
    },
  ],
} as const;

async function getEventContractAddress(
  provider: ProviderRpcClient,
  transactionHash: string,
  sourceConfiguration: string // trackingMeta.sourceConfiguration from Step 1
): Promise<string | null> {
  const tx = await provider.getTransaction({ hash: transactionHash });

  if (!tx) {
    throw new Error('Transaction not found');
  }

  const configContract = new provider.Contract(
    EVENT_CONFIG_EVENTS_ABI,
    new Address(sourceConfiguration)
  );

  const subscriber = new provider.Subscriber();

  try {
    // Search the trace for a transaction on EventConfiguration
    const eventAddress = await subscriber
      .trace(tx)
      .filterMap(async (traceTx) => {
        if (traceTx.account.toString() !== sourceConfiguration) {
          return undefined;
        }

        // decodeTransactionEvents parses out_messages of the transaction using the contract ABI
        const events = await configContract.decodeTransactionEvents({
          transaction: traceTx,
        });
        const newEventContract = events.find(e => e.event === 'NewEventContract');

        return newEventContract?.data.eventContract;
      })
      .first();

    return eventAddress?.toString() ?? null;
  } finally {
    await subscriber.unsubscribe();
  }
}

Step 4: Track transfer status

Goal: Monitor transfer progress and check its current status

API Endpoint

POST {BASE_URL}/transfers/status
Content-Type: application/json

Request parameters

typescript
interface StatusRequest {
  tvmEvm: {
    contractAddress: string;       // Event contract address from Step 3
    dappChainId: number;           // TVM chain ID of the source network
    timestampCreatedFrom?: number; // Time filter (optional)
  };
}
ParameterDescription
contractAddressEvent contract address (format 0:...)
dappChainIdTVM chain ID of the source network
timestampCreatedFromMinimum timestamp for filter (optional)

Request example

bash
curl -X POST '{BASE_URL}/transfers/status' \
  -H 'Content-Type: application/json' \
  -d '{
    "tvmEvm": {
      "contractAddress": "0:abcd...abcd",
      "dappChainId": -239
    }
  }'

Response example (Pending)

json
{
  "transfer": {
    "tvmEvm": {
      "transferStatus": "Pending",
      "timestampCreatedAt": 1704067200,
      "outgoing": {
        "tokenType": "Alien",
        "contractAddress": "0:abcd...abcd",
        "chainId": -239,
        "userAddress": "0:2222...2222",
        "tokenAddress": "0:1111...1111",
        "proxyAddress": "0:7777...0000",
        "volumeExec": "0.999000",
        "volumeUsdtExec": "0.999",
        "feeVolumeExec": "0.001000",
        "withdrawalId": null,
        "transactionHash": null
      },
      "incoming": {
        "chainId": 1,
        "userAddress": "0x742d...Ab12",
        "tokenAddress": "0xdAC1...1ec7"
      }
    }
  },
  "updatedAt": 1704067200,
  "proofPayload": null,
  "notInstantTransfer": null
}

Response example (Completed)

json
{
  "transfer": {
    "tvmEvm": {
      "transferStatus": "Completed",
      "timestampCreatedAt": 1704067200,
      "outgoing": {
        "tokenType": "Alien",
        "contractAddress": "0:abcd...abcd",
        "chainId": -239,
        "userAddress": "0:2222...2222",
        "tokenAddress": "0:1111...1111",
        "proxyAddress": "0:7777...0000",
        "volumeExec": "0.999000",
        "volumeUsdtExec": "0.999",
        "feeVolumeExec": "0.001000",
        "withdrawalId": "0x1234567890abcdef...",
        "transactionHash": "0x789abc..."
      },
      "incoming": {
        "chainId": 1,
        "userAddress": "0x742d...Ab12",
        "tokenAddress": "0xdAC1...1ec7"
      }
    }
  },
  "updatedAt": 1704067260,
  "proofPayload": null,
  "notInstantTransfer": null
}

proofPayload field

For TVM→EVM transfers, proofPayload is always null. Relay node signatures are stored in the Event contract on the TVM side and are read directly from there (see Step 5).

Transfer statuses

StatusDescription
PendingTransfer in progress. Event contract created, relay nodes are collecting signatures.
CompletedTransfer fully completed. Tokens withdrawn in EVM (withdrawalId and transactionHash are filled).
FailedTransfer failed. Event contract rejected the event (invalid parameters).

When to proceed to Step 5 (non-credit)

  • For credit mode, wait for Completed status — Gas Credit Backend will complete the transfer automatically.
  • For non-credit, you don't need to wait for Completed — proceed to Step 5 as soon as the Event contract has collected enough relay node signatures. The readEventData() function from Step 5.1 will check the signature count and throw an error if there aren't enough.

Step 5: Complete the transfer (Non-Credit mode)

Goal: Collect relay node signatures from the TVM Event contract, encode the data, and call saveWithdraw*() in MultiVault to withdraw tokens in EVM.

When is this step required?

useCreditAction
trueCredit Backend automatically calls saveWithdraw*(). Skip Step 5.
falseYou must manually collect signatures and call saveWithdraw*(). Execute Step 5.

Difference between withdrawal methods

MethodToken typeDescription
saveWithdrawAlienAlienUnlocks Alien tokens in MultiVault, transfers to user
saveWithdrawNativeNativeMints new ERC-20 tokens via create2, transfers to user

TVM SDK required

Step 5 requires reading TVM contract state (calling get-methods getDetails, round_number, getFlags). This requires everscale-inpage-provider — it cannot be done via TonAPI or other HTTP APIs.

5.1: Read data from the Event contract (TVM)

After relay nodes have confirmed the event, you need to read from TVM:

  • Event contract — event data and relay node signatures
  • EventConfiguration — event ABI, flags, and proxy address
  • Round number — relay node round number
typescript
import { Address, ProviderRpcClient } from 'everscale-inpage-provider';

// Minimal Event contract ABI (getDetails + round_number)
// Source: ../build/MultiVaultTvmEvmEventAlien.abi.json
//         ../build/MultiVaultTvmEvmEventNative.abi.json
const EVENT_ABI = {
  functions: [
    {
      name: 'getDetails',
      inputs: [{ name: 'answerId', type: 'uint32' }],
      outputs: [
        { name: '_eventInitData', type: 'tuple', components: [
          { name: 'voteData', type: 'tuple', components: [
            { name: 'eventTransactionLt', type: 'uint64' },
            { name: 'eventTimestamp', type: 'uint32' },
            { name: 'eventData', type: 'cell' },
          ]},
          { name: 'configuration', type: 'address' },
          { name: 'roundDeployer', type: 'address' },
        ]},
        { name: '_status', type: 'uint8' },
        { name: '_confirms', type: 'uint256[]' },
        { name: '_rejects', type: 'uint256[]' },
        { name: 'empty', type: 'uint256[]' },
        { name: '_signatures', type: 'bytes[]' },
        { name: 'balance', type: 'uint128' },
        { name: '_initializer', type: 'address' },
        { name: '_meta', type: 'cell' },
        { name: '_requiredVotes', type: 'uint32' },
      ],
    },
    {
      name: 'round_number',
      inputs: [],
      outputs: [{ name: 'round_number', type: 'uint32' }],
    },
  ],
} as const;

// Minimal TvmEvmEventConfiguration ABI (getDetails + getFlags)
// Source: ../build/TvmEvmEventConfiguration.abi.json
const EVENT_CONFIG_ABI = {
  functions: [
    {
      name: 'getDetails',
      inputs: [{ name: 'answerId', type: 'uint32' }],
      outputs: [
        { name: '_basicConfiguration', type: 'tuple', components: [
          { name: 'eventABI', type: 'bytes' },
          { name: 'roundDeployer', type: 'address' },
          { name: 'eventInitialBalance', type: 'uint64' },
          { name: 'eventCode', type: 'cell' },
        ]},
        { name: '_networkConfiguration', type: 'tuple', components: [
          { name: 'eventEmitter', type: 'address' },
          { name: 'proxy', type: 'uint160' },
          { name: 'startTimestamp', type: 'uint32' },
          { name: 'endTimestamp', type: 'uint32' },
        ]},
        { name: '_meta', type: 'cell' },
      ],
    },
    {
      name: 'getFlags',
      inputs: [{ name: 'answerId', type: 'uint32' }],
      outputs: [{ name: '_flags', type: 'uint64' }],
    },
  ],
} as const;

async function readEventData(
  provider: ProviderRpcClient,
  eventContractAddress: string,
  configurationAddress: string
) {
  const eventContract = new provider.Contract(EVENT_ABI, new Address(eventContractAddress));
  const configContract = new provider.Contract(EVENT_CONFIG_ABI, new Address(configurationAddress));

  // Read data in parallel
  const [eventDetails, roundNumberResult, configDetails, flagsResult] = await Promise.all([
    eventContract.methods.getDetails({ answerId: 0 }).call(),
    eventContract.methods.round_number({}).call(),
    configContract.methods.getDetails({ answerId: 0 }).call(),
    configContract.methods.getFlags({ answerId: 0 }).call(),
  ]);

  // Verify that enough relay node signatures have been collected
  const requiredVotes = Number(eventDetails._requiredVotes);
  const signaturesCount = eventDetails._signatures.filter(s => s !== '').length;

  if (signaturesCount < requiredVotes) {
    throw new Error(
      `Not enough signatures: ${signaturesCount}/${requiredVotes}. ` +
      `Event contract is not yet confirmed — retry later.`
    );
  }

  return {
    eventInitData: eventDetails._eventInitData,
    signatures: eventDetails._signatures.filter(s => s !== ''), // only non-empty signatures
    roundNumber: roundNumberResult.round_number,
    eventABI: configDetails._basicConfiguration.eventABI,  // base64-encoded event ABI
    proxy: configDetails._networkConfiguration.proxy,
    flags: flagsResult._flags,
  };
}

5.2: Encode the payload

The event data must be encoded into a format understood by the EVM MultiVault contract:

typescript
import { mapTonCellIntoEthBytes } from 'eth-ton-abi-converter';
import { ethers } from 'ethers';

function encodeEventData(
  eventInitData: any,
  eventContractAddress: string,
  roundNumber: string,
  eventABI: string,    // base64-encoded ABI
  proxy: string,       // uint160
  flags: string
): string {
  // 1. Convert eventData from TVM Cell to EVM bytes
  const eventABIDecoded = Buffer.from(eventABI, 'base64').toString();
  const eventDataEncoded = mapTonCellIntoEthBytes(
    eventABIDecoded,
    eventInitData.voteData.eventData,
    flags,
  );

  // 2. Prepare addresses (format "wid:address")
  const [configWid, configAddr] = eventInitData.configuration.toString().split(':');
  const [eventWid, eventAddr] = eventContractAddress.split(':');

  // 3. Convert proxy uint160 → EVM address
  const proxyAddress = `0x${BigInt(proxy).toString(16).padStart(40, '0')}`;

  // 4. Encode event struct for EVM
  const abiCoder = ethers.AbiCoder.defaultAbiCoder();
  const encodedEvent = abiCoder.encode(
    [
      'tuple(uint64 eventTransactionLt, uint32 eventTimestamp, bytes eventData, ' +
      'int8 configurationWid, uint256 configurationAddress, ' +
      'int8 eventContractWid, uint256 eventContractAddress, ' +
      'address proxy, uint32 round)'
    ],
    [{
      eventTransactionLt: eventInitData.voteData.eventTransactionLt,
      eventTimestamp: eventInitData.voteData.eventTimestamp,
      eventData: eventDataEncoded,
      configurationWid: configWid,
      configurationAddress: `0x${configAddr}`,
      eventContractWid: eventWid,
      eventContractAddress: `0x${eventAddr}`,
      proxy: proxyAddress,
      round: roundNumber,
    }]
  );

  return encodedEvent;
}

5.3: Process signatures

Relay node signatures are stored in the Event contract in base64 format. For the EVM contract, they need to be converted to hex and sorted by signer address (ascending):

typescript
import { ethers } from 'ethers';

function processSignatures(
  signatures: string[],   // base64-encoded signatures from the Event contract
  encodedEvent: string     // encoded payload from Step 5.2
): string[] {
  // 1. Hash the payload
  const messageHash = ethers.keccak256(encodedEvent);

  // 2. Convert signatures and recover signer addresses
  const signaturesWithAddresses = signatures.map(sign => {
    const signature = `0x${Buffer.from(sign, 'base64').toString('hex')}`;
    // recoverAddress performs ecrecover on the raw digest (without EIP-191 prefix)
    const address = ethers.recoverAddress(messageHash, signature);
    return {
      signature,
      address,
      order: BigInt(address), // for numeric sorting
    };
  });

  // 3. Sort by signer address (ascending)
  signaturesWithAddresses.sort((a, b) => {
    if (a.order < b.order) return -1;
    if (a.order > b.order) return 1;
    return 0;
  });

  return signaturesWithAddresses.map(s => s.signature);
}

Important

Signature order is critical — the EVM contract verifies that signatures are sorted by signer address. Incorrect order will cause a verification error.

5.4: Call saveWithdraw

The final step — calling the MultiVault contract in EVM:

typescript
async function saveWithdrawAlien(
  signer: ethers.Signer,
  multiVaultAddress: string,
  encodedEvent: string,
  signatures: string[]
): Promise<string> {
  const multiVaultAbi = [
    'function saveWithdrawAlien(bytes payload, bytes[] signatures) external',
  ];

  const multiVault = new ethers.Contract(multiVaultAddress, multiVaultAbi, signer);

  console.log('Calling saveWithdrawAlien...');
  console.log('Number of signatures:', signatures.length);

  const tx = await multiVault.saveWithdrawAlien(encodedEvent, signatures);

  console.log('Transaction sent:', tx.hash);
  const receipt = await tx.wait(1);

  if (receipt?.status === 0) {
    throw new Error('saveWithdrawAlien failed');
  }

  console.log('saveWithdrawAlien confirmed');
  return receipt?.hash || '';
}
typescript
async function saveWithdrawNative(
  signer: ethers.Signer,
  multiVaultAddress: string,
  encodedEvent: string,
  signatures: string[]
): Promise<string> {
  const multiVaultAbi = [
    'function saveWithdrawNative(bytes payload, bytes[] signatures) external',
  ];

  const multiVault = new ethers.Contract(multiVaultAddress, multiVaultAbi, signer);

  console.log('Calling saveWithdrawNative...');
  console.log('Number of signatures:', signatures.length);

  const tx = await multiVault.saveWithdrawNative(encodedEvent, signatures);

  console.log('Transaction sent:', tx.hash);
  const receipt = await tx.wait(1);

  if (receipt?.status === 0) {
    throw new Error('saveWithdrawNative failed');
  }

  console.log('saveWithdrawNative confirmed');
  return receipt?.hash || '';
}

Step 6: If withdrawal is delayed

Sometimes after saveWithdraw*(), tokens do not arrive immediately. This happens in two cases:

SituationWhat happenedWhich tokensWhat to do
Limit exceededAmount exceeds the per-transaction or daily safety limitAlien and NativeWait for administrator approval
Insufficient liquidityThe contract does not have enough tokens for withdrawalAlien onlyWait for replenishment or cancel withdrawal

In both cases, a deferred request (Pending Withdrawal) is created in the MultiVault contract. Tokens are not lost — they are locked until the situation is resolved.

Why do Native tokens always withdraw without liquidity issues?

Native tokens are minted (created anew) upon withdrawal, not taken from the contract balance. Therefore, insufficient liquidity is impossible for them.

How to determine if withdrawal is delayed

When checking the transfer status (Step 4), the API response contains a notInstantTransfer field. If it is not null — the withdrawal is delayed:

jsonc
{
  "notInstantTransfer": {
    "payloadId": "0x1234...abcd",               // keccak256(payload) — transfer identifier in the API
    "status": "Required",                        // Required — awaiting approval, NotRequired — awaiting liquidity
    "userId": "0x742d...Ab12",                   // User ID (matches evmUserAddress)
    "evmChainId": 1,
    "evmTokenAddress": "0xdAC1...1ec7",
    "evmUserAddress": "0x742d...Ab12",
    "tvmChainId": -239,
    "tvmTokenAddress": "0:1111...1111",
    "tvmUserAddress": "0:2222...2222",
    "contractAddress": "0x7890...7890",
    "volumeExec": "1000.000000",                 // Original withdrawal amount
    "volumeUsdtExec": "1000.000000",             // Original amount in USDT equivalent
    "currentAmount": "1000.000000",              // Current amount (may decrease after partial withdrawal)
    "bounty": "0",                               // Reward for liquidity provider
    "symbol": "USDT",
    "decimals": 6,
    "timestamp": 1704067200,
    "openTransactionHashEvm": "0xabc1...abc1",   // Hash of the EVM saveWithdraw*() transaction
    "closeTransactionHashEvm": null               // Hash of the closing transaction (null until closed)
  }
}

Key fields:

  • status — reason for delay: Required (limits, awaiting approval) or NotRequired (insufficient liquidity)
  • payloadId — payload hash (keccak256), identifier in the API. Do not confuse with the sequential id for contract calls (see below)
  • currentAmount — current amount to withdraw

What the user can do

ActionWhen availableDescription
Set BountyAt any timeSet a reward for whoever helps close the withdrawal (Alien only)
CancelWithdrawal approved or does not require approval (NotRequired / Approved)Cancel and return tokens back to TVM (Alien only)
Force WithdrawWithdrawal approved or does not require approval (NotRequired / Approved)Forcibly withdraw tokens if liquidity has appeared

If status is Required

If the withdrawal is awaiting approval (limits) — the user waits for the administrator's decision.

How to get the Pending Withdrawal ID

For contract calls (Set Bounty, Cancel, Force Withdraw), you need the sequential id of the Pending Withdrawal — this is not the payloadId from the API (keccak256 hash), but a sequential number (0, 1, 2...) for a given user.

The id is extracted from the PendingWithdrawalCreated event in the saveWithdraw*() transaction receipt:

typescript
import { ethers } from 'ethers';

function getPendingWithdrawalId(receipt: ethers.TransactionReceipt): bigint | null {
  // PendingWithdrawalCreated(address recipient, uint256 id, address token, uint256 amount, bytes32 payloadId)
  const eventSignature = ethers.id(
    'PendingWithdrawalCreated(address,uint256,address,uint256,bytes32)'
  );

  const log = receipt.logs.find(l => l.topics[0] === eventSignature);
  if (!log) return null; // Instant withdrawal — Pending Withdrawal was not created

  const abiCoder = ethers.AbiCoder.defaultAbiCoder();
  const decoded = abiCoder.decode(
    ['address', 'uint256', 'address', 'uint256', 'bytes32'],
    log.data
  );

  return decoded[1]; // id — sequential PW number
}

Important

payloadId from the API (keccak256(payload)) is not the same as id in contract calls. The contract uses the sequential PW number for the recipient, while payloadId is a hash for deduplication.

Example: Set Bounty

Sets a reward for a liquidity provider who will close the Pending Withdrawal through a deposit. Available only for Alien tokens. Can only be called by the recipient.

typescript
import { ethers } from 'ethers';

async function setPendingWithdrawalBounty(
  signer: ethers.Signer,
  multiVaultAddress: string,
  pendingWithdrawalId: number,
  bounty: string // Reward amount in the token's smallest units
): Promise<string> {
  const multiVaultAbi = [
    'function setPendingWithdrawalBounty(uint256 id, uint256 bounty)',
  ];

  const multiVault = new ethers.Contract(multiVaultAddress, multiVaultAbi, signer);

  const tx = await multiVault.setPendingWithdrawalBounty(
    pendingWithdrawalId,
    bounty
  );

  const receipt = await tx.wait(1);
  console.log('Bounty set:', receipt?.hash);
  return receipt?.hash || '';
}

Example: Cancel (cancel withdrawal)

Cancels the Pending Withdrawal and returns tokens to TVM. Available only for Alien tokens with status NotRequired or Approved.

typescript
import { ethers } from 'ethers';

async function cancelPendingWithdrawal(
  signer: ethers.Signer,
  multiVaultAddress: string,
  pendingWithdrawalId: number,
  amount: string,           // Amount to cancel (full or partial)
  tvmRecipient: {           // Return address in TVM
    wid: number;            // Workchain ID
    addr: string;           // Address (uint256)
  }
): Promise<string> {
  const multiVaultAbi = [
    'function cancelPendingWithdrawal(uint256 id, uint256 amount, tuple(int8 wid, uint256 addr) recipient, uint expected_gas, bytes payload, uint bounty) payable',
  ];

  const multiVault = new ethers.Contract(multiVaultAddress, multiVaultAbi, signer);

  const tx = await multiVault.cancelPendingWithdrawal(
    pendingWithdrawalId,
    amount,
    tvmRecipient,
    '0',    // expected_gas
    '0x',   // payload (empty)
    '0'     // bounty for new pending (on partial cancel)
  );

  const receipt = await tx.wait(1);
  console.log('Cancellation confirmed:', receipt?.hash);
  return receipt?.hash || '';
}

Example: Force Withdraw

Forcibly withdraws tokens if liquidity has appeared. Available for statuses NotRequired and Approved.

typescript
import { ethers } from 'ethers';

async function forceWithdraw(
  signer: ethers.Signer,
  multiVaultAddress: string,
  pendingWithdrawalIds: { recipient: string; id: number }[]
): Promise<string> {
  const multiVaultAbi = [
    'function forceWithdraw(tuple(address recipient, uint256 id)[] pendingWithdrawalIds)',
  ];

  const multiVault = new ethers.Contract(multiVaultAddress, multiVaultAbi, signer);

  const tx = await multiVault.forceWithdraw(pendingWithdrawalIds);

  const receipt = await tx.wait(1);
  console.log('Force withdrawal confirmed:', receipt?.hash);
  return receipt?.hash || '';
}

Learn more

Search transfers

API Endpoint

POST {BASE_URL}/transfers/search
Content-Type: application/json

Request example

bash
curl -X POST '{BASE_URL}/transfers/search' \
  -H 'Content-Type: application/json' \
  -d '{
    "transferKinds": ["TvmToEvm"],
    "tvmUserAddress": "0:2222...2222",
    "limit": 10,
    "offset": 0,
    "isNeedTotalCount": true
  }'

Filter parameters

ParameterTypeRequiredDescription
transferKindsstring[]["TvmToEvm"] for TVM→EVM transfers
tvmUserAddressstringSender address in TVM
evmUserAddressstringRecipient address in EVM
statusesstring[]Pending, Completed, Failed
fromTvmChainIdnumberChain ID of the source TVM network
toEvmChainIdnumberChain ID of the target EVM network
limitnumberRecord limit
offsetnumberOffset
isNeedTotalCountbooleanWhether to return the total count

Transfer modes: Credit vs Non-Credit

AspectCredit (useCredit=true)Non-Credit (useCredit=false)
Gas in EVMPaid by Gas Credit BackendPaid by the user
AutomationFully automaticRequires manual saveWithdraw*() call
SpeedFaster (automatic withdrawal)Depends on the user
FeeIncluded in feesMeta.amountBridge fee only
RecommendationFor regular usersFor advanced users/automation

Credit Mode (useCredit=true)

  • Credit Backend automatically calls saveWithdraw*() in EVM
  • Gas is paid from your transfer fee
  • The user does not need to intervene after burn/transfer in TVM
  • The transfer completes automatically

Non-Credit Mode (useCredit=false)

  • You are responsible for calling saveWithdraw*() in EVM
  • Requires collecting relay node signatures from the TVM Event contract (Step 5)
  • Requires sending a separate EVM transaction
  • Cheaper, but requires additional steps

Full example: TVM→EVM transfer

typescript
import { ethers } from 'ethers';
import { Address, ProviderRpcClient } from 'everscale-inpage-provider';
import { EverscaleStandaloneClient } from 'everscale-standalone-client';

// Production: https://tetra-history-api.chainconnect.com/v2
// Testnet:    https://history-api-test.chainconnect.com/v2
const API_BASE = '{BASE_URL}';
const TVM_RPC_ENDPOINT = 'https://jrpc-ton.broxus.com'; // TVM network JRPC node

// Configuration
const CONFIG = {
  tvmChainId: -239,
  evmChainId: 1,
  tokenAddress: '0:1111...1111', // TIP-3 token in TVM
  amount: '1000000000', // in nano-units
};

// --- Helper functions and ABIs (defined in Steps 2–6) ---

// ABI:  EVENT_CONFIG_EVENTS_ABI (Step 3), EVENT_ABI, EVENT_CONFIG_ABI (Step 5.1)
// Step 2: sendTvmToEvmTransaction(tvmClient, senderAddress, payloadResponse)
// Step 3: getEventContractAddress(provider, transactionHash, sourceConfiguration)
// Step 5: readEventData(provider, eventContractAddress, configurationAddress)
// Step 5: encodeEventData(eventInitData, eventContractAddress, roundNumber, eventABI, proxy, flags)
// Step 5: processSignatures(signatures, encodedEvent)
// Step 5: saveWithdrawAlien(signer, multiVaultAddress, encodedEvent, signatures)
// Step 5: saveWithdrawNative(signer, multiVaultAddress, encodedEvent, signatures)
// Step 6: getPendingWithdrawalId(receipt)

async function tvmToEvmTransfer(
  tvmClient: ProviderRpcClient,
  tvmSenderAddress: string, // User's TVM wallet address
  params: {
    fromChainId: number;
    toChainId: number;
    tokenAddress: string;
    recipientAddress: string;
    amount: string;
    senderAddress: string;
  }
) {
  // === Step 1: Prepare the transfer ===
  console.log('[Step 1] Building payload...');
  const payloadResponse = await fetch(`${API_BASE}/payload/build`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...params, useCredit: false }),
  }).then(r => r.json());

  console.log('Payload received:', {
    transferKind: payloadResponse.transferKind,
    tokenAmount: payloadResponse.tokenAmount,
    fee: payloadResponse.feesMeta?.amount,
    method: payloadResponse.abiMeta.abiMethod,
  });

  const tokenType = payloadResponse.tokensMeta.sourceTokenType; // 'Alien' | 'Native'
  const multiVaultAddress = payloadResponse.trackingMeta.targetMultivault;

  // === Step 2: Burn/Transfer in TVM ===
  console.log('\n[Step 2] Sending burn/transfer transaction...');
  const txHash = await sendTvmToEvmTransaction(
    tvmClient, tvmSenderAddress, payloadResponse
  );
  console.log('Transaction sent, hash:', txHash);

  // === Step 3: Obtain Event Contract Address ===
  console.log('\n[Step 3] Getting Event contract address...');
  const contractAddress = await getEventContractAddress(
    tvmClient,
    txHash,
    payloadResponse.trackingMeta.sourceConfiguration
  );

  if (!contractAddress) {
    throw new Error('Event contract not found in the transaction trace');
  }

  console.log('Contract Address:', contractAddress);

  // === Step 4: Check status (optional) ===
  console.log('\n[Step 4] Checking transfer status...');
  const statusResult = await fetch(`${API_BASE}/transfers/status`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      tvmEvm: { contractAddress, dappChainId: params.fromChainId },
    }),
  }).then(r => r.json());

  console.log('Status:', statusResult.transfer?.tvmEvm?.transferStatus);

  // === Step 5: Withdraw in EVM ===
  console.log('\n[Step 5] Withdrawing tokens in EVM...');

  // 5.1: Wait for relay node signatures and read data from the Event contract.
  // readEventData will throw an error if there are not enough signatures — retry with an interval.
  let eventData;
  while (true) {
    try {
      eventData = await readEventData(
        tvmClient,
        contractAddress,
        payloadResponse.trackingMeta.sourceConfiguration
      );
      break; // Enough signatures
    } catch (e: any) {
      if (e.message?.includes('Not enough signatures')) {
        console.log('Waiting for relay node signatures...');
        await new Promise(r => setTimeout(r, 15_000)); // 15 seconds
        continue;
      }
      throw e;
    }
  }

  // 5.2: Encode payload
  const encodedEvent = encodeEventData(
    eventData.eventInitData,
    contractAddress,
    eventData.roundNumber,
    eventData.eventABI,
    eventData.proxy,
    eventData.flags
  );

  // 5.3: Process signatures
  const sortedSignatures = processSignatures(eventData.signatures, encodedEvent);

  // 5.4: Call saveWithdraw
  const ethersProvider = new ethers.BrowserProvider(window.ethereum);
  const signer = await ethersProvider.getSigner();

  let withdrawTxHash: string;
  if (tokenType === 'Alien') {
    withdrawTxHash = await saveWithdrawAlien(signer, multiVaultAddress, encodedEvent, sortedSignatures);
  } else {
    withdrawTxHash = await saveWithdrawNative(signer, multiVaultAddress, encodedEvent, sortedSignatures);
  }

  // === Step 6: Check if withdrawal is delayed ===
  // Extract Pending Withdrawal ID from the saveWithdraw*() transaction receipt
  const withdrawReceipt = await ethersProvider.getTransactionReceipt(withdrawTxHash);
  const pendingWithdrawalId = withdrawReceipt ? getPendingWithdrawalId(withdrawReceipt) : null;

  if (pendingWithdrawalId !== null) {
    console.log('\nPending Withdrawal created (Step 6)');
    console.log('Pending Withdrawal ID:', pendingWithdrawalId.toString());

    // Check status via API
    const finalStatus = await fetch(`${API_BASE}/transfers/status`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        tvmEvm: { contractAddress, dappChainId: params.fromChainId },
      }),
    }).then(r => r.json());

    console.log('Status:', finalStatus.notInstantTransfer?.status);
    console.log('Amount:', finalStatus.notInstantTransfer?.currentAmount);
    return finalStatus;
  }

  console.log('\nTransfer completed!');
  return null;
}

// --- Usage ---
const tvmClient = new ProviderRpcClient({
  fallback: () => EverscaleStandaloneClient.create({
    connection: { type: 'jrpc', data: { endpoint: TVM_RPC_ENDPOINT } },
  }),
});
await tvmClient.ensureInitialized();

const tvmSenderAddress = '0:2222...2222'; // TVM address of the connected wallet

await tvmToEvmTransfer(tvmClient, tvmSenderAddress, {
  fromChainId: CONFIG.tvmChainId,
  toChainId: CONFIG.evmChainId,
  tokenAddress: CONFIG.tokenAddress,
  recipientAddress: '0x742d...Ab12',
  amount: CONFIG.amount,
  senderAddress: tvmSenderAddress,
});

Error reference

TVM errors during Burn/Transfer

ErrorCauseSolution
Invalid messagePayload is incorrectRequest a new payload via the API
Low balanceInsufficient gas for the transactionIncrease attachedValue in the request
UnauthorizedAttempt to burn/transfer someone else's tokenUse your own TokenWallet
Not enough balanceInsufficient tokens for burn/transferCheck balance before sending

EVM errors during saveWithdraw*()

ErrorCauseSolution
Withdraw: wrong chain idPayload is intended for a different EVM networkCheck toChainId in the API request
Withdraw: token is blacklistedToken has been added to the blacklistContact the team, token cannot be withdrawn
Withdraw: bounty > withdraw amountBounty exceeds the withdrawal amountReduce bounty or do not use it
Withdraw: already seenThis withdraw has already been executed (replay protection)This is normal, withdrawal is already completed
Withdraw: invalid configurationEvent configuration is incorrectSystem error, contact the team
(no message)verifySignedTvmEvent() returned an errorRelay node signatures are invalid, try again later

Pending Withdrawal errors

ErrorCauseSolution
Pending: amount is zeroAttempted operation with a zero pendingUse a different pending
Pending: wrong amountPayout amount is incorrectCheck the pending amount
Pending: native tokenAttempted to set bounty for a native tokenBounty only works for Alien tokens
Pending: bounty too largeBounty exceeds the pending amountReduce the bounty

ChainConnect Bridge Documentation