Live mainnet smoke test
Introduction
Section titled “Introduction”Unit and contract tests prove the protocol; a live smoke test proves the chain. It runs the
whole loop against a real network — a merchant gate issues a 402, a PipRailClient
pays on-chain, the gate verifies the proof, and the request returns 200 — then submits the same
proof again and confirms the gate rejects it. If that passes on mainnet with real money, the
driver is correct.
Keep the amount tiny ('0.001') and use a wallet you fund on purpose. This is the same kind of
round-trip local-verification does without a network; here the
chain is real.
The round-trip you’re proving
Section titled “The round-trip you’re proving”Stand up a real gate, point a client at it, and pay. The gate verifies the on-chain transfer against its own RPC before the handler runs — nothing is mocked.
import express from 'express'import { requirePayment, PipRailClient } from '@piprail/sdk'
const app = express()app.get( '/report', requirePayment({ chain: 'base', token: 'USDC', amount: '0.001', payTo: '0xYourWallet' }), (_req, res) => res.json({ unlocked: true }),)const server = app.listen(0) // throwaway localhost gate, real chain underneathconst port = (server.address() as import('node:net').AddressInfo).portimport { PipRailError } from '@piprail/sdk'
const client = new PipRailClient({ chain: 'base', wallet: { privateKey: process.env.AGENT_KEY! }, policy: { maxAmount: '0.01', tokens: ['USDC'] },})
try { const res = await client.fetch(`http://127.0.0.1:${port}/report`) console.log(res.status) // 200 — paid, verified, unlocked console.log(client.spent().count) // 1 — settled exactly once} catch (err) { // Real money is in flight, so handle a failed settlement explicitly rather than crashing. if (err instanceof PipRailError) { console.error(`payment failed [${err.code}]: ${err.message}`) // e.g. PAYMENT_DECLINED · INSUFFICIENT_FUNDS · RECIPIENT_NOT_READY · PAYMENT_TIMEOUT } else { throw err }}The policy cap is your seatbelt: even pointed at a real network, the client refuses anything
over maxAmount before any send. See spend policy for the
full set of caps.
Then prove the replay reject
Section titled “Then prove the replay reject”A correct gate redeems a proof once. Re-submitting the same settled proof must come back
tx_already_used (the in-memory used-proof set defeats double-spend). A client.fetch
round-trip never hands the merchant the raw proof header, so drive the gate directly: take an
accepted rail from gate.challenge(), frame the proof yourself with buildSignatureHeader,
and verify it twice.
import { createPaymentGate, buildSignatureHeader } from '@piprail/sdk'
const gate = createPaymentGate({ chain: 'base', token: 'USDC', amount: '0.001', payTo: '0xYourWallet' })
// The rail the proof claims — taken from a fresh challenge, so nonce + asset already match.const { challenge } = await gate.challenge('https://api.example.com/report')const accepted = challenge.accepts.find((a) => a.scheme === 'onchain-proof')!
// `txHash` is the proof ref you got back after paying on-chain (the settled tx hash).const txHash = '0xYourSettledTxHash'const proofHeader = buildSignatureHeader({ x402Version: 2, accepted, payload: { nonce: accepted.extra.nonce, txHash },})
const first = await gate.verify(proofHeader)console.log(first.kind) // → 'paid' (verified, settled once)
const second = await gate.verify(proofHeader)console.log(second.kind, second.kind === 'invalid' && second.error)// → 'invalid' 'tx_already_used' (the used-proof set defeats the replay)Use a funded wallet
Section titled “Use a funded wallet”The payer needs the payment token and a little of the chain’s native coin for gas — they’re separate balances. Fund a dedicated test wallet with tiny amounts before you run; never put a production key on the open internet.
const url = 'https://api.example.com/report'const plan = await client.planPayment(url)
if (!plan) { console.log('not payment-gated — nothing to fund')} else if (!plan.payable) { console.log(plan.fundingHint) // → "Have the USDC, but need ~0.000021 ETH for gas on base (have 0)."}planPayment() is the pre-flight: it returns null when the
URL isn’t payment-gated (so null-guard it), and otherwise reads your balances, the gas, and
recipient readiness, telling you exactly what to top up — so you fund the wallet once instead of
discovering a shortfall mid-test. On chains with a receive prerequisite (a Stellar/XRPL
trustline, an Algorand ASA opt-in, a NEAR storage_deposit) the payTo account must be set up
too, or verification has nothing to find. See Why payments fail.
Per-template gotchas
Section titled “Per-template gotchas”The two proof-binding templates fail differently, so check the right thing:
| Template | Chains | What to confirm |
|---|---|---|
| B — digest-bound | EVM, Solana, Tron, Sui, Aptos | The proof is the tx hash; verify reads the confirmed transfer + recency window + single-use set. |
| A — memo/nonce-bound | Stellar, XRPL, TON, NEAR, Algorand | The challenge nonce rides in the memo/note/comment, matched on the merchant’s own account. |
A few rails settle slowly or read on a confirmed node — give maxTimeoutSeconds headroom and
expect a wait on TON (asynchronous settlement) and Tron (verified on the solidity node).
A reference harness — the Anvil end-to-end
Section titled “A reference harness — the Anvil end-to-end”For EVM you don’t need mainnet at all: the examples/sdk-sandbox harness runs the same accept ↔
pay round-trip — USDC and the native coin — against a local Anvil fork of Base with fake
funds, and asserts the second redemption is tx_already_used. It forks via a public RPC, deals
itself fake USDC by writing contract storage, and skips cleanly if Anvil isn’t installed — so
it’s a regression gate that costs nothing.
npm run build:sdkcd examples/sdk-sandboxnode run-all.mjs # suite 05 is the live on-chain round-tripUse the fork to prove the shape of the loop, and a funded mainnet wallet to prove the chain. For deterministic, offline tests of your own integration code, stub the driver instead — see Mocking.