Skip to content

Mocking the driver

The whole point of the PaymentDriver architecture is that server.ts, client.ts, and x402.ts depend only on the PaymentDriver contract in drivers/types.ts — zero viem, zero @solana/web3.js. So to test that protocol layer you don’t need a chain, an RPC, or a wallet: you register a fake driver that returns whatever you want, and drive the gate or client against it.

This is exactly how PipRail’s own test suite works — register a fake ResolvedNetwork, stub globalThis.fetch, and assert the 402 flow without ever broadcasting. The driver SPI you use here is the same one documented on the Driver SPI reference.

registerDriver is a public export. A PaymentDriver is just { family, resolve() }, and resolve() hands back a ResolvedNetwork — so the minimal fake is one object whose methods return canned values:

import { registerDriver, type ResolvedNetwork } from '@piprail/sdk'
const fakeNet: ResolvedNetwork = {
family: 'stellar',
network: 'stellar:pubnet',
supports: (n) => n === 'stellar:pubnet',
resolveToken: () => ({ asset: 'native', decimals: 7, symbol: 'XLM' }),
describeAsset: () => ({ symbol: 'XLM', decimals: 7 }),
assertValidPayTo: () => undefined,
bindWallet: (w) => ({ _native: w }),
send: async () => 'fake-proof-ref',
confirm: async () => ({ height: '1' }),
estimateCost: async () => ({ feeSymbol: 'XLM', feeDecimals: 7, fee: '100', feeFormatted: '0.00001', basis: 'heuristic' }),
balanceOf: async () => ({ token: 100000000n, native: 100000000n }),
recipientReady: async () => ({ ready: 'n/a' }),
verify: async () => ({ ok: false, error: 'transfer_not_found', detail: '' }),
}
registerDriver({ family: 'stellar', resolve: () => fakeNet })

Registering a driver for a family pre-empts the lazy real one for that family — so naming chain: 'stellar' now resolves to your fake instead of auto-mounting @stellar/stellar-sdk. The client and gate never know the difference.

You only have to make the methods your test exercises return something sensible — the rest can stay trivial. These are the ones the protocol layer calls:

MethodReturnsUsed by
verify(ref, accept)a VerifyResult{ ok: true, receipt } or { ok: false, error, detail }the gate, on the paid retry
send(wallet, accept)the proof ref (tx hash / signature)the client, when it pays
confirm(ref, n){ height } once confirmedthe client, after send
resolveToken / describeAssetthe asset’s { asset, decimals, symbol }budget + symbol checks
balanceOf / recipientReady / estimateCostbalances, readiness, gasplanPayment()

verify returns a VerifyResult and never throws for a transient read — so to simulate a bad proof, return { ok: false, error: 'transfer_not_found', detail: '' }; to simulate a paid request, return { ok: true, receipt } (a full X402Receipt). See the VerifyErrorCode list for the error strings.

With the fake registered, a createPaymentGate on that family runs entirely in memory. The gate parses the proof header first, so you can’t just hand it an arbitrary string and expect kind: 'paid' — an unparseable ref re-issues a fresh challenge. Build a real proof header from the challenge’s own accepts[] nonce via buildSignatureHeader, and point the fake’s verify at ok: true:

import {
createPaymentGate,
registerDriver,
buildSignatureHeader,
type ResolvedNetwork,
type X402Receipt,
} from '@piprail/sdk'
import { expect } from 'vitest'
// A fake that ACCEPTS the proof — echo the gate's own accept into the receipt.
const okNet: ResolvedNetwork = {
...fakeNet,
verify: async (ref, accept): Promise<{ ok: true; receipt: X402Receipt }> => ({
ok: true,
receipt: {
scheme: 'onchain-proof',
success: true,
network: accept.network,
transaction: ref,
asset: accept.asset,
amount: accept.amount,
payer: 'GPAYER',
payTo: accept.payTo,
verifiedAt: new Date().toISOString(),
},
}),
}
registerDriver({ family: 'stellar', resolve: () => okNet })
const gate = createPaymentGate({ chain: 'stellar', token: 'native', amount: '0.10', payTo: 'G…' })
// no proof yet → a challenge
const first = await gate.verify(undefined)
expect(first.kind).toBe('challenge')
// build a REAL proof header from the challenge's onchain-proof accept
const { challenge } = await gate.challenge()
const accepted = challenge.accepts.find((a) => a.scheme === 'onchain-proof')!
const header = buildSignatureHeader({
x402Version: 2,
accepted,
payload: { nonce: accepted.extra.nonce, txHash: `0x${'a'.repeat(64)}` },
})
// fake verify returns ok:true → paid
const paid = await gate.verify(header)
expect(paid.kind).toBe('paid')
// → { kind: 'paid', receipt: { scheme: 'onchain-proof', success: true, … }, receiptHeader: '…' }

Because the protocol layer re-derives every checked field from the trusted accept, you can also point the fake at a hostile server response and assert the gate still refuses — the same proof-binding guarantees hold against a mock.

A fake method is an ordinary function, so wrap it in a spy to assert that a refusal happens before any broadcast — e.g. that the client never calls send when a payment policy declines:

import { vi } from 'vitest'
const send = vi.fn(async () => 'fake-proof-ref')
const net: ResolvedNetwork = { ...fakeNet, send }
registerDriver({ family: 'stellar', resolve: () => net })
// ... drive a client whose policy should reject this payment ...
expect(send).not.toHaveBeenCalled() // refusal beat the side effect

The client talks HTTP, so a full client test pairs the fake driver with a stubbed globalThis.fetch that returns a 402 carrying your accepts[], then a 200 on the retry. The fake driver covers the chain side; the fetch stub covers the wire side:

import { buildChallengeHeader, type X402Challenge } from '@piprail/sdk'
const challengeBody: X402Challenge = {
x402Version: 2,
resource: { url: 'https://api.example.com/report' },
accepts: [
{
scheme: 'onchain-proof',
network: 'stellar:pubnet',
amount: '1000000', // 0.10 at 7 decimals
asset: 'native',
payTo: 'G…',
maxTimeoutSeconds: 600,
extra: { nonce: 'n', decimals: 7, minConfirmations: 1, amountFormatted: '0.10', symbol: 'XLM' },
},
],
}
globalThis.fetch = vi.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify(challengeBody), {
status: 402,
headers: { 'payment-required': buildChallengeHeader(challengeBody) },
})
)
.mockResolvedValueOnce(new Response('{"ok":true}', { status: 200 }))

This is the pattern behind PipRail’s local verification tests. When you want to prove the real chain path end to end instead, drop the mock and run a live smoke test.

The same SPI is how you’d add a genuine new chain family rather than a fake one: implement every required ResolvedNetwork method for your chain, return it from a PaymentDriver.resolve(), and call registerDriver. The optional methods (payExact, discoverySigner, exactDomain, settleExactSelf) are EVM-only today and may be omitted. The full contract — every method’s error behaviour and which are optional — is the Driver SPI reference.