Перейти к основному содержимому

Проверка платежей и отслеживание заказов

В этом руководстве описано, как проверять некастодиальные платежи через смарт-контракты Volet.com в Ethereum, BNB Smart Chain и TRON.

Хеш транзакции (txid) — не доказательство оплаты. Прежде чем пометить заказ как оплаченный, ваш бэкенд должен самостоятельно проверить выполнение транзакции, её финальность и платёжные события. В этой интеграции Volet.com не отправляет уведомления на Status URL, как в Hosted Checkout.

Как работает проверка платежа​

  1. Создайте заказ. Сохраните ожидаемые сеть, актив, кошелёк для приёма средств (seller), контракт-процессор, ожидаемую сумму для мерчанта и, если нужно, кошелёк покупателя.
  2. Зафиксируйте попытку оплаты. Свяжите txid платежа с заказом. Хеши транзакций approve() для USDT храните отдельно.
  3. Проверьте выполнение и финальность. Получите данные из блокчейна через RPC-подключение на бэкенде или облачный блокчейн-сервис.
  4. Проверьте платёжные события. Найдите событие PaymentProcessed от утверждённого процессора. Для USDT также проверьте фактический Transfer токенов мерчанту.
  5. Сопоставьте с заказом. Сравните адрес мерчанта из события, покупателя (если требуется) и чистую сумму с неизменными данными заказа на бэкенде.
  6. Завершите заказ один раз. Атомарно сохраните проверенный платёж и запустите идемпотентное выполнение заказа.

Можно использовать облачного провайдера. Собственная блокчейн-нода не нужна.

Платёжные события и суммы​

Задокументированные процессоры Volet.com генерируют событие PaymentProcessed после выплаты мерчанту:

event PaymentProcessed(
address indexed buyer,
address indexed seller,
uint256 commission,
uint256 totalPay
);

Для этих развёртываний чистая сумма, которую получает мерчант, считается так:

receivedAmount = totalPay - commission

Оба значения указываются в минимальных единицах актива. Формула верна при любом значении buyerPaysCommission.

АктивНеобходимые доказательства в блокчейне
ETH, BNBУспешное финализированное выполнение и совпадающий лог PaymentProcessed от утверждённого процессора нативного актива
USDT ERC-20, BEP-20Совпадающий лог PaymentProcessed процессора и лог Transfer USDT от утверждённого контракта токена: процессор → мерчант на сумму receivedAmount
TRXУспешное solidified-выполнение и совпадающий лог PaymentProcessed от утверждённого процессора TRX
USDT TRC-20Совпадающий лог PaymentProcessed процессора и лог Transfer USDT TRC-20 от утверждённого контракта токена: процессор → мерчант на сумму receivedAmount

Всегда проверяйте адрес, от которого пришло каждое событие. Лог с правильным именем события, но от постороннего контракта не доказывает оплату. Прямые переводы с кошелька на кошелёк не входят в этот платёжный сценарий через процессор.

Согласно материалам по интеграции процессоров, нативная выплата откатывается, если перевод мерчанту не прошёл. Поэтому подлинное событие PaymentProcessed в успешной финализированной транзакции служит доказательством нативной выплаты. Внутренние переводы в обозревателе блокчейна помогают при разборе, но для описанного здесь метода проверки не нужны.

Эти проверки событий надёжны для описанных здесь развёрнутых реализаций процессоров. Сверьте ABI и адреса процессоров с тем развёртыванием, которое используете вы.

Требования к финальности​

СетьУсловие завершения
EthereumТранзакция в финализированном блоке; успешная квитанция и совпадающие платёжные события
BNB Smart ChainТранзакция соответствует настроенной политике финальности, которую поддерживает провайдер; успешная квитанция и совпадающие платёжные события
TRONТранзакция доступна через SolidityNode, квитанция сообщает SUCCESS, платёжные события совпадают

В примерах для разработчиков в EVM-сетях используется 15 подтверждений. Это политика по количеству подтверждений, а не финальность Ethereum. В примерах ниже сохранена проверка финализированного блока; используйте провайдера, который поддерживает нужную для вашей сети семантику финальности. Если провайдер не может её предоставить, оставьте платёж на проверке и не считайте его финальным.

Если провайдер недоступен или квитанция ещё не появилась, повторите запрос. Пустой ответ не доказывает, что платёж не прошёл.

Жизненный цикл заказа​

СостояниеЗначениеВыполнять?
createdЗаказ сохранён, платёж не отправленНет
approval_pendingОтправлена транзакция одобрения USDTНет
pendingtxid платежа сохранён; ожидается включение в блок или финальностьНет
verifyingПроверяются выполнение, финальность и событияНет
paidПроверенный платёж соответствует заказуДа, один раз
failedПодтверждённая ошибка выполнения или окончательный отказ при проверкеНет
expiredСрок оплаты истёк; отправленные платежи всё равно нужно сверитьНет, до сверки
reviewНеполные, неоднозначные или противоречивые данные проверкиНет

Обновление страницы, истёкший срок оплаты или закрытая вкладка браузера не должны останавливать фоновый обработчик проверки на бэкенде.

Сохраните ожидаемый платёж​

Создайте запись о платеже до того, как просить покупателя отправить транзакцию. Пример:

function createPendingPaymentRecord(order) {
return {
orderId: order.orderId,
chain: order.chain, // e.g. 'ethereum', 'bsc', 'tron'
chainId: order.chainId ?? null,
asset: order.asset,
tokenAddress: order.tokenAddress ?? null,
processorAddress: order.processorAddress,
sellerAddress: order.sellerAddress,
buyerAddress: order.buyerAddress ?? null,
amountBaseUnits: order.amountBaseUnits.toString(),
expectedSellerAmount: order.expectedSellerAmount.toString(),
buyerPaysCommission: order.buyerPaysCommission,
approvalTxids: [],
paymentAttempts: [],
status: 'created'
};
}

amountBaseUnits — запрошенная сумма заказа. expectedSellerAmount — чистая сумма, которую должен получить мерчант, в минимальных единицах. Определите и сохраните её на основе проверенного серверного заказа и утверждённых настроек комиссии, используя результаты previewPayment() процессора. Не выводите её из произвольных данных браузера или зашитого в код процента комиссии.

Для USDT используйте 6 decimals в Ethereum и TRON и 18 decimals в BNB Smart Chain. У ETH и BNB 18 decimals, у TRX — 6 (SUN). Никогда не рассчитывайте суммы токенов в арифметике с плавающей точкой.

Храните утверждённые адреса сетей, токенов и процессоров в белом списке на бэкенде. У функции transfer() нет параметра orderId, поэтому связывать попытки оплаты с заказами должно ваше приложение.

Используйте уникальные записи о попытках оплаты и не допускайте, чтобы одно платёжное событие в блокчейне было присвоено нескольким заказам. Событие идентифицируется как (chain, txid, logIndex). Также сделайте выполнение заказов идемпотентным. Повторная попытка клиента должна создавать новую запись, а не перезаписывать прежний ожидающий хеш.

Проверка в Ethereum и BNB Smart Chain​

Используйте на бэкенде ethers v6. Пример ниже проверяет выполнение и финальность, а затем — подлинный лог PaymentProcessed. Для USDT он дополнительно проверяет реальный перевод токенов.

Аргумент order должен содержать доверенные значения с бэкенда. Передавайте provider той же сети, что указана в order.chainId.

import { ethers } from 'ethers';

const events = new ethers.Interface([
'event PaymentProcessed(address indexed buyer, address indexed seller, uint256 commission, uint256 totalPay)',
'event Transfer(address indexed from, address indexed to, uint256 value)'
]);

function sameEvmAddress(a, b) {
return ethers.getAddress(a) === ethers.getAddress(b);
}

async function verifyEvmPayment(provider, txid, order) {
const chainId = (await provider.getNetwork()).chainId;
if (chainId !== BigInt(order.chainId)) {
return { state: 'review', reason: 'Unexpected network' };
}

const receipt = await provider.getTransactionReceipt(txid);
if (!receipt) return { state: 'pending' };
if (receipt.status !== 1) return { state: 'failed', reason: 'Execution reverted' };

let finalized;
try {
finalized = await provider.getBlock('finalized');
} catch {
return { state: 'review', reason: 'Finality data unavailable' };
}
if (!finalized || receipt.blockNumber > finalized.number) {
return { state: 'pending' };
}

// Make sure the receipt still belongs to the canonical block.
const block = await provider.getBlock(receipt.blockNumber);
if (!block || block.hash !== receipt.blockHash) {
return { state: 'review', reason: 'Block mismatch' };
}

const processor = ethers.getAddress(order.processorAddress);
const seller = ethers.getAddress(order.sellerAddress);
const token = order.tokenAddress
? ethers.getAddress(order.tokenAddress)
: null;
const expectedNet = BigInt(order.expectedSellerAmount);

for (const log of receipt.logs) {
if (!sameEvmAddress(log.address, processor)) continue;

let event;
try { event = events.parseLog(log); } catch { continue; }
if (event?.name !== 'PaymentProcessed') continue;
if (!sameEvmAddress(event.args.seller, seller)) continue;
if (order.buyerAddress &&
!sameEvmAddress(event.args.buyer, order.buyerAddress)) continue;

const net = event.args.totalPay - event.args.commission;
if (net < 0n || net !== expectedNet) continue;

if (token) {
const tokenPaid = receipt.logs.some((otherLog) => {
if (!sameEvmAddress(otherLog.address, token)) return false;
let transfer;
try { transfer = events.parseLog(otherLog); } catch { return false; }
return transfer?.name === 'Transfer' &&
sameEvmAddress(transfer.args.from, processor) &&
sameEvmAddress(transfer.args.to, seller) &&
transfer.args.value === net;
});
if (!tokenPaid) continue;
}

return {
state: 'matched',
buyer: event.args.buyer,
sellerReceived: net.toString(),
eventId: `${order.chain}:${txid.toLowerCase()}:${log.index}`
};
}

return { state: 'review', reason: 'No matching payment event' };
}

Как использовать результат: matched означает, что доказательства оплаты прошли проверки этой функции. Прежде чем перевести заказ в paid, закрепите его eventId в базе данных с ограничением уникальности и завершите заказ идемпотентной серверной транзакцией.

Код намеренно сопоставляет аутентифицированные логи квитанции, а не полагается на calldata транзакции. Прямая транзакция мерчанту или посторонний Transfer не подходят.

Для платежей в нативных ETH и BNB задокументированным доказательством выплаты служит аутентифицированное событие PaymentProcessed процессора. Для USDT дополнительный Transfer должен исходить от утверждённого контракта токена и переводить токены от процессора продавцу.

В продакшене настройте собственные надёжные RPC-эндпоинты и обрабатывайте ошибки провайдера и повторные попытки вокруг этой функции.

Проверка в TRON​

В TRON запрашивайте solidified-квитанцию транзакции через эндпоинт SolidityNode:

POST https://api.trongrid.io/walletsolidity/gettransactioninfobyid

Квитанция содержит необработанные логи событий. Реализация ниже читает их напрямую и не зависит от того, есть ли список событий в индексаторе и в каком формате он отдаёт адреса. Для декодирования EVM-совместимых данных событий используется ethers v6, а для нормализации адресов TRON — tronweb.

import { ethers } from 'ethers';
import { TronWeb } from 'tronweb';

const tronEvents = new ethers.Interface([
'event PaymentProcessed(address indexed buyer, address indexed seller, uint256 commission, uint256 totalPay)',
'event Transfer(address indexed from, address indexed to, uint256 value)'
]);

function tronAddress20(address) {
// Base58 addresses use 41 + 20-byte account; EVM event addresses use 20 bytes.
if (typeof address !== 'string') throw new Error('Invalid address');
if (TronWeb.isAddress(address)) {
return TronWeb.address.toHex(address).slice(-40).toLowerCase();
}
const hex = address.replace(/^0x/, '').toLowerCase();
if (/^[0-9a-f]{40}$/.test(hex)) return hex;
if (/^41[0-9a-f]{40}$/.test(hex)) return hex.slice(2);
throw new Error('Invalid TRON address format');
}

function decodeTronLog(log) {
return tronEvents.parseLog({
topics: (log.topics || []).map((topic) => `0x${topic.replace(/^0x/, '')}`),
data: `0x${(log.data || '').replace(/^0x/, '')}`
});
}

async function verifyTronPayment(txid, order, tronGridApiKey) {
if (!/^[0-9a-fA-F]{64}$/.test(txid)) {
throw new Error('Invalid TRON transaction ID');
}

const response = await fetch(
'https://api.trongrid.io/walletsolidity/gettransactioninfobyid',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'TRON-PRO-API-KEY': tronGridApiKey
},
body: JSON.stringify({ value: txid })
}
);
if (!response.ok) throw new Error(`TronGrid HTTP ${response.status}`);

const info = await response.json();
if (info.Error) throw new Error(`TronGrid: ${info.Error}`);
if (!info.id) return { state: 'pending' };
if (info.id.toLowerCase() !== txid.toLowerCase()) {
return { state: 'review', reason: 'Transaction ID mismatch' };
}
if (!info.receipt?.result) {
return { state: 'review', reason: 'Receipt status unavailable' };
}
if (info.receipt.result !== 'SUCCESS') {
return { state: 'failed', reason: info.receipt.result };
}

const processor = tronAddress20(order.processorAddress);
const seller = tronAddress20(order.sellerAddress);
const token = order.tokenAddress ? tronAddress20(order.tokenAddress) : null;
const expectedNet = BigInt(order.expectedSellerAmount);
const logs = info.log || [];

for (const [index, log] of logs.entries()) {
if (tronAddress20(log.address) !== processor) continue;
let event;
try { event = decodeTronLog(log); } catch { continue; }
if (event?.name !== 'PaymentProcessed') continue;
if (tronAddress20(event.args.seller) !== seller) continue;
if (order.buyerAddress &&
tronAddress20(event.args.buyer) !== tronAddress20(order.buyerAddress)) continue;

const net = event.args.totalPay - event.args.commission;
if (net < 0n || net !== expectedNet) continue;

if (token) {
const tokenPaid = logs.some((other) => {
if (tronAddress20(other.address) !== token) return false;
let transfer;
try { transfer = decodeTronLog(other); } catch { return false; }
return transfer?.name === 'Transfer' &&
tronAddress20(transfer.args.from) === processor &&
tronAddress20(transfer.args.to) === seller &&
transfer.args.value === net;
});
if (!tokenPaid) continue;
}

return {
state: 'matched',
buyer: TronWeb.address.fromHex(`41${tronAddress20(event.args.buyer)}`),
sellerReceived: net.toString(),
eventId: `tron:${txid.toLowerCase()}:${index}`
};
}

return { state: 'review', reason: 'No matching payment event' };
}

Функция проверяет solidified-квитанцию выполнения, подлинное событие PaymentProcessed и, для USDT, совпадающее событие Transfer токена. Отсутствие solidified-квитанции означает ожидание, а не неуспешный платёж. При ошибках или неполных данных о событиях оставьте платёж на проверке и повторите запрос.

Используйте утверждённые адреса процессоров и токенов в основной сети TRON из справочника по смарт-контрактам. Не принимайте другую сеть или адрес процессора, переданные из браузера.

Подробнее о финальности SolidityNode — в документации TRON confirmation semantics.

Сопоставление платежей с заказами и защита от повторов​

Функция transfer() не принимает orderId. Надёжно связывать заказы с платежами — задача вашего бэкенда.

  • Когда страница оплаты отправляет платёж: сохраните его txid в существующем заказе и зафиксируйте неизменяемую попытку оплаты.
  • При обработке платёжного события: проверьте, что продавец, покупатель (если задан) и чистая сумма из события совпадают с ожидаемым заказом.
  • Для идемпотентности: используйте уникальный ключ (chain, txid, logIndex) для каждого платёжного события. Не допускайте, чтобы одно событие завершило несколько заказов. Не выполняйте заказ дважды, даже если обработчик проверки повторяет попытку.
  • Для повторных попыток и поздних платежей: сохраняйте прежние попытки оплаты и сверяйте транзакции, которые финализировались после истечения срока оплаты.

У нескольких платежей могут совпадать продавец и сумма. Не полагайтесь на совпадение только продавца, суммы или временного окна и не меняйте младшие десятичные знаки, чтобы искусственно сделать суммы счетов уникальными. Сохраняйте прямую связь транзакции с заказом везде, где ваша страница оплаты отправляет транзакцию.

Повторные попытки и операционные ошибки​

СитуацияДействие
Транзакция ещё не в блоке или не solidifiedОставьте попытку в ожидании и повторите проверку
Тайм-аут RPC, 429 или временная ошибка сетиПовторите с увеличивающейся задержкой; не делайте вывод, что платёж не прошёл
Транзакция одобрения прошла успешноЗафиксируйте одобрение и дождитесь отдельной платёжной транзакции
Финализированная транзакция откатиласьПометьте попытку как неуспешную; не выполняйте заказ
Квитанция успешна, но событие не совпадаетОставьте на проверке; проверьте процессор, токен, продавца и ожидаемую сумму
Браузер закрыт или срок оплаты истёкПродолжайте сверку на бэкенде
Платёжное событие уже закреплено за заказомНе присваивайте его другому заказу

Счётчика подтверждений в браузере, статуса в обозревателе блокчейна или скриншота от клиента недостаточно, чтобы завершить заказ.

Ручная проверка​

При разборе платежа используйте Etherscan, BscScan или TronScan. Проверьте транзакцию в нужной сети: успешное выполнение, событие PaymentProcessed утверждённого процессора, адрес мерчанта и чистую сумму. Для USDT также проверьте подлинный Transfer токенов от процессора мерчанту.

Ручная проверка помогает службе поддержки сверять платежи, но в рабочей системе оплаты нужна автоматическая проверка на сервере.

Чек-лист перед запуском​

  • Утверждённые адреса процессоров и токенов USDT в основной сети настроены на сервере.
  • ABI события PaymentProcessed совпадает с развёрнутыми контрактами-процессорами.
  • Точность активов верна: ETH/BNB — 18, USDT в Ethereum/TRON — 6, USDT в BSC — 18, TRX — 6.
  • Заказы и ожидаемые чистые суммы для продавца сохраняются до отправки платежа.
  • Хеши одобрений токенов хранятся отдельно от хешей платежей.
  • Проверяются статус квитанции и финальность с учётом особенностей сети.
  • PaymentProcessed сверяется с адресом процессора-источника, продавцом и ожидаемой чистой суммой.
  • Transfer USDT сверяется с адресом токена-источника, процессором, продавцом и суммой.
  • Фиксированный кошелёк покупателя сверяется, если этого требует страница оплаты.
  • Каждое событие закрепляется уникально; выполнение заказов идемпотентно.
  • Протестированы неуспешные, повторные, неполные платежи и платежи не тому получателю.
  • Обрабатываются сбои RPC, отсутствующие логи, поздние подтверждения и истёкшие сроки оплаты.
  • Проверка протестирована на известных успешных и неуспешных транзакциях для каждого поддерживаемого актива.