Skip to content

Live mainnet smoke test

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.

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 underneath
const port = (server.address() as import('node:net').AddressInfo).port
import { 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.

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)

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.

The two proof-binding templates fail differently, so check the right thing:

TemplateChainsWhat to confirm
B — digest-boundEVM, Solana, Tron, Sui, AptosThe proof is the tx hash; verify reads the confirmed transfer + recency window + single-use set.
A — memo/nonce-boundStellar, XRPL, TON, NEAR, AlgorandThe 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.

Terminal window
npm run build:sdk
cd examples/sdk-sandbox
node run-all.mjs # suite 05 is the live on-chain round-trip

Use 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.