> ## Documentation Index
> Fetch the complete documentation index at: https://docs.reinskit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Reconcile a purchase before paying again

> Run a synthetic purchase-recovery example that retains uncertain payment state across checkpoints and retries delivery without a second payment.

Reins' offline purchase-recovery example separates payment state from resource delivery. You can restore a synthetic uncertain attempt, see a second payment attempt refused, then retry failed delivery using the same retained record. It runs locally without a wallet, HTTP request or ledger connection.

> [!NOTE]
>
> This is a synthetic orchestration exercise, not an x402 adapter, transaction builder, signer or receipt verifier. It spends no XRP and adds no ledger enforcement. All 11 example tests, the demo and strict TypeScript checks passed. The separate product suite has 154 passing tests and the build passed. Internal agent review is not an external security assessment.

## Run the copyable example

In a fresh local directory, create this `package.json`. Use Node.js 22.12 or later:

```json theme={"dark"}
{"private": true, "type": "module"}
```

Install the runner and test tool versions used for this example:

```bash theme={"dark"}
npm install --save-dev tsx@4.23.15 vitest@5.0.5
```

Copy the three complete files at the end of this page into `purchase.ts`, `demo.ts` and `purchase.test.ts`, in the same directory. Then run:

```bash theme={"dark"}
node --import tsx demo.ts
npx vitest run purchase.test.ts
```

The demo writes a synthetic JSON checkpoint into a new temporary directory, restores it and removes that directory afterwards. Its output is:

```text theme={"dark"}
SYNTHETIC OFFLINE EXAMPLE. No payment or receipt has been verified.
After checkpoint restore: submission-unknown
Second payment blocked: Payment already attempted; reconcile the retained hash
After failed delivery: paid-delivery-failed
Delivery retried using retained synthetic receipt: delivered
Recorded payment attempts: 1
```

If you already have the updated source checkout, run `node --import tsx examples/x402-offline/demo.ts` and `npx vitest run examples/x402-offline/purchase.test.ts`. The regular product `npm run test:unit` command selects `packages/`, so the example tests must be run explicitly.

## Read the state

The strict local quote binds identifier, resource, GET method, facilitator label, network, XRP asset, destination, integer drops, SourceTag and expiry. It is not an x402 wire-format parser. Fixture labels are not XRPL addresses. The example uses `xrpl:1` for testnet and numeric SourceTag `58900000`. Its clock uses arbitrary integer seconds, not ledger sequences or protocol timestamps.

| State | Meaning in this exercise |
| - | - |
| `ready` | Approved fixture fields match; no attempt is recorded. |
| `submission-unknown` | One synthetic transaction hash is retained; reconcile it before proceeding. |
| `paid` | The caller supplied a definitive synthetic paid outcome for that same hash. |
| `paid-delivery-failed` | Payment remains paid even though fixture delivery failed. |
| `delivered` | Delivery succeeded without increasing the recorded attempt count. |
| `not-paid` | A definitive synthetic unpaid outcome ends this record; it does not reopen payment. |

`beginSubmission` records uncertainty before any hypothetical transport. A real caller would have to persist the checkpoint before sending anything. `reconcile` accepts only the retained hash and a definitive fixture outcome; it does not query or verify the ledger. A timeout is not a definitive unpaid result. `recordDelivery` changes delivery state only and cannot start payment.

## Keep the limitation visible

The at-most-one-attempt property applies only to the same retained and restored record. New records, older ready checkpoints, concurrent processes, tampering or lost files can bypass it. The checkpoint hash checks consistency, not authenticity. This exercise does not implement atomic persistence, fsync, durable journaling, uniqueness locks, trusted clocks or actual submission reconciliation.

Synthetic receipts are deliberately labelled unverified. Delivery retry does not ensure merchant delivery, correctness or refunds. These are toolkit orchestration rules, not ledger guarantees or independent co-signer enforcement. Do not attach a wallet to this fixture and treat it as a payment engine.

## Resolve protocol compatibility first

On November 11, 2026, the x402 Foundation's [XRPL exact-scheme specification](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_xrpl.md#9-safety-checks-must) requires rejecting a transaction containing `Delegate`. That conflicts with Reins' current delegated allowance payment topology. This is a specification-level compatibility gate, not a claim that XRPL itself rejects delegation.

[T54's v2 exact-scheme documentation](https://xrpl-x402.t54.ai/docs/xrpl-scheme) describes facilitator submission of presigned Payments. Its hosted acceptance of a custom delegated Payment remains unverified. Inspection of the checksum-verified [Python SDK 0.3.4 source archive](https://files.pythonhosted.org/packages/1d/f5/a39b1d68bf4bd5f6e03f60c2bf6a5e7359ad1cbbfc44ac59ca5757a6e343/x402_xrpl-0.3.4.tar.gz) default payer builder found no delegation parameter; that does not establish hosted verifier behavior or behavior of other versions.

This example therefore stays independent of any transport, SDK or facilitator. Do not remove `Delegate` or change signing topology simply to pass a facilitator check. A future real adapter needs a pinned protocol and implementation, compatible authority model, trusted settlement evidence and a separately reviewed durable recovery design. The example is available here as source; it is not a new npm release. See the [exposure inspector](https://docs.reinskit.com/guides/inspect-exposure.md) for the current published read-only CLI.

## Copy the complete files

The example follows the project's Apache-2.0 license. Save each block with the indicated filename.

> [accordion] purchase.ts: strict local lifecycle
>
> ```typescript theme={"dark"}
> /** Synthetic offline lifecycle. No HTTP, XRPL, signatures or receipt verification. */
> import { createHash } from 'node:crypto'
>
> export interface Quote {
>   id: string
>   resource: string
>   method: 'GET'
>   facilitator: string
>   network: 'xrpl:1'
>   asset: 'XRP'
>   destination: string
>   amountDrops: string
>   sourceTag: number
>   expiresAt: number
> }
> export type Status = 'ready' | 'submission-unknown' | 'paid' | 'paid-delivery-failed' | 'delivered' | 'not-paid'
> export interface PurchaseState {
>   version: 1
>   quote: Quote
>   quoteHash: string
>   status: Status
>   paymentAttempts: 0 | 1
>   syntheticTxHash?: string
>   syntheticReceipt?: string
> }
> const quoteKeys = ['id', 'resource', 'method', 'facilitator', 'network', 'asset', 'destination', 'amountDrops', 'sourceTag', 'expiresAt'] as const
> const statuses: readonly Status[] = ['ready', 'submission-unknown', 'paid', 'paid-delivery-failed', 'delivered', 'not-paid']
> const digest = (text: string) => createHash('sha256').update(text).digest('hex')
> function record(value: unknown): Record<string, unknown> {
>   if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected an object')
>   return value as Record<string, unknown>
> }
> function strictKeys(value: Record<string, unknown>, allowed: readonly string[]) {
>   if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error('Unsupported field')
> }
> function validateQuote(value: unknown): Quote {
>   const q = record(value)
>   strictKeys(q, quoteKeys)
>   for (const key of ['id', 'resource', 'facilitator', 'destination']) {
>     if (typeof q[key] !== 'string' || !q[key] || q[key] !== q[key].trim()) throw new Error(`Invalid ${key}`)
>   }
>   if (q.method !== 'GET' || q.network !== 'xrpl:1' || q.asset !== 'XRP') throw new Error('Unsupported method, network or asset')
>   if (typeof q.amountDrops !== 'string' || !/^[1-9]\d*$/.test(q.amountDrops)) throw new Error('Expected positive integer drops')
>   if (q.sourceTag !== 58900000) throw new Error('Expected numeric SourceTag 58900000')
>   if (!Number.isSafeInteger(q.expiresAt) || Number(q.expiresAt) <= 0) throw new Error('Invalid expiry')
>   return Object.fromEntries(quoteKeys.map(key => [key, q[key]])) as unknown as Quote
> }
> function quoteHash(quote: Quote): string {
>   return digest(JSON.stringify(quoteKeys.map(key => [key, quote[key]])))
> }
> function assertTime(now: number, expiry: number) {
>   if (!Number.isSafeInteger(now) || now < 0) throw new Error('Invalid fixture time')
>   if (now >= expiry) throw new Error('Quote expired')
> }
> function txHash(value: unknown): string {
>   if (typeof value !== 'string' || !/^[A-Fa-f0-9]{64}$/.test(value)) throw new Error('Invalid synthetic transaction hash')
>   return value.toUpperCase()
> }
> const receipt = (hash: string, quote: string) => `SYNTHETIC-NOT-VERIFIED:${digest(hash + quote)}`
>
> export class Purchase {
>   #state: PurchaseState
>   private constructor(state: PurchaseState) { this.#state = structuredClone(state) }
>
>   static create(untrusted: unknown, approved: unknown, now: number): Purchase {
>     const quote = validateQuote(untrusted)
>     const approval = validateQuote(approved)
>     if (quoteHash(quote) !== quoteHash(approval)) throw new Error('Quote changed from approved fields')
>     assertTime(now, quote.expiresAt)
>     return new Purchase({ version: 1, quote, quoteHash: quoteHash(quote), status: 'ready', paymentAttempts: 0 })
>   }
>   static restore(json: string): Purchase {
>     const s = record(JSON.parse(json))
>     strictKeys(s, ['version', 'quote', 'quoteHash', 'status', 'paymentAttempts', 'syntheticTxHash', 'syntheticReceipt'])
>     const quote = validateQuote(s.quote)
>     const hash = quoteHash(quote)
>     if (s.version !== 1 || s.quoteHash !== hash || !statuses.includes(s.status as Status)) throw new Error('Invalid checkpoint')
>     const isReady = s.status === 'ready'
>     if (s.paymentAttempts !== (isReady ? 0 : 1)) throw new Error('Inconsistent checkpoint attempts')
>     if (isReady && (s.syntheticTxHash !== undefined || s.syntheticReceipt !== undefined)) throw new Error('Inconsistent ready checkpoint')
>     const paid = ['paid', 'paid-delivery-failed', 'delivered'].includes(String(s.status))
>     if (!isReady) {
>       const tx = txHash(s.syntheticTxHash)
>       if (paid ? s.syntheticReceipt !== receipt(tx, hash) : s.syntheticReceipt !== undefined) throw new Error('Inconsistent checkpoint receipt')
>       s.syntheticTxHash = tx
>     }
>     return new Purchase({ ...s, quote } as unknown as PurchaseState)
>   }
>   get state(): PurchaseState { return structuredClone(this.#state) }
>   checkpoint(): string { return JSON.stringify(this.#state, null, 2) }
>
>   /** Record uncertainty first; caller must save this checkpoint BEFORE hypothetical submission. */
>   beginSubmission(currentQuote: unknown, syntheticTxHash: string, now: number): void {
>     if (this.#state.status !== 'ready') throw new Error('Payment already attempted; reconcile the retained hash')
>     const quote = validateQuote(currentQuote)
>     if (quoteHash(quote) !== this.#state.quoteHash) throw new Error('Quote changed after preparation')
>     assertTime(now, quote.expiresAt)
>     const hash = txHash(syntheticTxHash)
>     this.#state = { ...this.#state, status: 'submission-unknown', paymentAttempts: 1, syntheticTxHash: hash }
>   }
>   /** Fixture outcome only, not ledger proof. Unknown/timeouts must not call this method. */
>   reconcile(syntheticTxHash: string, outcome: 'paid' | 'not-paid'): void {
>     if (this.#state.status !== 'submission-unknown') throw new Error('No uncertain submission to reconcile')
>     if (txHash(syntheticTxHash) !== this.#state.syntheticTxHash) throw new Error('Reconcile must retain the same transaction hash')
>     if (outcome !== 'paid' && outcome !== 'not-paid') throw new Error('Unknown outcome remains uncertain')
>     this.#state.status = outcome
>     if (outcome === 'paid') this.#state.syntheticReceipt = receipt(this.#state.syntheticTxHash, this.#state.quoteHash)
>   }
>   /** Delivery retries retain the same paid record and receipt. They never initiate payment. */
>   recordDelivery(success: boolean): void {
>     if (!['paid', 'paid-delivery-failed'].includes(this.#state.status)) throw new Error('A paid purchase is required for delivery')
>     if (typeof success !== 'boolean') throw new Error('Expected fixture delivery outcome')
>     this.#state.status = success ? 'delivered' : 'paid-delivery-failed'
>   }
> }
> export const createPurchase = (untrusted: unknown, approved: unknown, now: number) => Purchase.create(untrusted, approved, now)
> export const restorePurchase = (json: string) => Purchase.restore(json)
> ```

> [accordion] demo.ts: synthetic checkpoint and delivery retry
>
> ```typescript theme={"dark"}
> /** All inputs/outcomes are synthetic. This file never makes a payment or HTTP request. */
> import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
> import { tmpdir } from 'node:os'
> import { join } from 'node:path'
> import { createPurchase, restorePurchase, type Quote } from './purchase.js'
>
> const quote: Quote = {
>   id: 'fixture-invoice-1', resource: 'fixture://weather', method: 'GET',
>   facilitator: 'fixture-facilitator', network: 'xrpl:1', asset: 'XRP',
>   destination: 'fixture-merchant-not-an-XRPL-address', amountDrops: '1000',
>   sourceTag: 58900000, expiresAt: 200,
> }
> const directory = await mkdtemp(join(tmpdir(), 'reins-offline-purchase-'))
> const path = join(directory, 'synthetic-checkpoint.json')
> try {
>   const purchase = createPurchase(quote, quote, 100)
>   purchase.beginSubmission(quote, 'A'.repeat(64), 101)
>   // Save BEFORE hypothetical transport. There is deliberately no transport implementation here.
>   await writeFile(path, purchase.checkpoint(), { mode: 0o600 })
>   const restored = restorePurchase(await readFile(path, 'utf8'))
>   console.log('SYNTHETIC OFFLINE EXAMPLE. No payment or receipt has been verified.')
>   console.log(`After checkpoint restore: ${restored.state.status}`)
>   try { restored.beginSubmission(quote, 'B'.repeat(64), 102) }
>   catch (error) { console.log(`Second payment blocked: ${(error as Error).message}`) }
>   restored.reconcile('A'.repeat(64), 'paid') // Fixture assertion, not a network query.
>   restored.recordDelivery(false)
>   await writeFile(path, restored.checkpoint(), { mode: 0o600 })
>   const deliveryRetry = restorePurchase(await readFile(path, 'utf8'))
>   console.log(`After failed delivery: ${deliveryRetry.state.status}`)
>   deliveryRetry.recordDelivery(true) // Fixture resource fetch, no HTTP request.
>   console.log(`Delivery retried using retained synthetic receipt: ${deliveryRetry.state.status}`)
>   console.log(`Recorded payment attempts: ${deliveryRetry.state.paymentAttempts}`)
> } finally {
>   await rm(directory, { recursive: true, force: true })
> }
> ```

> [accordion] purchase.test.ts: offline regression cases
>
> ```typescript theme={"dark"}
> import { describe, expect, it } from 'vitest'
> import { createPurchase, restorePurchase, type Quote } from './purchase.js'
> const quote: Quote = { id:'fixture-quote-1',method:'GET',facilitator:'fixture-facilitator',resource:'fixture://weather',network:'xrpl:1',asset:'XRP',destination:'fixture-merchant',amountDrops:'1000',sourceTag:58900000,expiresAt:200 }
> const tx = 'A'.repeat(64)
> describe('offline purchase lifecycle', () => {
>   it('binds every approved quote field', () => {
>     const mutations: Partial<Quote>[] = [{id:'other'},{method:'POST' as never},{facilitator:'other'},{resource:'fixture://other'},{network:'xrpl:0' as never},{asset:'RLUSD' as never},{destination:'other'},{amountDrops:'1001'},{sourceTag:123},{expiresAt:201}]
>     for (const change of mutations) expect(() => createPurchase({...quote,...change},quote,100)).toThrow()
>   })
>   it('rejects an unsupported network or asset even when both inputs agree', () => {
>     for (const change of [{network:'xrpl:0'}, {asset:'RLUSD'}]) {
>       const unsupported={...quote,...change}
>       expect(() => createPurchase(unsupported,unsupported,100)).toThrow(/Unsupported/)
>     }
>   })
>   it('requires exact integer drops and numeric user source tag', () => {
>     for (const amountDrops of ['-1','0','1.1','1e3','01000']) expect(() => createPurchase({...quote,amountDrops},{...quote,amountDrops},100)).toThrow()
>     expect(() => createPurchase({...quote,sourceTag:'58900000' as never},quote,100)).toThrow()
>   })
>   it('rejects expired quote before preparing and before submission', () => {
>     expect(() => createPurchase(quote,quote,200)).toThrow(/expired/)
>     const p = createPurchase(quote,quote,100)
>     expect(() => p.beginSubmission(quote,tx,200)).toThrow(/expired/)
>   })
>   it('blocks changed quote at submission without recording an attempt', () => {
>     const p=createPurchase(quote,quote,100)
>     expect(() => p.beginSubmission({...quote,amountDrops:'2000'},tx,101)).toThrow(/changed/)
>     expect(p.state.status).toBe('ready')
>   })
>   it('restores uncertain submission without permitting a second payment', () => {
>     const p=createPurchase(quote,quote,100)
>     p.beginSubmission(quote,tx,101)
>     const restored=restorePurchase(p.checkpoint())
>     expect(restored.state.status).toBe('submission-unknown')
>     expect(() => restored.beginSubmission(quote,'B'.repeat(64),102)).toThrow(/already/)
>     expect(() => restored.reconcile('B'.repeat(64),'paid')).toThrow(/hash/)
>     expect(restored.state.paymentAttempts).toBe(1)
>   })
>   it('preserves paid status when delivery fails and retries delivery without repayment', () => {
>     const p=createPurchase(quote,quote,100)
>     p.beginSubmission(quote,tx,101); p.reconcile(tx,'paid'); p.recordDelivery(false)
>     const restored=restorePurchase(p.checkpoint())
>     expect(restored.state.status).toBe('paid-delivery-failed')
>     expect(() => restored.beginSubmission(quote,tx,102)).toThrow(/already/)
>     restored.recordDelivery(true)
>     expect(restored.state.status).toBe('delivered')
>     expect(restored.state.paymentAttempts).toBe(1)
>   })
>   it('makes definitive unpaid result terminal for this purchase', () => {
>     const p=createPurchase(quote,quote,100)
>     p.beginSubmission(quote,tx,101); p.reconcile(tx,'not-paid')
>     expect(() => p.beginSubmission(quote,tx,102)).toThrow(/already/)
>     expect(() => p.recordDelivery(true)).toThrow()
>   })
>   it('rejects inconsistent or modified checkpoint instead of restoring ready', () => {
>     const p=createPurchase(quote,quote,100); p.beginSubmission(quote,tx,101)
>     const state=JSON.parse(p.checkpoint())
>     expect(() => restorePurchase(JSON.stringify({...state,status:'ready'}))).toThrow()
>     expect(() => restorePurchase(JSON.stringify({...state,quote:{...quote,amountDrops:'2000'}}))).toThrow()
>     expect(() => restorePurchase(JSON.stringify({...state,paymentAttempts:0}))).toThrow()
>   })
>   it('rejects unrecognized quote fields and unknown reconciliation outcomes', () => {
>     expect(() => createPurchase({...quote,extra:'unexpected'},quote,100)).toThrow(/Unsupported/)
>     const p=createPurchase(quote,quote,100);p.beginSubmission(quote,tx,101)
>     expect(() => p.reconcile(tx,'timeout' as never)).toThrow(/uncertain/)
>     expect(p.state.status).toBe('submission-unknown')
>   })
>   it('returns defensive state copies', () => {
>     const p=createPurchase(quote,quote,100); const state=p.state
>     state.quote.amountDrops='2000'
>     expect(p.state.quote.amountDrops).toBe('1000')
>   })
> })
> ```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.