Трансфер TVM→EVM
Обзор
Трансфер TVM→EVM позволяет отправить токены из TVM сети в EVM сеть. Процесс включает:
- Build Payload — получить данные для транзакции через API
- Burn/Transfer в TVM — отправить транзакцию в TVM (сжечь Alien или перевести Native на Proxy)
- Получение Event Contract Address — найти адрес задеплоенного Event-контракта
- Ожидание подтверждений — relay-ноды подписывают событие, Event-контракт накапливает подписи
- Вывод в EVM (non-credit) — прочитать подписи из Event-контракта и вызвать
saveWithdraw*()в MultiVault - Если вывод отложен — при превышении лимитов или недостатке ликвидности создаётся Pending Withdrawal
Механизм консенсуса (TVM→EVM)
Для TVM→EVM консенсус достигается через подписи relay-нод:
- Relay-ноды вызывают
confirm(signature, voteReceiver)с криптографической подписью - Event-контракт хранит подписи в маппинге
signatures - Подписи передаются в EVM-контракт
MultiVault.saveWithdraw*() - EVM-контракт верифицирует подписи через публичные ключи relay-нод
- Консенсус:
confirms >= requiredVotes, гдеrequiredVotes = keys.length * 2/3 + 1
Подписи необходимы, потому что EVM-контракт не может читать состояние TVM и должен получать доказательство консенсуса.
Типы токенов
| Тип | Операция TVM | Операция EVM | Описание |
|---|---|---|---|
| Alien | burn на TokenWallet | unlock в MultiVault | Токены сжигаются в TVM, в EVM разблокируются |
| Native | lock на Proxy | mint в MultiVault | Токены блокируются в TVM, в EVM выпускаются |
API Base URL
Все API-запросы в этом гайде используют один из двух окружений:
| Окружение | Base URL |
|---|---|
| Production | https://tetra-history-api.chainconnect.com/v2 |
| Testnet | https://history-api-test.chainconnect.com/v2 |
Эндпоинты, используемые в гайде:
| Эндпоинт | Метод | Шаг |
|---|---|---|
/payload/build | POST | Шаг 1 — сборка payload |
/transfers/status | POST | Шаг 4 — проверка статуса |
/transfers/search | POST | Поиск трансферов |
Шаг 1: Подготовка трансфера (Build Payload)
Цель: Получить payload для отправки транзакции в TVM
API Endpoint
POST {BASE_URL}/payload/build
Content-Type: application/jsonПараметры запроса
interface TransferPayloadRequest {
fromChainId: number; // TVM chain ID сети отправления
toChainId: number; // EVM chain ID сети назначения
tokenAddress: string; // Адрес токена в TVM (формат 0:...)
recipientAddress: string; // Адрес получателя в EVM (hex формат 0x...)
amount: string; // Сумма в nano-единицах (integer string)
senderAddress: string; // Адрес отправителя в TVM (формат 0:...)
useCredit?: boolean; // true = Credit Backend оплачивает газ. Default: false
remainingGasTo?: string; // Куда вернуть остаток газа (для non-credit)
payload?: string; // Дополнительный payload
callback?: CallbackRequest; // Callback при завершении
evmChainId?: number; // Явное указание EVM chain ID
tokenBalance?: { // Для native currency (wrapped native token)
nativeTokenAmount: string; // Баланс native currency (для wrapped native token трансферов)
wrappedNativeTokenAmount: string; // Баланс wrapped native (для wrapped native token трансферов)
};
}| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
fromChainId | number | ✅ | TVM chain ID сети отправления |
toChainId | number | ✅ | EVM chain ID сети назначения |
tokenAddress | string | ✅ | Адрес TIP-3 токена в TVM (формат 0:...) |
recipientAddress | string | ✅ | Адрес получателя в EVM (формат 0x... lowercase) |
amount | string | ✅ | Сумма в минимальных единицах (целое число как строка, напр. "1000000" для 1 USDT с 6 decimals) |
senderAddress | string | ✅ | Адрес отправителя в TVM (формат 0:...) |
useCredit | boolean | ❌ | Если true — Gas Credit Backend автоматически вызывает saveWithdraw в EVM. Default: false |
remainingGasTo | string | ❌ | Адрес для возврата остатка газа (используется если useCredit=false) |
callback | object | ❌ | Параметры callback (адрес EVM контракта для вызова после завершения) |
tokenBalance.nativeTokenAmount | string | ⚠️ | Баланс газового токена (native currency). Обязателен при отправке газового токена |
tokenBalance.wrappedNativeTokenAmount | string | ⚠️ | Баланс wrapped версии газового токена. Обязателен при отправке газового токена |
Пример запроса
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"
}'Пример ответа
{
"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
}Поля ответа
| Поле | Описание | Использование |
|---|---|---|
transferKind | Тип трансфера (TvmToEvm) | Подтверждение направления |
tokensMeta.sourceTokenType | Тип токена в источнике (Alien или Native) | Определяет метод: burn vs transfer |
tokenAmount | Итоговая сумма после вычета комиссии | Сумма, которую получит пользователь |
feesMeta.amount | Размер комиссии моста | Информация о комиссии |
feesMeta.numerator / denominator | Дробь комиссии | Расчёт процента комиссии |
abiMeta.executionAddress | Адрес TokenWallet для транзакции | Получатель TVM транзакции (Шаг 2) |
abiMeta.tx | Полный BOC вызова (base64) | Готовый body для TonConnect (Шаг 2) |
abiMeta.abi | ABI контракта (JSON string) | Для вызова через TVM SDK (Шаг 2) |
abiMeta.abiMethod | Метод контракта (burn или transfer) | Имя метода для вызова (Шаг 2) |
abiMeta.params | Параметры метода (JSON string) | Параметры для вызова через TVM SDK (Шаг 2) |
abiMeta.attachedValue | Количество nanoTON для отправки | Газ TVM транзакции (Шаг 2) |
trackingMeta.sourceConfiguration | Адрес EventConfiguration в TVM | Для получения Event Contract Address (Шаг 3) |
trackingMeta.sourceProxy | Адрес Proxy контракта в TVM | Информационное |
trackingMeta.targetMultivault | Адрес MultiVault в EVM | Для вызова saveWithdraw*() (Шаг 5) |
Шаг 2: Отправка транзакции в TVM
Цель: Выполнить burn (Alien) или transfer (Native) в TVM сети и получить Transaction Hash
Что потребуется из Шага 1
| Параметр | Описание | Использование |
|---|---|---|
abiMeta.tx | Полный BOC вызова burn/transfer | Для TonConnect — готовый body сообщения |
abiMeta.abi | ABI контракта (JSON) | Для TVM SDK — создание Contract |
abiMeta.abiMethod | "burn" или "transfer" | Для TVM SDK — имя метода |
abiMeta.params | Параметры метода (JSON) | Для TVM SDK — аргументы вызова |
abiMeta.executionAddress | Адрес TokenWallet | Получатель транзакции |
abiMeta.attachedValue | Газ в nanoTON | Amount транзакции |
Различие между операциями
| Операция | Тип токена | Описание |
|---|---|---|
| burn | Alien | Токен сжигается на TokenWallet. TokenRoot вызывает callback в ProxyMultiVaultAlien |
| transfer | Native | Токен переводится на wallet Proxy. TokenWallet вызывает callback в ProxyMultiVaultNative |
Отправка транзакции
import TonConnectUI from '@tonconnect/ui';
import { Cell } from '@ton/core';
interface TransferPayloadResponse {
abiMeta: {
tx: string; // BOC base64
executionAddress: string; // TokenWallet адрес
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(`Отправляем ${abiMeta.abiMethod} транзакцию...`);
console.log('Адрес контракта:', abiMeta.executionAddress);
console.log('Газ:', abiMeta.attachedValue, 'nanoTON');
// Формируем транзакцию
// abiMeta.tx — готовый BOC с закодированным вызовом burn/transfer,
// включая сумму токенов, адрес получателя и bridge payload.
// abiMeta.attachedValue — газ (nanoTON), не сумма токенов.
const transaction = {
validUntil: Math.floor(Date.now() / 1000) + 600, // 10 минут
messages: [
{
address: abiMeta.executionAddress, // Адрес TokenWallet
amount: abiMeta.attachedValue, // Газ (nanoTON)
payload: abiMeta.tx, // BOC: burn/transfer + сумма токенов внутри
},
],
};
// Отправляем через TonConnect
const result = await tonConnectUI.sendTransaction(transaction);
// TonConnect возвращает BOC (сериализованное сообщение), а не transaction hash.
// Извлекаем message hash из BOC — он используется в TonAPI для поиска trace.
const messageHash = Cell.fromBase64(result.boc).hash().toString('hex');
console.log('Транзакция отправлена, message hash:', messageHash);
return messageHash;
}import { Address, ProviderRpcClient } from 'everscale-inpage-provider';
interface TransferPayloadResponse {
abiMeta: {
tx: string; // BOC base64 (полный вызов burn/transfer — для TonConnect)
abi: string; // ABI контракта (JSON string)
abiMethod: 'burn' | 'transfer';
params: string; // Параметры метода (JSON string)
executionAddress: string; // TokenWallet адрес
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(`Отправляем ${abiMeta.abiMethod} транзакцию...`);
console.log('Адрес контракта:', abiMeta.executionAddress);
console.log('Газ:', abiMeta.attachedValue, 'nanoTON');
// API возвращает ABI контракта, имя метода и параметры —
// используем их напрямую для вызова через 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('Транзакция отправлена, hash:', transaction.id.hash);
return transaction.id.hash;
}Пример использования
import TonConnectUI from '@tonconnect/ui';
// TonConnect manifest — JSON-файл с описанием вашего dApp (название, иконка, URL).
// Хостится на вашем домене. Формат: { "url": "...", "name": "...", "iconUrl": "..." }
const tonConnectUI = new TonConnectUI({
manifestUrl: 'https://your-app.com/tonconnect-manifest.json',
});
// payloadResponse получен на Шаге 1
const messageHash = await sendTvmToEvmTransaction(tonConnectUI, payloadResponse);
console.log('Message hash получен:', 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 получен на Шаге 1
const txHash = await sendTvmToEvmTransaction(
tvmClient,
'0:2222...2222', // TVM адрес кошелька пользователя
payloadResponse
);
console.log('Transaction hash получен:', txHash);После успешной транзакции
Полученный хеш транзакции (Transaction Hash или Message Hash) используется на следующем шаге для поиска Event-контракта через trace дерево транзакций (Шаг 3).
После подтверждения транзакции в TVM автоматически создаётся Event-контракт. Его адрес (Event Contract Address) используется как идентификатор трансфера для отслеживания статуса, получения подписей и диагностики.
Шаг 3: Получение Event Contract Address
Цель: Найти адрес Event-контракта для отслеживания трансфера
Важно
В отличие от EVM→TVM и TVM→TVM (где используется transaction hash), для TVM→EVM нужен именно Event Contract Address созданного Event-контракта.
Как создаётся Event-контракт
При выполнении burn (Alien) или transfer (Native) транзакции:
- TokenWallet вызывает callback в TokenRoot или ProxyMultiVaultAlien/ProxyMultiVaultNative
- Proxy деплоит Event-контракт через TvmEvmEventConfiguration
- Event-контракт создаётся с данными трансфера
- EventConfiguration эмитит событие
NewEventContractс адресом Event-контракта
Получение Event Contract Address
// При деплое Event-контракта, EventConfiguration создаёт дочернюю транзакцию
// на новом контракте. Ищем в trace транзакцию на sourceConfiguration
// и берём адрес её дочерней транзакции.
async function getEventAddressViaTonApi(
walletTxHash: string,
sourceConfiguration: string // trackingMeta.sourceConfiguration из Шага 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) {
// Нашли транзакцию на EventConfiguration.
// Event-контракт — дочерняя транзакция (deploy нового контракта).
for (const child of node.children || []) {
const childAccount = child.transaction?.account?.address;
if (childAccount) {
return childAccount; // Адрес задеплоенного Event-контракта
}
}
}
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';
// Минимальный ABI TvmEvmEventConfiguration — только событие NewEventContract
// Источник: ../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 из Шага 1
): Promise<string | null> {
const tx = await provider.getTransaction({ hash: transactionHash });
if (!tx) {
throw new Error('Транзакция не найдена');
}
const configContract = new provider.Contract(
EVENT_CONFIG_EVENTS_ABI,
new Address(sourceConfiguration)
);
const subscriber = new provider.Subscriber();
try {
// Ищем в trace транзакцию на EventConfiguration
const eventAddress = await subscriber
.trace(tx)
.filterMap(async (traceTx) => {
if (traceTx.account.toString() !== sourceConfiguration) {
return undefined;
}
// decodeTransactionEvents парсит out_messages транзакции по 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();
}
}Шаг 4: Отслеживание статуса трансфера
Цель: Отслеживать прогресс трансфера и проверять его текущий статус
API Endpoint
POST {BASE_URL}/transfers/status
Content-Type: application/jsonПараметры запроса
interface StatusRequest {
tvmEvm: {
contractAddress: string; // Адрес Event-контракта из Шага 3
dappChainId: number; // TVM chain ID сети отправления
timestampCreatedFrom?: number; // Фильтр по времени (опционально)
};
}| Параметр | Описание |
|---|---|
contractAddress | Адрес Event-контракта (формат 0:...) |
dappChainId | TVM chain ID сети отправления |
timestampCreatedFrom | Минимальный timestamp для фильтра (опционально) |
Пример запроса
curl -X POST '{BASE_URL}/transfers/status' \
-H 'Content-Type: application/json' \
-d '{
"tvmEvm": {
"contractAddress": "0:abcd...abcd",
"dappChainId": -239
}
}'Пример ответа (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
}Пример ответа (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
Для TVM→EVM трансферов proofPayload всегда null. Подписи relay-нод хранятся в Event-контракте на стороне TVM и читаются оттуда напрямую (см. Шаг 5).
Статусы трансфера
| Статус | Описание |
|---|---|
Pending | Трансфер в процессе. Event-контракт создан, relay-ноды собирают подписи. |
Completed | Трансфер полностью завершён. Токены выведены в EVM (withdrawalId и transactionHash заполнены). |
Failed | Трансфер не удался. Event-контракт отклонил событие (неверные параметры). |
Когда переходить к Шагу 5 (non-credit)
- Для credit-режима ожидайте статус
Completed— Gas Credit Backend завершит трансфер автоматически. - Для non-credit не нужно ждать
Completed— переходите к Шагу 5, как только Event-контракт наберёт достаточно подписей relay-нод. ФункцияreadEventData()из Шага 5.1 проверит количество подписей и выбросит ошибку, если их недостаточно.
Шаг 5: Завершение трансфера (Non-Credit режим)
Цель: Собрать подписи relay-нод из TVM Event-контракта, закодировать данные и вызвать saveWithdraw*() в MultiVault для вывода токенов в EVM.
Когда требуется этот шаг?
useCredit | Действие |
|---|---|
true | Credit Backend автоматически вызывает saveWithdraw*(). Пропустите Шаг 5. |
false | Вы должны вручную собрать подписи и вызвать saveWithdraw*(). Выполните Шаг 5. |
Различие между методами вывода
| Метод | Тип токена | Описание |
|---|---|---|
| saveWithdrawAlien | Alien | Разлокирует Alien токены в MultiVault, передаёт пользователю |
| saveWithdrawNative | Native | Минтит новые ERC-20 токены через create2, передаёт пользователю |
Требуется TVM SDK
Шаг 5 требует чтения состояния TVM-контрактов (вызов get-методов getDetails, round_number, getFlags). Для этого необходим everscale-inpage-provider — через TonAPI или другие HTTP API это сделать нельзя.
5.1: Чтение данных из Event-контракта (TVM)
После того как relay-ноды подтвердили событие, необходимо прочитать из TVM:
- Event-контракт — данные события и подписи relay-нод
- EventConfiguration — ABI события, флаги и адрес proxy
- Round number — номер раунда relay-нод
import { Address, ProviderRpcClient } from 'everscale-inpage-provider';
// Минимальный ABI Event-контракта (getDetails + round_number)
// Источник: ../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;
// Минимальный ABI TvmEvmEventConfiguration (getDetails + getFlags)
// Источник: ../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));
// Читаем данные параллельно
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(),
]);
// Проверяем, что набрано достаточно подписей relay-нод
const requiredVotes = Number(eventDetails._requiredVotes);
const signaturesCount = eventDetails._signatures.filter(s => s !== '').length;
if (signaturesCount < requiredVotes) {
throw new Error(
`Недостаточно подписей: ${signaturesCount}/${requiredVotes}. ` +
`Event-контракт ещё не подтверждён — повторите позже.`
);
}
return {
eventInitData: eventDetails._eventInitData,
signatures: eventDetails._signatures.filter(s => s !== ''), // только непустые подписи
roundNumber: roundNumberResult.round_number,
eventABI: configDetails._basicConfiguration.eventABI, // base64-encoded ABI события
proxy: configDetails._networkConfiguration.proxy,
flags: flagsResult._flags,
};
}5.2: Кодирование payload
Данные события необходимо закодировать в формат, понятный EVM-контракту MultiVault:
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. Конвертировать eventData из TVM Cell в EVM bytes
const eventABIDecoded = Buffer.from(eventABI, 'base64').toString();
const eventDataEncoded = mapTonCellIntoEthBytes(
eventABIDecoded,
eventInitData.voteData.eventData,
flags,
);
// 2. Подготовить адреса (формат "wid:address")
const [configWid, configAddr] = eventInitData.configuration.toString().split(':');
const [eventWid, eventAddr] = eventContractAddress.split(':');
// 3. Конвертировать proxy uint160 → EVM address
const proxyAddress = `0x${BigInt(proxy).toString(16).padStart(40, '0')}`;
// 4. Закодировать event struct для 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: Обработка подписей
Подписи relay-нод хранятся в Event-контракте в формате base64. Для EVM-контракта их нужно конвертировать в hex и отсортировать по адресу подписанта (по возрастанию):
import { ethers } from 'ethers';
function processSignatures(
signatures: string[], // base64-encoded подписи из Event-контракта
encodedEvent: string // закодированный payload из Шага 5.2
): string[] {
// 1. Хешировать payload
const messageHash = ethers.keccak256(encodedEvent);
// 2. Конвертировать подписи и восстановить адреса подписантов
const signaturesWithAddresses = signatures.map(sign => {
const signature = `0x${Buffer.from(sign, 'base64').toString('hex')}`;
// recoverAddress делает ecrecover по raw digest (без EIP-191 префикса)
const address = ethers.recoverAddress(messageHash, signature);
return {
signature,
address,
order: BigInt(address), // для числовой сортировки
};
});
// 3. Отсортировать по адресу подписанта (по возрастанию)
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);
}Важно
Порядок подписей критичен — EVM-контракт проверяет что подписи отсортированы по адресу подписанта. Неправильный порядок приведёт к ошибке верификации.
5.4: Вызов saveWithdraw
Финальный шаг — вызов контракта MultiVault в 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('Вызываем saveWithdrawAlien...');
console.log('Количество подписей:', signatures.length);
const tx = await multiVault.saveWithdrawAlien(encodedEvent, signatures);
console.log('Транзакция отправлена:', tx.hash);
const receipt = await tx.wait(1);
if (receipt?.status === 0) {
throw new Error('saveWithdrawAlien не удался');
}
console.log('saveWithdrawAlien подтверждён');
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('Вызываем saveWithdrawNative...');
console.log('Количество подписей:', signatures.length);
const tx = await multiVault.saveWithdrawNative(encodedEvent, signatures);
console.log('Транзакция отправлена:', tx.hash);
const receipt = await tx.wait(1);
if (receipt?.status === 0) {
throw new Error('saveWithdrawNative не удался');
}
console.log('saveWithdrawNative подтверждён');
return receipt?.hash || '';
}Шаг 6: Если вывод отложен
Иногда после saveWithdraw*() токены не приходят сразу. Это происходит в двух случаях:
| Ситуация | Что произошло | Какие токены | Что делать |
|---|---|---|---|
| Превышен лимит | Сумма больше разового или дневного лимита безопасности | Alien и Native | Ждать одобрения администратора |
| Не хватает ликвидности | На контракте недостаточно токенов для вывода | Только Alien | Ждать пополнения или отменить вывод |
В обоих случаях в контракте MultiVault создаётся отложенный запрос (Pending Withdrawal) на вывод. Токены не теряются — они заблокированы до разрешения ситуации.
Почему Native токены всегда выводятся без проблем с ликвидностью?
Native токены при выводе минтятся (создаются заново), а не берутся из баланса контракта. Поэтому недостаток ликвидности для них невозможен.
Как определить, что вывод отложен
При проверке статуса трансфера (Шаг 4) в ответе API появляется поле notInstantTransfer. Если оно не null — вывод отложен:
{
"notInstantTransfer": {
"payloadId": "0x1234...abcd", // keccak256(payload) — идентификатор трансфера в API
"status": "Required", // Required — ждёт одобрения, NotRequired — ждёт ликвидность
"userId": "0x742d...Ab12", // ID пользователя (совпадает с 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", // Исходная сумма вывода
"volumeUsdtExec": "1000.000000", // Исходная сумма в USDT эквиваленте
"currentAmount": "1000.000000", // Текущая сумма (может уменьшиться после частичного вывода)
"bounty": "0", // Награда для провайдера ликвидности
"symbol": "USDT",
"decimals": 6,
"timestamp": 1704067200,
"openTransactionHashEvm": "0xabc1...abc1", // Hash EVM транзакции saveWithdraw*()
"closeTransactionHashEvm": null // Hash закрывающей транзакции (null, пока не закрыт)
}
}Ключевые поля:
status— причина задержки:Required(лимиты, ждёт одобрения) илиNotRequired(недостаток ликвидности)payloadId— хеш payload (keccak256), идентификатор в API. Не путать с порядковымidдля контрактных вызовов (см. ниже)currentAmount— текущая сумма к выводу
Что может сделать пользователь
| Действие | Когда доступно | Описание |
|---|---|---|
| Set Bounty | В любой момент | Назначить награду тому, кто поможет закрыть вывод (только Alien) |
| Cancel | Вывод одобрен или не требует одобрения (NotRequired / Approved) | Отменить и вернуть токены обратно в TVM (только Alien) |
| Force Withdraw | Вывод одобрен или не требует одобрения (NotRequired / Approved) | Принудительно забрать токены, если ликвидность появилась |
Если статус Required
Если вывод ожидает одобрения (лимиты) — пользователь ждёт решения администратора.
Как получить Pending Withdrawal ID
Для контрактных вызовов (Set Bounty, Cancel, Force Withdraw) нужен порядковый id Pending Withdrawal — это не payloadId из API (keccak256 хеш), а последовательный номер (0, 1, 2...) у конкретного пользователя.
id извлекается из события PendingWithdrawalCreated в receipt транзакции saveWithdraw*():
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; // Мгновенный вывод — Pending Withdrawal не создан
const abiCoder = ethers.AbiCoder.defaultAbiCoder();
const decoded = abiCoder.decode(
['address', 'uint256', 'address', 'uint256', 'bytes32'],
log.data
);
return decoded[1]; // id — порядковый номер PW
}Важно
payloadId из API (keccak256(payload)) — это не то же самое, что id в контрактных вызовах. Контракт использует порядковый номер PW у получателя, а payloadId — хеш для дедупликации.
Пример: Set Bounty (установка награды)
Устанавливает награду для провайдера ликвидности, который закроет Pending Withdrawal через депозит. Доступно только для Alien токенов. Может вызвать только получатель (recipient).
import { ethers } from 'ethers';
async function setPendingWithdrawalBounty(
signer: ethers.Signer,
multiVaultAddress: string,
pendingWithdrawalId: number,
bounty: string // Размер награды в минимальных единицах токена
): 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 установлен:', receipt?.hash);
return receipt?.hash || '';
}Пример: Cancel (отмена вывода)
Отменяет Pending Withdrawal и возвращает токены в TVM. Доступно только для Alien токенов со статусом NotRequired или Approved.
import { ethers } from 'ethers';
async function cancelPendingWithdrawal(
signer: ethers.Signer,
multiVaultAddress: string,
pendingWithdrawalId: number,
amount: string, // Сумма для отмены (полная или частичная)
tvmRecipient: { // Адрес возврата в TVM
wid: number; // Workchain ID
addr: string; // Адрес (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 (пустой)
'0' // bounty для нового pending (при частичной отмене)
);
const receipt = await tx.wait(1);
console.log('Отмена подтверждена:', receipt?.hash);
return receipt?.hash || '';
}Пример: Force Withdraw (принудительный вывод)
Принудительно выводит токены, если ликвидность появилась. Доступно для статусов NotRequired и 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('Принудительный вывод подтверждён:', receipt?.hash);
return receipt?.hash || '';
}Подробнее
- Лимиты (Limits) — как работают лимиты
- Liquidity Requests — управление LR
Поиск трансферов
API Endpoint
POST {BASE_URL}/transfers/search
Content-Type: application/jsonПример запроса
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
}'Параметры фильтрации
| Параметр | Тип | Обязательный | Описание |
|---|---|---|---|
transferKinds | string[] | ❌ | ["TvmToEvm"] для TVM→EVM трансферов |
tvmUserAddress | string | ❌ | Адрес отправителя в TVM |
evmUserAddress | string | ❌ | Адрес получателя в EVM |
statuses | string[] | ❌ | Pending, Completed, Failed |
fromTvmChainId | number | ❌ | Chain ID исходной TVM сети |
toEvmChainId | number | ❌ | Chain ID целевой EVM сети |
limit | number | ✅ | Лимит записей |
offset | number | ✅ | Смещение |
isNeedTotalCount | boolean | ✅ | Возвращать ли общее количество |
Режимы трансфера: Credit vs Non-Credit
| Аспект | Credit (useCredit=true) | Non-Credit (useCredit=false) |
|---|---|---|
| Газ в EVM | Оплачивает Gas Credit Backend | Оплачивает пользователь |
| Автоматизация | Полностью автоматический | Требует ручной вызов saveWithdraw*() |
| Скорость | Быстрее (автоматический вывод) | Зависит от пользователя |
| Комиссия | Включена в feesMeta.amount | Только комиссия моста |
| Рекомендация | Для обычных пользователей | Для продвинутых/автоматизации |
Credit Mode (useCredit=true)
- Credit Backend автоматически вызывает
saveWithdraw*()в EVM - Газ платится из комиссии вашего трансфера
- Пользователю не требуется вмешиваться после burn/transfer в TVM
- Трансфер завершается автоматически
Non-Credit Mode (useCredit=false)
- Вы ответственны за вызов
saveWithdraw*()в EVM - Требуется собрать подписи relay-нод из TVM Event-контракта (Шаг 5)
- Требуется отправить отдельную EVM транзакцию
- Дешевле, но требует дополнительных действий
Полный пример: TVM→EVM трансфер
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'; // JRPC-нода TVM сети
// Конфигурация
const CONFIG = {
tvmChainId: -239,
evmChainId: 1,
tokenAddress: '0:1111...1111', // TIP-3 токен в TVM
amount: '1000000000', // в nano-единицах
};
// --- Вспомогательные функции и ABI (определены в Шагах 2–6) ---
// ABI: EVENT_CONFIG_EVENTS_ABI (Шаг 3), EVENT_ABI, EVENT_CONFIG_ABI (Шаг 5.1)
// Шаг 2: sendTvmToEvmTransaction(tvmClient, senderAddress, payloadResponse)
// Шаг 3: getEventContractAddress(provider, transactionHash, sourceConfiguration)
// Шаг 5: readEventData(provider, eventContractAddress, configurationAddress)
// Шаг 5: encodeEventData(eventInitData, eventContractAddress, roundNumber, eventABI, proxy, flags)
// Шаг 5: processSignatures(signatures, encodedEvent)
// Шаг 5: saveWithdrawAlien(signer, multiVaultAddress, encodedEvent, signatures)
// Шаг 5: saveWithdrawNative(signer, multiVaultAddress, encodedEvent, signatures)
// Шаг 6: getPendingWithdrawalId(receipt)
async function tvmToEvmTransfer(
tvmClient: ProviderRpcClient,
tvmSenderAddress: string, // TVM адрес кошелька пользователя
params: {
fromChainId: number;
toChainId: number;
tokenAddress: string;
recipientAddress: string;
amount: string;
senderAddress: string;
}
) {
// === Шаг 1: Подготовка трансфера ===
console.log('[Шаг 1] Собираем 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 получен:', {
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;
// === Шаг 2: Burn/Transfer в TVM ===
console.log('\n[Шаг 2] Отправляем burn/transfer транзакцию...');
const txHash = await sendTvmToEvmTransaction(
tvmClient, tvmSenderAddress, payloadResponse
);
console.log('Транзакция отправлена, hash:', txHash);
// === Шаг 3: Получение Event Contract Address ===
console.log('\n[Шаг 3] Получаем адрес Event-контракта...');
const contractAddress = await getEventContractAddress(
tvmClient,
txHash,
payloadResponse.trackingMeta.sourceConfiguration
);
if (!contractAddress) {
throw new Error('Event-контракт не найден в trace транзакции');
}
console.log('Contract Address:', contractAddress);
// === Шаг 4: Проверка статуса (опционально) ===
console.log('\n[Шаг 4] Проверяем статус трансфера...');
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('Статус:', statusResult.transfer?.tvmEvm?.transferStatus);
// === Шаг 5: Вывод в EVM ===
console.log('\n[Шаг 5] Выводим токены в EVM...');
// 5.1: Ждём подписи relay-нод и читаем данные из Event-контракта.
// readEventData выбросит ошибку, если подписей недостаточно — повторяем с интервалом.
let eventData;
while (true) {
try {
eventData = await readEventData(
tvmClient,
contractAddress,
payloadResponse.trackingMeta.sourceConfiguration
);
break; // Подписей достаточно
} catch (e: any) {
if (e.message?.includes('Недостаточно подписей')) {
console.log('Ожидаем подписи relay-нод...');
await new Promise(r => setTimeout(r, 15_000)); // 15 секунд
continue;
}
throw e;
}
}
// 5.2: Кодируем payload
const encodedEvent = encodeEventData(
eventData.eventInitData,
contractAddress,
eventData.roundNumber,
eventData.eventABI,
eventData.proxy,
eventData.flags
);
// 5.3: Обрабатываем подписи
const sortedSignatures = processSignatures(eventData.signatures, encodedEvent);
// 5.4: Вызываем 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);
}
// === Шаг 6: Проверяем, не отложен ли вывод ===
// Извлекаем Pending Withdrawal ID из receipt транзакции saveWithdraw*()
const withdrawReceipt = await ethersProvider.getTransactionReceipt(withdrawTxHash);
const pendingWithdrawalId = withdrawReceipt ? getPendingWithdrawalId(withdrawReceipt) : null;
if (pendingWithdrawalId !== null) {
console.log('\nСоздан Pending Withdrawal (Шаг 6)');
console.log('Pending Withdrawal ID:', pendingWithdrawalId.toString());
// Проверяем статус через 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('Статус:', finalStatus.notInstantTransfer?.status);
console.log('Сумма:', finalStatus.notInstantTransfer?.currentAmount);
return finalStatus;
}
console.log('\nТрансфер завершён!');
return null;
}
// --- Использование ---
const tvmClient = new ProviderRpcClient({
fallback: () => EverscaleStandaloneClient.create({
connection: { type: 'jrpc', data: { endpoint: TVM_RPC_ENDPOINT } },
}),
});
await tvmClient.ensureInitialized();
const tvmSenderAddress = '0:2222...2222'; // TVM адрес подключённого кошелька
await tvmToEvmTransfer(tvmClient, tvmSenderAddress, {
fromChainId: CONFIG.tvmChainId,
toChainId: CONFIG.evmChainId,
tokenAddress: CONFIG.tokenAddress,
recipientAddress: '0x742d...Ab12',
amount: CONFIG.amount,
senderAddress: tvmSenderAddress,
});Справка по ошибкам
TVM ошибки при Burn/Transfer
| Ошибка | Причина | Решение |
|---|---|---|
Invalid message | Payload некорректен | Запросите новый payload через API |
Low balance | Недостаточно газа на транзакцию | Увеличьте attachedValue в запросе |
Unauthorized | Попытка burn/transfer чужого токена | Используйте свой TokenWallet |
Not enough balance | Недостаточно токенов для burn/transfer | Проверьте баланс перед отправкой |
EVM ошибки при saveWithdraw*()
| Ошибка | Причина | Решение |
|---|---|---|
Withdraw: wrong chain id | Payload предназначен для другой EVM сети | Проверьте toChainId в API запросе |
Withdraw: token is blacklisted | Токен добавлен в blacklist | Свяжитесь с командой, токен невозможно вывести |
Withdraw: bounty > withdraw amount | Награда превышает сумму вывода | Уменьшите bounty или не используйте её |
Withdraw: already seen | Этот withdraw уже был выполнен (replay protection) | Это нормально, withdrawal уже завершён |
Withdraw: invalid configuration | Event configuration некорректна | Системная ошибка, свяжитесь с командой |
| (без сообщения) | verifySignedTvmEvent() вернул ошибку | Подписи relay-нод невалидны, попробуйте позже |
Ошибки Pending Withdrawal
| Ошибка | Причина | Решение |
|---|---|---|
Pending: amount is zero | Попытка операции с нулевым pending | Используйте другой pending |
Pending: wrong amount | Сумма выплаты некорректна | Проверьте сумму pending |
Pending: native token | Попытка установить bounty для native | Bounty работает только для Alien |
Pending: bounty too large | Bounty превышает сумму pending | Уменьшите bounty |