Proof binding
Introduction
Section titled “Introduction”A payment proof must be bound to the exact challenge that asked for it. Otherwise a stranger’s
payment — or your own earlier one — could be replayed to unlock a resource it never paid for.
PipRail solves this two ways, and every driver uses one of them. Both end at the same bar:
the transfer exists, succeeded, is recent, and moved at least the required amount of the right
asset to payTo.
The cornerstone, in every family: verify() re-derives every checked field from the trusted
accept the server issued, never from the client-supplied ref. A forged echo has nothing to
redirect.
Template A — memo/nonce-bound
Section titled “Template A — memo/nonce-bound”For families where you can read an account’s recent history (Stellar, XRPL, NEAR tokens,
Algorand, TON), the challenge nonce rides inside the signed transaction — a memo, note, or
comment — and verify() finds the one transaction on the merchant account that carries it.
The nonce is part of the challenge the server issued. It lives on accept.extra.nonce, while
the recipient is the top-level accept.payTo:
// X402AcceptEntry the server signs; the client commits accept.extra.nonce into the tx.accept.extra.nonce // the per-challenge nonce (under `extra`, not top-level)accept.payTo // re-derived here — the account verify() reads, not the client's refOn Stellar, the memo is sha256(nonce) as a MEMO_HASH; the verifier reads the merchant’s
recent transactions and matches it, then confirms the payment op:
// drivers/stellar/verify.ts — bind to THIS challenge by memo, then confirm the paymentconst tx = txs.find( (t) => t.memo_type === 'hash' && typeof t.memo === 'string' && wantMemo.includes(t.memo))if (!tx) return notFound(nonce)Because the nonce is committed inside a transaction the payer signed, a stranger’s payment can’t satisfy a different challenge — and an old payment can’t satisfy a new one. That binding, plus the gate’s single-use proof set, is the anti-replay guarantee.
Template B — digest-bound
Section titled “Template B — digest-bound”For families with a transaction hash you can look up directly, the proof is the tx hash/digest. There’s no nonce in the transaction; binding comes from reading that exact transaction and holding it to three gates: recipient + amount + asset, a recency window, and the gate’s single-use proof set. This covers EVM, Solana, Tron, Sui, Aptos — including their native coins — plus native NEAR.
On EVM, the verifier reads the receipt, checks confirmations, enforces the replay window, then
sums the ERC-20 Transfer logs to payTo:
// drivers/evm/verify.ts — `payTo` and `asset` come from accept, not the client refconst transferred = sumTransfersTo(receipt.logs, asset, payTo)if (transferred.total < requiredAmount) { return { ok: false, error: 'transfer_not_found', detail: `No ERC-20 Transfer to ${payTo}.` }}The recency window is what stops a replay of an old, genuine payment to the same address.
maxTimeoutSeconds is top-level on the trusted accept:
const ageSeconds = Math.floor(Date.now() / 1000) - Number(block.timestamp)if (ageSeconds > accept.maxTimeoutSeconds) { return { ok: false, error: 'payment_expired', detail: `Payment is ${ageSeconds}s old.` }}Re-using the same tx hash twice is caught one level up by the payment gate’s used-proof set — see Replay protection.
Which template each family uses
Section titled “Which template each family uses”The native coin doesn’t always follow the chain’s token path: on Stellar, XRPL, Algorand, and TON, the nonce rides in a memo/note/comment for the native coin too, so those stay memo-bound. NEAR is the one family that uses both — its NEP-141 token path is memo-bound, while native NEAR is digest-bound (by tx hash).
| Family | Template | What binds the proof |
|---|---|---|
| EVM, Solana, Tron, Sui, Aptos | B — digest | tx hash + recipient + amount + recency window + single-use set (native coin included) |
| NEAR (native) | B — digest | tx hash + recipient + amount + recency |
| NEAR (tokens) | A — memo | memo in the ft_transfer event = nonce |
| Stellar | A — memo | MEMO_HASH = sha256(nonce) on the merchant account (XLM + issued assets) |
| XRPL | A — memo | nonce in a Memo, on a Payment to payTo (XRP + IOUs) |
| Algorand | A — memo | nonce in the transaction’s note field (native ALGO + ASA alike) |
| TON | A — memo | nonce in the transfer comment (native TON + jettons) |
verify() always re-derives from the trusted accept
Section titled “verify() always re-derives from the trusted accept”This is the rule that makes binding hold. The verifier reads accept.payTo, accept.asset,
accept.amount, and accept.extra.nonce — all from the challenge the server signed — and
matches the on-chain transaction against those. The client’s ref only points at which
transaction to inspect; it can never change what is checked.
// Every driver: every checked field comes from `accept`, the trusted side.const required = BigInt(accept.amount) // base units, already scaled by decimalsconst payTo = accept.payTo // top-level on X402AcceptEntryconst nonce = accept.extra.nonce // under `extra` — Template A families bind to itA failed binding returns a typed VerifyErrorCode —
transfer_not_found, amount_too_low, wrong_recipient, or payment_expired — never a
throw. (A transient RPC read maps to tx_not_found instead, so a node hiccup never reads as a
forged payment.)
Per-family gotchas
Section titled “Per-family gotchas”A few families need a twist on the template. These are load-bearing details, not trivia.
TON settles asynchronously. Value moves across contracts, so there is no single tx hash that
“is” the payment. The proof ref is a self-contained locator (ton:<jetton-wallet>|<nonce>); the
verifier reads the merchant’s jetton wallet (derived from the official jetton master, so a
look-alike jetton can’t match) for the incoming internal_transfer carrying the nonce in its
forward comment. Native TON is read from the incoming message’s value + text comment to payTo
— same memo binding, no jetton wallet.
XRPL compares delivered_amount, not Amount. A successful Payment using tfPartialPayment
can advertise a large Amount but actually deliver almost nothing — a known theft vector. The
verifier compares against what was delivered:
// drivers/xrpl/verify.ts — partial-payment guardconst paid = deliveredBaseUnits(tx.delivered_amount, wantAsset, accept.extra.decimals)if (paid < required) { return { ok: false, error: 'amount_too_low', detail: `Delivered ${paid}, required ${required}.` }}Tron verifies on the solidity (confirmed) node. An unconfirmed or unknown tx reads as
not-found, so finality is the gate — getTransactionInfo only returns once the tx has
solidified.
NEAR has no clean account-history RPC, so it verifies by tx hash and scans the trusted token
contract’s transfer logs. Only logs emitted by a receipt whose executor is the trusted token
contract (accept.asset) count — a look-alike contract emitting a fake EVENT_JSON can’t
satisfy the check — and the memo must equal the freshly-issued nonce.
// drivers/near/verify.ts — provenance + nonce bindingfor (const r of tx.receipts) { if (r.executorId !== accept.asset) continue // trusted contract only // …match new_owner_id === accept.payTo && memo === nonce}