Settle invoices on Stable using ERC-3009 with deterministic nonces derived from invoice metadata. Reconcile through on-chain events.
This guide walks through settling an invoice on-chain using ERC-3009 with a deterministic nonce derived from invoice metadata. The nonce links each payment to its invoice and prevents double payment.
Concept: For the invoice settlement model and comparison to traditional B2B invoicing, see Invoice settlement.
A full invoice lifecycle: the buyer signs an ERC-3009 authorization off-chain, the vendor submits it on-chain, and reconciliation matches the resulting AuthorizationUsed event back to the invoice by deterministic nonce.
The buyer submits the transferWithAuthorization transaction directly and pays gas. Use this when the buyer controls when and how the payment is executed, for example when the buyer’s accounting system needs the tx hash tied to an internal approval flow.
// pay.tsimport { ethers } from "ethers";import { provider, USDT0_ADDRESS } from "./config";const buyerWallet = new ethers.Wallet(process.env.BUYER_KEY!, provider);const usdt0 = new ethers.Contract( USDT0_ADDRESS, [ "function transferWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s)", ], buyerWallet,);async function payInvoice( authorization: { from: string; to: string; value: bigint; validAfter: number; validBefore: number; nonce: string }, signature: string,) { const { v, r, s } = ethers.Signature.from(signature); const tx = await usdt0.transferWithAuthorization( authorization.from, authorization.to, authorization.value, authorization.validAfter, authorization.validBefore, authorization.nonce, v, r, s, ); const receipt = await tx.wait(1); console.log("Invoice paid, tx:", receipt.hash); // The nonce is now consumed; the same invoice cannot be paid twice. return { txHash: receipt.hash, blockNumber: receipt.blockNumber };}
The buyer sends {authorization, signature} to the vendor through API, email, or any channel. The vendor (or a facilitator) submits the transaction on the buyer’s behalf, so the buyer does not need to manage gas. Use this when the vendor needs synchronous confirmation within the same request flow.
Regardless of who submitted the transaction, every invoice payment emits an AuthorizationUsed event carrying the deterministic nonce. The vendor listens for this event and matches it to a pending invoice by nonce. Because the nonce is derived from invoice metadata, matching is exact.
Matching by nonce identifies which invoice was paid, but the vendor should also verify the Transfer event in the same transaction to confirm that the correct amount was sent to the correct recipient. The code below includes this verification.
// reconcile.tsimport { ethers } from "ethers";import { provider, USDT0_ADDRESS, Invoice } from "./config";import { getInvoiceNonce } from "./nonce";const usdt0 = new ethers.Contract( USDT0_ADDRESS, [ "event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)", "event Transfer(address indexed from, address indexed to, uint256 value)", ], provider,);// Build a lookup map: nonce -> invoice.// In production, this comes from your invoice database.const invoices: Invoice[] = [ { number: "INV-2026-001234", vendor: "0xVendorAddress", buyer: "0xBuyerAddress", amount: ethers.parseUnits("5000", 6), dueDate: Math.floor(new Date("2026-04-30").getTime() / 1000), },];const nonceToInvoice = new Map<string, Invoice>();for (const inv of invoices) { nonceToInvoice.set(getInvoiceNonce(inv), inv);}usdt0.on("AuthorizationUsed", async (authorizer: string, nonce: string, event: any) => { const invoice = nonceToInvoice.get(nonce); if (!invoice) return; // not one of our invoices const receipt = await event.getTransactionReceipt(); const transferLog = receipt.logs .map((log: any) => { try { return usdt0.interface.parseLog(log); } catch { return null; } }) .find( (parsed: any) => parsed?.name === "Transfer" && parsed.args[0].toLowerCase() === invoice.buyer.toLowerCase() && parsed.args[1].toLowerCase() === invoice.vendor.toLowerCase() && parsed.args[2] === invoice.amount ); if (!transferLog) { console.error("No matching Transfer event for invoice:", invoice.number); return; } // All checks passed console.log(`Invoice ${invoice.number} PAID`); console.log(" tx:", receipt.hash); console.log(" settled at block:", receipt.blockNumber); // In production: update your ERP/accounting system here // erpSystem.markPaid(invoice.number, receipt.hash, receipt.blockNumber);});console.log("Listening for invoice settlements...");
npx tsx reconcile.ts
Listening for invoice settlements...Invoice INV-2026-001234 PAID tx: 0x8f3a...2d41 settled at block: 1284371
A submitted transferWithAuthorization can revert for several reasons. Detect and surface each one to the vendor or buyer so the invoice can be retried or closed.
Revert reason
Cause
Recovery
FiatTokenV2: invalid signature
Signature doesn’t match the authorization fields.
Ask buyer to re-sign with unchanged invoice data.
FiatTokenV2: authorization is used or canceled
Nonce was already consumed (double-submission) or the buyer cancelled it.
Mark the invoice as already-paid; look up the original tx by nonce.
FiatTokenV2: authorization is not yet valid
Submitted before validAfter.
Wait until validAfter or issue a new authorization.
FiatTokenV2: authorization is expired
Submitted after validBefore.
Issue a new authorization with an extended window.
FiatTokenV2: transfer amount exceeds balance
Buyer’s USDT0 balance is insufficient.
Notify buyer to fund their wallet, then retry the same signature.
Catch reverts and classify them before retrying.
// retry.tsimport { ethers } from "ethers";async function submitWithRetry( submit: () => Promise<ethers.ContractTransactionResponse>,): Promise<string> { try { const tx = await submit(); const receipt = await tx.wait(1); return receipt!.hash; } catch (err: any) { const reason = err?.info?.error?.message || err?.reason || err?.message || ""; if (reason.includes("authorization is used or canceled")) { // Lookup the original tx by AuthorizationUsed event; mark invoice paid. throw new Error("ALREADY_PAID"); } if (reason.includes("authorization is expired")) { throw new Error("AUTHORIZATION_EXPIRED"); } if (reason.includes("invalid signature")) { throw new Error("INVALID_SIGNATURE"); } if (reason.includes("transfer amount exceeds balance")) { throw new Error("INSUFFICIENT_BALANCE"); } throw err; }}
Never retry a failed submission without classifying the error. Blind retries on a reverted transferWithAuthorization can pass validation after the buyer tops up their balance, which may not match the buyer’s latest intent.