Verificación de pagos y seguimiento de pedidos
Esta guía explica cómo verificar los pagos no custodiales realizados con contratos inteligentes de Volet.com en Ethereum, BNB Smart Chain y TRON.
Un hash de transacción (txid) no es prueba de pago. Tu backend debe comprobar por su cuenta la ejecución, la finalidad y los eventos de pago antes de marcar un pedido como pagado. En esta integración, Volet.com no envía notificaciones a la Status URL como en Hosted Checkout.
Cómo funciona la verificación de pagos
- Crea un pedido. Guarda la red, el activo, la billetera receptora (
seller), el contrato procesador, el monto esperado para el comercio y, cuando corresponda, la billetera del comprador. - Registra el intento de pago. Asocia el
txiddel pago con el pedido. Guarda aparte los hashes de las transaccionesapprove()de USDT. - Comprueba la ejecución y la finalidad. Obtén los datos de la blockchain mediante una conexión RPC del backend o un servicio blockchain alojado.
- Verifica los eventos de pago. Busca el evento
PaymentProcessedemitido por el procesador aprobado. En USDT, verifica además elTransferreal del token hacia el comercio. - Asocia el pago con el pedido. Compara la dirección del comercio del evento, el comprador (si se requiere) y el monto neto con los datos inmutables del pedido en el backend.
- Completa el pedido una sola vez. Registra de forma atómica el pago verificado y lanza una entrega idempotente.
Puedes usar un proveedor alojado. No necesitas operar tu propio nodo de blockchain.
Eventos de pago y montos
Los procesadores documentados de Volet.com emiten PaymentProcessed después de pagar al comercio:
event PaymentProcessed(
address indexed buyer,
address indexed seller,
uint256 commission,
uint256 totalPay
);
En estos despliegues, el monto neto que recibe el comercio se calcula así:
receivedAmount = totalPay - commission
Ambos valores usan las unidades mínimas del activo. El cálculo se aplica con cualquier valor de buyerPaysCommission.
| Activo | Evidencia on-chain requerida |
|---|---|
| ETH, BNB | Ejecución exitosa y finalizada, y un log PaymentProcessed coincidente emitido por el procesador aprobado del activo nativo |
| USDT ERC-20, BEP-20 | Log PaymentProcessed coincidente del procesador y log Transfer de USDT del contrato de token aprobado: procesador → comercio por receivedAmount |
| TRX | Ejecución exitosa y solidificada, y un log PaymentProcessed coincidente emitido por el procesador aprobado de TRX |
| USDT TRC-20 | Log PaymentProcessed coincidente del procesador y log Transfer de USDT TRC-20 del contrato de token aprobado: procesador → comercio por receivedAmount |
Comprueba siempre la dirección que emitió cada evento. Un log con el nombre de evento correcto, pero emitido por un contrato ajeno, no prueba el pago. Las transferencias directas de billetera a billetera quedan fuera de este flujo de pago basado en el procesador.
Según los materiales de integración de los procesadores, los pagos nativos se revierten si falla la transferencia al comercio; por eso, el evento PaymentProcessed auténtico en una transacción exitosa y finalizada sirve como evidencia del pago nativo. Las vistas de transferencias internas de los exploradores sirven para investigar, pero el método de verificación descrito aquí no las requiere.
Estas comprobaciones de eventos son fiables para las implementaciones desplegadas de los procesadores descritas aquí. Valida la ABI y las direcciones de los procesadores contra el despliegue que uses.
Requisitos de finalidad
| Red | Condición para completar |
|---|---|
| Ethereum | Transacción en un bloque finalizado; recibo exitoso y eventos de pago coincidentes |
| BNB Smart Chain | La transacción cumple la política de finalidad configurada y admitida por el proveedor; recibo exitoso y eventos de pago coincidentes |
| TRON | La transacción está disponible en un SolidityNode, el recibo indica SUCCESS y los eventos de pago coinciden |
Los ejemplos para desarrolladores usan 15 confirmaciones en las redes EVM. Es una política de número de confirmaciones, no la finalidad de Ethereum. Los ejemplos de abajo mantienen una comprobación de bloque finalizado; usa un proveedor que admita la semántica de finalidad que requiere tu red. Si no puede proporcionar esa información, deja el pago en revisión en lugar de considerarlo final.
Si un proveedor no está disponible o aún no aparece el recibo, vuelve a intentarlo. Una respuesta vacía no prueba que el pago haya fallado.
Ciclo de vida del pedido
| Estado | Significado | ¿Entregar? |
|---|---|---|
created | Pedido guardado, sin pago enviado | No |
approval_pending | Transacción de aprobación de USDT enviada | No |
pending | txid del pago guardado; a la espera de inclusión o finalidad | No |
verifying | Se están comprobando la ejecución, la finalidad y los eventos | No |
paid | El pago verificado coincide con el pedido | Sí, una vez |
failed | Fallo de ejecución confirmado o validación fallida de forma concluyente | No |
expired | Venció el plazo de pago; los pagos enviados aún requieren conciliación | No, hasta conciliar |
review | Datos de verificación incompletos, ambiguos o contradictorios | No |
Recargar la página, que venza el plazo de pago o cerrar la pestaña del navegador no debe detener el proceso de verificación del backend.
Guarda el pago esperado
Crea el registro del pago antes de pedir al comprador que envíe la transacción. Ejemplo:
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 es el monto solicitado del pedido. expectedSellerAmount es el monto neto que debe recibir el comercio, en unidades mínimas. Determínalo y guárdalo a partir del pedido validado en tu servidor y de la configuración de comisiones aprobada, usando los resultados de previewPayment() del procesador. No lo obtengas de datos arbitrarios del navegador ni de un porcentaje de comisión fijo en el código.
En USDT, usa 6 decimales en Ethereum y TRON y 18 decimales en BNB Smart Chain. ETH y BNB usan 18 decimales; TRX usa 6 (SUN). Nunca uses aritmética de punto flotante para calcular montos de tokens.
Guarda las direcciones aprobadas de redes, tokens y procesadores en una lista de permitidos del backend. La función transfer() no tiene el parámetro orderId, así que tu aplicación debe vincular los intentos de pago con los pedidos.
Usa registros únicos de intentos de pago y asegúrate de que un evento de pago on-chain no pueda asignarse a más de un pedido. Un evento se identifica por (chain, txid, logIndex). Haz también que la entrega de pedidos sea idempotente. Un reintento del cliente debe crear un intento nuevo en lugar de sobrescribir un hash pendiente anterior.
Verificación en Ethereum y BNB Smart Chain
Usa ethers v6 en el backend. El siguiente ejemplo verifica la ejecución y la finalidad y luego comprueba un log PaymentProcessed auténtico. En USDT, además comprueba la transferencia real del token.
El argumento order debe contener valores de confianza del backend. Pasa el provider de la misma red que 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' };
}
Cómo usar este resultado: matched significa que la evidencia del pago pasó las comprobaciones de esta función. Antes de cambiar el pedido a paid, reclama su eventId de forma única en tu base de datos y completa el pedido mediante una transacción idempotente en el servidor.
El código asocia intencionalmente los logs autenticados del recibo en lugar de basarse en el calldata de la transacción. Una transacción directa al comercio o un evento Transfer ajeno no cuentan.
En los pagos con ETH y BNB nativos, el evento PaymentProcessed autenticado del procesador es la evidencia documentada del pago. En USDT, el Transfer adicional debe emitirlo el contrato de token aprobado y debe transferir los tokens del procesador al vendedor.
En producción, configura tus propios endpoints RPC fiables y gestiona los errores del proveedor y los reintentos alrededor de esta función.
Verificación en TRON
En TRON, consulta un recibo de transacción solidificado mediante el endpoint del SolidityNode:
POST https://api.trongrid.io/walletsolidity/gettransactioninfobyid
El recibo incluye los logs de eventos sin procesar. La siguiente implementación los lee directamente, sin depender de que un indexador tenga la lista de eventos ni de su formato de direcciones. Usa ethers v6 para decodificar los datos de eventos compatibles con EVM y tronweb para normalizar las direcciones de TRON.
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' };
}
La función verifica el recibo de ejecución solidificado, el evento PaymentProcessed auténtico y, en USDT, el evento Transfer del token coincidente. Si falta el recibo solidificado, el pago está pendiente, no fallido. Ante errores o información de eventos incompleta, deja el pago en revisión y vuelve a intentarlo.
Usa las direcciones aprobadas de los procesadores y tokens en la mainnet de TRON que figuran en la Referencia de contratos inteligentes. No aceptes otra red ni otra dirección de procesador enviadas desde el navegador.
Consulta TRON confirmation semantics para conocer los detalles de la finalidad en SolidityNode.
Asocia los pagos con los pedidos y evita repeticiones
La función transfer() no incluye un orderId. Mantener una asociación fiable entre pedidos y pagos es responsabilidad de tu backend.
- Cuando la página de pago envía un pago: guarda su
txiden el pedido existente y registra un intento de pago inmutable. - Al procesar un evento de pago: comprueba que el vendedor, el comprador opcional y el monto neto del evento coincidan con el pedido esperado.
- Para la idempotencia: usa una clave única
(chain, txid, logIndex)para cada evento de pago. Evita que el mismo evento complete varios pedidos. No entregues un pedido dos veces aunque el proceso de verificación lo reintente. - Para reintentos y pagos tardíos: conserva los intentos de pago anteriores y concilia las transacciones que se finalizan después de que vence el plazo de pago.
Varios pagos pueden tener el mismo vendedor y monto. No te bases solo en el vendedor, el monto o una ventana de tiempo, ni modifiques los últimos decimales para fabricar montos de factura únicos. Mantén una asociación directa entre transacción y pedido en todos los puntos donde tu página de pago envía la transacción.
Reintentos y errores operativos
| Situación | Acción |
|---|---|
| La transacción aún no se minó o no está solidificada | Mantén el intento pendiente y vuelve a intentarlo |
| Tiempo de espera de RPC, 429 o error de red transitorio | Reintenta con espera progresiva; no concluyas que el pago falló |
| La transacción de aprobación fue exitosa | Registra la aprobación y espera la transacción de pago aparte |
| La transacción finalizada se revirtió | Marca el intento como fallido; no entregues el pedido |
| Recibo exitoso, pero evento sin coincidencia | Déjalo en revisión; revisa el procesador, el token, el vendedor y el monto esperado |
| Se cierra el navegador o vence el plazo de pago | Continúa la conciliación en el backend |
| El evento de pago ya está reclamado por un pedido | No lo asignes a otro pedido |
Un contador de confirmaciones en el navegador, la insignia de estado de un explorador de bloques o una captura de pantalla del cliente no bastan para completar un pedido.
Verificación manual
Usa Etherscan, BscScan o TronScan para investigar un pago. Revisa la transacción en la red correcta: ejecución exitosa, evento PaymentProcessed del procesador aprobado, dirección del comercio y monto neto. En USDT, revisa también el Transfer auténtico del token del procesador al comercio.
La revisión manual puede ayudar al equipo de soporte a conciliar pagos, pero una página de pago en producción debe implementar una verificación automática en el servidor.
Lista de verificación para el lanzamiento
- Direcciones aprobadas de los procesadores y del token USDT en mainnet configuradas en el servidor.
- La ABI del evento
PaymentProcessedcoincide con los contratos procesadores desplegados. - Precisión correcta de los activos: ETH/BNB 18, USDT en Ethereum/TRON 6, USDT en BSC 18, TRX 6.
- Pedidos y montos netos esperados para el vendedor registrados antes de enviar el pago.
- Hashes de aprobación de tokens guardados aparte de los hashes de pago.
- Estado del recibo y finalidad específica de cada red comprobados.
-
PaymentProcessedverificado contra la dirección del procesador emisor, el vendedor y el monto neto esperado. -
Transferde USDT verificado contra la dirección del token emisor, el procesador, el vendedor y el monto. - Billetera fija del comprador comprobada cuando la página de pago lo requiere.
- Cada evento reclamado de forma única; la entrega es idempotente.
- Probados los pagos fallidos, duplicados, insuficientes y con destinatario incorrecto.
- Gestionadas las caídas de RPC, los logs faltantes, las confirmaciones tardías y los plazos de pago vencidos.
- Verificación probada con transacciones exitosas y fallidas conocidas para cada activo compatible.
Relacionado
- Integración con contratos inteligentes: descripción general de la integración de pagos no custodiales.
- Integración con Ethereum y BNB Smart Chain: envía pagos en ETH, BNB y USDT.
- Integración con TRON: envía pagos en TRX y USDT TRC-20.
- Referencia de contratos inteligentes: direcciones de los procesadores, decimales de los tokens e interfaz de eventos.
- Solución de problemas: errores frecuentes de billeteras y pagos.