Skip to content

Трансфер TVM→EVM

Обзор

Трансфер TVM→EVM позволяет отправить токены из TVM сети в EVM сеть. Процесс включает:

  1. Build Payload — получить данные для транзакции через API
  2. Burn/Transfer в TVM — отправить транзакцию в TVM (сжечь Alien или перевести Native на Proxy)
  3. Получение Event Contract Address — найти адрес задеплоенного Event-контракта
  4. Ожидание подтверждений — relay-ноды подписывают событие, Event-контракт накапливает подписи
  5. Вывод в EVM (non-credit) — прочитать подписи из Event-контракта и вызвать saveWithdraw*() в MultiVault
  6. Если вывод отложен — при превышении лимитов или недостатке ликвидности создаётся 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Описание
Alienburn на TokenWalletunlock в MultiVaultТокены сжигаются в TVM, в EVM разблокируются
Nativelock на Proxymint в MultiVaultТокены блокируются в TVM, в EVM выпускаются

API Base URL

Все API-запросы в этом гайде используют один из двух окружений:

ОкружениеBase URL
Productionhttps://tetra-history-api.chainconnect.com/v2
Testnethttps://history-api-test.chainconnect.com/v2

Эндпоинты, используемые в гайде:

ЭндпоинтМетодШаг
/payload/buildPOSTШаг 1 — сборка payload
/transfers/statusPOSTШаг 4 — проверка статуса
/transfers/searchPOSTПоиск трансферов

Шаг 1: Подготовка трансфера (Build Payload)

Цель: Получить payload для отправки транзакции в TVM

API Endpoint

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

Параметры запроса

typescript
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 трансферов) 
  };
}
ПараметрТипОбязательныйОписание
fromChainIdnumberTVM chain ID сети отправления
toChainIdnumberEVM chain ID сети назначения
tokenAddressstringАдрес TIP-3 токена в TVM (формат 0:...)
recipientAddressstringАдрес получателя в EVM (формат 0x... lowercase)
amountstringСумма в минимальных единицах (целое число как строка, напр. "1000000" для 1 USDT с 6 decimals)
senderAddressstringАдрес отправителя в TVM (формат 0:...)
useCreditbooleanЕсли true — Gas Credit Backend автоматически вызывает saveWithdraw в EVM. Default: false
remainingGasTostringАдрес для возврата остатка газа (используется если useCredit=false)
callbackobjectПараметры callback (адрес EVM контракта для вызова после завершения)
tokenBalance.nativeTokenAmountstring⚠️Баланс газового токена (native currency). Обязателен при отправке газового токена
tokenBalance.wrappedNativeTokenAmountstring⚠️Баланс wrapped версии газового токена. Обязателен при отправке газового токена

Пример запроса

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"
  }'

Пример ответа

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
}

Поля ответа

ПолеОписаниеИспользование
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.abiABI контракта (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.abiABI контракта (JSON)Для TVM SDK — создание Contract
abiMeta.abiMethod"burn" или "transfer"Для TVM SDK — имя метода
abiMeta.paramsПараметры метода (JSON)Для TVM SDK — аргументы вызова
abiMeta.executionAddressАдрес TokenWalletПолучатель транзакции
abiMeta.attachedValueГаз в nanoTONAmount транзакции

Различие между операциями

ОперацияТип токенаОписание
burnAlienТокен сжигается на TokenWallet. TokenRoot вызывает callback в ProxyMultiVaultAlien
transferNativeТокен переводится на wallet Proxy. TokenWallet вызывает callback в ProxyMultiVaultNative

Отправка транзакции

typescript
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;
}
typescript
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;
}

Пример использования

typescript
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);
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 получен на Шаге 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) транзакции:

  1. TokenWallet вызывает callback в TokenRoot или ProxyMultiVaultAlien/ProxyMultiVaultNative
  2. Proxy деплоит Event-контракт через TvmEvmEventConfiguration
  3. Event-контракт создаётся с данными трансфера
  4. EventConfiguration эмитит событие NewEventContract с адресом Event-контракта

Получение Event Contract Address

typescript
// При деплое 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);
}
typescript
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

Параметры запроса

typescript
interface StatusRequest {
  tvmEvm: {
    contractAddress: string;       // Адрес Event-контракта из Шага 3
    dappChainId: number;           // TVM chain ID сети отправления
    timestampCreatedFrom?: number; // Фильтр по времени (опционально)
  };
}
ПараметрОписание
contractAddressАдрес Event-контракта (формат 0:...)
dappChainIdTVM chain ID сети отправления
timestampCreatedFromМинимальный timestamp для фильтра (опционально)

Пример запроса

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

Пример ответа (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
}

Пример ответа (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

Для 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Действие
trueCredit Backend автоматически вызывает saveWithdraw*(). Пропустите Шаг 5.
falseВы должны вручную собрать подписи и вызвать saveWithdraw*(). Выполните Шаг 5.

Различие между методами вывода

МетодТип токенаОписание
saveWithdrawAlienAlienРазлокирует Alien токены в MultiVault, передаёт пользователю
saveWithdrawNativeNativeМинтит новые 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-нод
typescript
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:

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. Конвертировать 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 и отсортировать по адресу подписанта (по возрастанию):

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

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('Вызываем 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 || '';
}
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('Вызываем 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 — вывод отложен:

jsonc
{
  "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*():

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; // Мгновенный вывод — 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).

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

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

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('Принудительный вывод подтверждён:', receipt?.hash);
  return receipt?.hash || '';
}

Подробнее

Поиск трансферов

API Endpoint

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

Пример запроса

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
  }'

Параметры фильтрации

ПараметрТипОбязательныйОписание
transferKindsstring[]["TvmToEvm"] для TVM→EVM трансферов
tvmUserAddressstringАдрес отправителя в TVM
evmUserAddressstringАдрес получателя в EVM
statusesstring[]Pending, Completed, Failed
fromTvmChainIdnumberChain ID исходной TVM сети
toEvmChainIdnumberChain ID целевой EVM сети
limitnumberЛимит записей
offsetnumberСмещение
isNeedTotalCountbooleanВозвращать ли общее количество

Режимы трансфера: 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 трансфер

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'; // 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 messagePayload некорректенЗапросите новый payload через API
Low balanceНедостаточно газа на транзакциюУвеличьте attachedValue в запросе
UnauthorizedПопытка burn/transfer чужого токенаИспользуйте свой TokenWallet
Not enough balanceНедостаточно токенов для burn/transferПроверьте баланс перед отправкой

EVM ошибки при saveWithdraw*()

ОшибкаПричинаРешение
Withdraw: wrong chain idPayload предназначен для другой EVM сетиПроверьте toChainId в API запросе
Withdraw: token is blacklistedТокен добавлен в blacklistСвяжитесь с командой, токен невозможно вывести
Withdraw: bounty > withdraw amountНаграда превышает сумму выводаУменьшите bounty или не используйте её
Withdraw: already seenЭтот withdraw уже был выполнен (replay protection)Это нормально, withdrawal уже завершён
Withdraw: invalid configurationEvent configuration некорректнаСистемная ошибка, свяжитесь с командой
(без сообщения)verifySignedTvmEvent() вернул ошибкуПодписи relay-нод невалидны, попробуйте позже

Ошибки Pending Withdrawal

ОшибкаПричинаРешение
Pending: amount is zeroПопытка операции с нулевым pendingИспользуйте другой pending
Pending: wrong amountСумма выплаты некорректнаПроверьте сумму pending
Pending: native tokenПопытка установить bounty для nativeBounty работает только для Alien
Pending: bounty too largeBounty превышает сумму pendingУменьшите bounty

ChainConnect Bridge Documentation