Aller au contenu principal

Vérification des paiements et suivi des commandes

Ce guide explique comment vérifier les paiements non dépositaires effectués via les smart contracts de Volet.com sur Ethereum, BNB Smart Chain et TRON.

Un hash de transaction (txid) n’est pas une preuve de paiement. Votre backend doit vérifier de manière indépendante l’exécution, la finalité et les événements de paiement avant de marquer une commande comme payée. Pour cette intégration, Volet.com n’envoie pas de notifications vers la Status URL comme avec Hosted Checkout.

Fonctionnement de la vérification des paiements​

  1. Créez une commande. Enregistrez le réseau, l’actif, le portefeuille de réception (seller), le contrat processeur, le montant attendu pour le marchand et, le cas échéant, le portefeuille de l’acheteur.
  2. Enregistrez la tentative de paiement. Associez le txid du paiement à la commande. Conservez séparément les hashs des transactions approve() en USDT.
  3. Vérifiez l’exécution et la finalité. Récupérez les données blockchain via une connexion RPC côté backend ou un service blockchain hébergé.
  4. Vérifiez les événements de paiement. Trouvez l’événement PaymentProcessed émis par le processeur approuvé. Pour l’USDT, vérifiez aussi le Transfer réel des jetons vers le marchand.
  5. Rapprochez la commande. Comparez l’adresse du marchand dans l’événement, l’acheteur (si requis) et le montant net aux données immuables de la commande dans le backend.
  6. Finalisez la commande une seule fois. Enregistrez de façon atomique le paiement vérifié et déclenchez une exécution idempotente.

Vous pouvez utiliser un fournisseur hébergé. Il n’est pas nécessaire d’exploiter votre propre nœud blockchain.

Événements de paiement et montants​

Les processeurs Volet.com documentés émettent PaymentProcessed après avoir versé le paiement au marchand :

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

Pour ces déploiements, le montant net reçu par le marchand se calcule ainsi :

receivedAmount = totalPay - commission

Les deux valeurs sont exprimées dans les plus petites unités de l’actif. Le calcul s’applique quelle que soit la valeur de buyerPaysCommission.

ActifPreuves on-chain requises
ETH, BNBExécution réussie et finalisée, et un log PaymentProcessed concordant émis par le processeur approuvé de l’actif natif
USDT ERC-20, BEP-20Log PaymentProcessed concordant du processeur et log Transfer USDT du contrat de jeton approuvé : processeur → marchand pour receivedAmount
TRXExécution réussie et solidifiée, et un log PaymentProcessed concordant émis par le processeur TRX approuvé
USDT TRC-20Log PaymentProcessed concordant du processeur et log Transfer USDT TRC-20 du contrat de jeton approuvé : processeur → marchand pour receivedAmount

Vérifiez toujours l’adresse qui a émis chaque événement. Un log portant le bon nom d’événement mais émis par un contrat sans rapport ne prouve pas le paiement. Les transferts directs de portefeuille à portefeuille sortent du cadre de ce parcours de paiement via le processeur.

D’après la documentation d’intégration des processeurs, les versements natifs sont annulés si le transfert vers le marchand échoue ; l’événement PaymentProcessed authentique d’une transaction réussie et finalisée sert donc de preuve du versement natif. Les vues des transferts internes dans les explorateurs peuvent aider à enquêter, mais la méthode de vérification décrite ici ne les exige pas.

Ces contrôles d’événements font foi pour les implémentations de processeurs déployées décrites ici. Validez l’ABI et les adresses des processeurs par rapport au déploiement que vous utilisez.

Exigences de finalité​

RéseauCondition de finalisation
EthereumTransaction dans un bloc finalisé ; reçu réussi et événements de paiement concordants
BNB Smart ChainLa transaction respecte la politique de finalité configurée et prise en charge par le fournisseur ; reçu réussi et événements de paiement concordants
TRONLa transaction est disponible auprès d’un SolidityNode, le reçu indique SUCCESS et les événements de paiement concordent

Les exemples pour développeurs utilisent 15 confirmations sur les réseaux EVM. Il s’agit d’une politique fondée sur le nombre de confirmations, et non de la finalité d’Ethereum. Les exemples ci-dessous conservent un contrôle de bloc finalisé ; utilisez un fournisseur qui prend en charge la sémantique de finalité requise pour votre réseau. S’il ne peut pas fournir cette information, laissez le paiement en examen au lieu de le considérer comme final.

Si un fournisseur est indisponible ou si un reçu n’apparaît pas encore, réessayez. Une réponse vide ne prouve pas l’échec d’un paiement.

Cycle de vie de la commande​

ÉtatSignificationExécuter ?
createdCommande enregistrée, aucun paiement envoyéNon
approval_pendingTransaction d’approbation USDT envoyéeNon
pendingtxid du paiement enregistré ; en attente d’inclusion ou de finalitéNon
verifyingExécution, finalité et événements en cours de vérificationNon
paidLe paiement vérifié correspond à la commandeOui, une fois
failedÉchec d’exécution confirmé ou échec de validation concluantNon
expiredDélai de paiement expiré ; les paiements envoyés doivent encore être rapprochésNon, jusqu’au rapprochement
reviewDonnées de vérification incomplètes, ambiguës ou contradictoiresNon

Une actualisation de la page, un délai de paiement expiré ou un onglet fermé ne doivent pas arrêter le processus de vérification du backend.

Enregistrez le paiement attendu​

Créez l’enregistrement du paiement avant de demander à l’acheteur d’envoyer la transaction. Exemple :

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 est le montant demandé de la commande. expectedSellerAmount est le montant net que le marchand doit recevoir, en plus petites unités. Déterminez-le et enregistrez-le à partir de la commande validée côté serveur et de la configuration de frais approuvée, en vous appuyant sur les résultats de previewPayment() du processeur. Ne le déduisez pas de données arbitraires du navigateur ni d’un pourcentage de frais codé en dur.

Pour l’USDT, utilisez 6 décimales sur Ethereum et TRON et 18 décimales sur BNB Smart Chain. ETH et BNB utilisent 18 décimales ; TRX en utilise 6 (SUN). N’utilisez jamais l’arithmétique à virgule flottante pour calculer des montants de jetons.

Conservez les adresses approuvées des réseaux, des jetons et des processeurs dans une liste d’autorisation côté backend. La fonction transfer() n’a pas de paramètre orderId : c’est donc à votre application de relier les tentatives de paiement aux commandes.

Utilisez des enregistrements de tentative de paiement uniques et faites en sorte qu’un événement de paiement on-chain ne puisse pas être attribué à plus d’une commande. Un événement est identifié par (chain, txid, logIndex). Rendez aussi l’exécution des commandes idempotente. Une nouvelle tentative du client doit créer une nouvelle tentative au lieu d’écraser un hash en attente antérieur.

Vérification sur Ethereum et BNB Smart Chain​

Utilisez ethers v6 côté backend. L’exemple suivant vérifie l’exécution et la finalité, puis contrôle un log PaymentProcessed authentique. Pour l’USDT, il vérifie en plus le transfert réel des jetons.

L’argument order doit contenir des valeurs fiables issues du backend. Transmettez le provider du même réseau 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' };
}

Utilisation du résultat : matched signifie que les preuves de paiement ont passé les contrôles de cette fonction. Avant de passer la commande à paid, réservez son eventId de façon unique dans votre base de données et finalisez la commande via une transaction idempotente côté serveur.

Le code rapproche volontairement les logs authentifiés du reçu au lieu de s’appuyer sur le calldata de la transaction. Une transaction directe vers le marchand ou un événement Transfer sans rapport ne comptent pas.

Pour les paiements en ETH et BNB natifs, l’événement PaymentProcessed authentifié du processeur constitue la preuve documentée du versement. Pour l’USDT, le Transfer supplémentaire doit être émis par le contrat de jeton approuvé et transférer les jetons du processeur vers le vendeur.

En production, configurez vos propres endpoints RPC fiables et gérez les erreurs du fournisseur et les nouvelles tentatives autour de cette fonction.

Vérification sur TRON​

Sur TRON, demandez un reçu de transaction solidifié via l’endpoint SolidityNode :

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

Le reçu contient les logs d’événements bruts. L’implémentation suivante les lit directement, sans dépendre de la disponibilité de la liste d’événements d’un indexeur ni de son format d’adresses. Elle utilise ethers v6 pour décoder les données d’événements compatibles EVM et tronweb pour normaliser les adresses 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 fonction vérifie le reçu d’exécution solidifié, l’événement PaymentProcessed authentique et, pour l’USDT, l’événement Transfer concordant du jeton. Un reçu solidifié absent signifie que le paiement est en attente, et non qu’il a échoué. En cas d’erreur ou d’informations d’événements incomplètes, laissez le paiement en examen et réessayez.

Utilisez les adresses approuvées des processeurs et des jetons sur le mainnet TRON indiquées dans la Référence des smart contracts. N’acceptez pas un autre réseau ni une autre adresse de processeur transmis par le navigateur.

Consultez TRON confirmation semantics pour en savoir plus sur la finalité SolidityNode.

Rapprocher les paiements des commandes et empêcher les rejeux​

La fonction transfer() n’inclut pas d’orderId. Il revient à votre backend de maintenir une association fiable entre commandes et paiements.

  • Lorsque la page de paiement envoie un paiement : enregistrez son txid dans la commande existante et consignez une tentative de paiement immuable.
  • Lors du traitement d’un événement de paiement : vérifiez que le vendeur, l’acheteur facultatif et le montant net de l’événement correspondent à la commande attendue.
  • Pour l’idempotence : utilisez une clé unique (chain, txid, logIndex) pour chaque événement de paiement. Empêchez un même événement de finaliser plusieurs commandes. N’exécutez pas une commande deux fois, même si le processus de vérification réessaie.
  • Pour les nouvelles tentatives et les paiements tardifs : conservez les tentatives de paiement précédentes et rapprochez les transactions finalisées après l’expiration du délai de paiement.

Plusieurs paiements peuvent partager le même vendeur et le même montant. Ne vous fiez pas uniquement au vendeur, au montant ou à une fenêtre temporelle, et ne modifiez pas les dernières décimales pour fabriquer des montants de facture uniques. Conservez une association directe entre transaction et commande partout où votre page de paiement envoie la transaction.

Nouvelles tentatives et erreurs d’exploitation​

SituationAction
Transaction pas encore minée ou solidifiéeLaissez la tentative en attente et réessayez
Délai RPC dépassé, 429 ou erreur réseau temporaireRéessayez avec un délai croissant ; n’en concluez pas que le paiement a échoué
Transaction d’approbation réussieEnregistrez l’approbation, puis attendez la transaction de paiement distincte
Transaction finalisée annuléeMarquez la tentative comme échouée ; n’exécutez pas la commande
Reçu réussi mais événement non concordantLaissez en examen ; vérifiez le processeur, le jeton, le vendeur et le montant attendu
Navigateur fermé ou délai de paiement expiréPoursuivez le rapprochement côté backend
Événement de paiement déjà réservé par une commandeNe l’attribuez pas à une autre commande

Un compteur de confirmations dans le navigateur, un badge de statut d’explorateur de blocs ou une capture d’écran du client ne suffisent pas pour finaliser une commande.

Vérification manuelle​

Utilisez Etherscan, BscScan ou TronScan pour enquêter sur un paiement. Contrôlez la transaction sur le bon réseau : exécution réussie, événement PaymentProcessed du processeur approuvé, adresse du marchand et montant net. Pour l’USDT, examinez aussi le Transfer authentique des jetons du processeur vers le marchand.

L’examen manuel peut aider les équipes de support à rapprocher les paiements, mais une page de paiement en production doit mettre en œuvre une vérification automatisée côté serveur.

Liste de contrôle avant la mise en production​

  • Adresses approuvées des processeurs et du jeton USDT sur le mainnet configurées côté serveur.
  • L’ABI de l’événement PaymentProcessed correspond aux contrats processeurs déployés.
  • Précision correcte des actifs : ETH/BNB 18, USDT sur Ethereum/TRON 6, USDT sur BSC 18, TRX 6.
  • Commandes et montants nets attendus pour le vendeur enregistrés avant l’envoi du paiement.
  • Hashs d’approbation des jetons conservés séparément des hashs de paiement.
  • Statut du reçu et finalité propre à chaque réseau vérifiés.
  • PaymentProcessed vérifié par rapport à l’adresse du processeur émetteur, au vendeur et au montant net attendu.
  • Transfer USDT vérifié par rapport à l’adresse du jeton émetteur, au processeur, au vendeur et au montant.
  • Portefeuille acheteur fixe vérifié lorsque la page de paiement l’exige.
  • Chaque événement réservé de façon unique ; l’exécution est idempotente.
  • Paiements échoués, en double, insuffisants et vers le mauvais destinataire testés.
  • Pannes RPC, logs manquants, confirmations tardives et délais de paiement expirés gérés.
  • Vérification testée sur des transactions réussies et échouées connues pour chaque actif pris en charge.