TVM→EVM Transfer
Overview
A TVM→EVM transfer allows you to send tokens from a TVM network to an EVM network. The process includes:
- Build Payload — obtain transaction data via the API
- Burn/Transfer in TVM — send a transaction in TVM (burn Alien or transfer Native to Proxy)
- Obtain Event Contract Address — find the address of the deployed Event contract
- Wait for confirmations — relay nodes sign the event, the Event contract accumulates signatures
- Withdraw in EVM (non-credit) — read signatures from the Event contract and call
saveWithdraw*()in MultiVault - 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
signaturesmapping - Signatures are passed to the EVM contract
MultiVault.saveWithdraw*() - The EVM contract verifies signatures using relay node public keys
- Consensus:
confirms >= requiredVotes, whererequiredVotes = keys.length * 2/3 + 1
Signatures are required because the EVM contract cannot read TVM state and must receive consensus proof.
Token types
| Type | TVM operation | EVM operation | Description |
|---|---|---|---|
| Alien | burn on TokenWallet | unlock in MultiVault | Tokens are burned in TVM, unlocked in EVM |
| Native | lock on Proxy | mint in MultiVault | Tokens are locked in TVM, minted in EVM |
API Base URL
All API requests in this guide use one of two environments:
| Environment | Base URL |
|---|---|
| Production | https://tetra-history-api.chainconnect.com/v2 |
| Testnet | https://history-api-test.chainconnect.com/v2 |
Endpoints used in the guide:
| Endpoint | Method | Step |
|---|---|---|
/payload/build | POST | Step 1 — build payload |
/transfers/status | POST | Step 4 — check status |
/transfers/search | POST | Search 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/jsonRequest parameters
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)
};
}| Parameter | Type | Required | Description |
|---|---|---|---|
fromChainId | number | ✅ | TVM chain ID of the source network |
toChainId | number | ✅ | EVM chain ID of the target network |
tokenAddress | string | ✅ | TIP-3 token address in TVM (format 0:...) |
recipientAddress | string | ✅ | Recipient address in EVM (format 0x... lowercase) |
amount | string | ✅ | Amount in smallest units (integer as string, e.g. "1000000" for 1 USDT with 6 decimals) |
senderAddress | string | ✅ | Sender address in TVM (format 0:...) |
useCredit | boolean | ❌ | If true — Gas Credit Backend automatically calls saveWithdraw in EVM. Default: false |
remainingGasTo | string | ❌ | Address for returning remaining gas (used when useCredit=false) |
callback | object | ❌ | Callback parameters (EVM contract address to call upon completion) |
tokenBalance.nativeTokenAmount | string | ⚠️ | Gas token balance (native currency). Required when sending gas token |
tokenBalance.wrappedNativeTokenAmount | string | ⚠️ | Wrapped gas token balance. Required when sending gas token |
Request example
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
{
"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
| Field | Description | Usage |
|---|---|---|
transferKind | Transfer type (TvmToEvm) | Direction confirmation |
tokensMeta.sourceTokenType | Token type at source (Alien or Native) | Determines method: burn vs transfer |
tokenAmount | Final amount after fee deduction | Amount the user will receive |
feesMeta.amount | Bridge fee amount | Fee information |
feesMeta.numerator / denominator | Fee fraction | Fee percentage calculation |
abiMeta.executionAddress | TokenWallet address for the transaction | TVM transaction recipient (Step 2) |
abiMeta.tx | Full call BOC (base64) | Ready body for TonConnect (Step 2) |
abiMeta.abi | Contract ABI (JSON string) | For calling via TVM SDK (Step 2) |
abiMeta.abiMethod | Contract method (burn or transfer) | Method name for the call (Step 2) |
abiMeta.params | Method parameters (JSON string) | Parameters for calling via TVM SDK (Step 2) |
abiMeta.attachedValue | Amount of nanoTON to send | TVM transaction gas (Step 2) |
trackingMeta.sourceConfiguration | EventConfiguration address in TVM | For obtaining Event Contract Address (Step 3) |
trackingMeta.sourceProxy | Proxy contract address in TVM | Informational |
trackingMeta.targetMultivault | MultiVault address in EVM | For 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
| Parameter | Description | Usage |
|---|---|---|
abiMeta.tx | Full burn/transfer call BOC | For TonConnect — ready message body |
abiMeta.abi | Contract ABI (JSON) | For TVM SDK — creating Contract |
abiMeta.abiMethod | "burn" or "transfer" | For TVM SDK — method name |
abiMeta.params | Method parameters (JSON) | For TVM SDK — call arguments |
abiMeta.executionAddress | TokenWallet address | Transaction recipient |
abiMeta.attachedValue | Gas in nanoTON | Transaction amount |
Difference between operations
| Operation | Token type | Description |
|---|---|---|
| burn | Alien | Token is burned on TokenWallet. TokenRoot calls callback in ProxyMultiVaultAlien |
| transfer | Native | Token is transferred to Proxy wallet. TokenWallet calls callback in ProxyMultiVaultNative |
Sending the transaction
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;
}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
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);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:
- TokenWallet calls a callback in TokenRoot or ProxyMultiVaultAlien/ProxyMultiVaultNative
- Proxy deploys an Event contract via TvmEvmEventConfiguration
- The Event contract is created with transfer data
- EventConfiguration emits a
NewEventContractevent with the Event contract address
Obtaining Event Contract Address
// 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);
}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/jsonRequest parameters
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)
};
}| Parameter | Description |
|---|---|
contractAddress | Event contract address (format 0:...) |
dappChainId | TVM chain ID of the source network |
timestampCreatedFrom | Minimum timestamp for filter (optional) |
Request example
curl -X POST '{BASE_URL}/transfers/status' \
-H 'Content-Type: application/json' \
-d '{
"tvmEvm": {
"contractAddress": "0:abcd...abcd",
"dappChainId": -239
}
}'Response example (Pending)
{
"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)
{
"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
| Status | Description |
|---|---|
Pending | Transfer in progress. Event contract created, relay nodes are collecting signatures. |
Completed | Transfer fully completed. Tokens withdrawn in EVM (withdrawalId and transactionHash are filled). |
Failed | Transfer failed. Event contract rejected the event (invalid parameters). |
When to proceed to Step 5 (non-credit)
- For credit mode, wait for
Completedstatus — 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. ThereadEventData()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?
useCredit | Action |
|---|---|
true | Credit Backend automatically calls saveWithdraw*(). Skip Step 5. |
false | You must manually collect signatures and call saveWithdraw*(). Execute Step 5. |
Difference between withdrawal methods
| Method | Token type | Description |
|---|---|---|
| saveWithdrawAlien | Alien | Unlocks Alien tokens in MultiVault, transfers to user |
| saveWithdrawNative | Native | Mints 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
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:
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):
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:
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 || '';
}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:
| Situation | What happened | Which tokens | What to do |
|---|---|---|---|
| Limit exceeded | Amount exceeds the per-transaction or daily safety limit | Alien and Native | Wait for administrator approval |
| Insufficient liquidity | The contract does not have enough tokens for withdrawal | Alien only | Wait 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:
{
"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) orNotRequired(insufficient liquidity)payloadId— payload hash (keccak256), identifier in the API. Do not confuse with the sequentialidfor contract calls (see below)currentAmount— current amount to withdraw
What the user can do
| Action | When available | Description |
|---|---|---|
| Set Bounty | At any time | Set a reward for whoever helps close the withdrawal (Alien only) |
| Cancel | Withdrawal approved or does not require approval (NotRequired / Approved) | Cancel and return tokens back to TVM (Alien only) |
| Force Withdraw | Withdrawal 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:
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.
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.
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.
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
- Limits — how limits work
- Liquidity Requests — managing LRs
Search transfers
API Endpoint
POST {BASE_URL}/transfers/search
Content-Type: application/jsonRequest example
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
| Parameter | Type | Required | Description |
|---|---|---|---|
transferKinds | string[] | ❌ | ["TvmToEvm"] for TVM→EVM transfers |
tvmUserAddress | string | ❌ | Sender address in TVM |
evmUserAddress | string | ❌ | Recipient address in EVM |
statuses | string[] | ❌ | Pending, Completed, Failed |
fromTvmChainId | number | ❌ | Chain ID of the source TVM network |
toEvmChainId | number | ❌ | Chain ID of the target EVM network |
limit | number | ✅ | Record limit |
offset | number | ✅ | Offset |
isNeedTotalCount | boolean | ✅ | Whether to return the total count |
Transfer modes: Credit vs Non-Credit
| Aspect | Credit (useCredit=true) | Non-Credit (useCredit=false) |
|---|---|---|
| Gas in EVM | Paid by Gas Credit Backend | Paid by the user |
| Automation | Fully automatic | Requires manual saveWithdraw*() call |
| Speed | Faster (automatic withdrawal) | Depends on the user |
| Fee | Included in feesMeta.amount | Bridge fee only |
| Recommendation | For regular users | For 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
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
| Error | Cause | Solution |
|---|---|---|
Invalid message | Payload is incorrect | Request a new payload via the API |
Low balance | Insufficient gas for the transaction | Increase attachedValue in the request |
Unauthorized | Attempt to burn/transfer someone else's token | Use your own TokenWallet |
Not enough balance | Insufficient tokens for burn/transfer | Check balance before sending |
EVM errors during saveWithdraw*()
| Error | Cause | Solution |
|---|---|---|
Withdraw: wrong chain id | Payload is intended for a different EVM network | Check toChainId in the API request |
Withdraw: token is blacklisted | Token has been added to the blacklist | Contact the team, token cannot be withdrawn |
Withdraw: bounty > withdraw amount | Bounty exceeds the withdrawal amount | Reduce bounty or do not use it |
Withdraw: already seen | This withdraw has already been executed (replay protection) | This is normal, withdrawal is already completed |
Withdraw: invalid configuration | Event configuration is incorrect | System error, contact the team |
| (no message) | verifySignedTvmEvent() returned an error | Relay node signatures are invalid, try again later |
Pending Withdrawal errors
| Error | Cause | Solution |
|---|---|---|
Pending: amount is zero | Attempted operation with a zero pending | Use a different pending |
Pending: wrong amount | Payout amount is incorrect | Check the pending amount |
Pending: native token | Attempted to set bounty for a native token | Bounty only works for Alien tokens |
Pending: bounty too large | Bounty exceeds the pending amount | Reduce the bounty |