From f0c448212decfe32edd0fe38785ef317c42ae864 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:31:26 +0300 Subject: [PATCH 01/11] fix(x402): canonicalize TS exact replay identity and fail closed on ambiguous broadcast Bind the exact adapter's replay reservation to a canonical payload identity (sha256 over the decoded message plus the pinned requirements) so a re-encoded transaction cannot dodge the reserve-before-settle claim. Keep the reservation when settlement throws, since a facilitator can raise after broadcast and its side-effect boundary is ambiguous; only release on definitively pre-broadcast verification failures enumerated in RELEASE_SAFE_SETTLE_REASONS. Add the reserve-capable replay-store module the adapters depend on, a shared short-TTL single-flight challenge blockhash cache, an oversized-header cap, and the upto same-channel route binding with documented TTL-boundary handling. The store production/durability policy and the demo-signer-on-mainnet boundary revalidation live in the config layer (shared TS leaf), so the adapters here require only a reserve-capable replayStore and fail closed when one is absent. --- .../src/__tests__/x402-adapter.test.ts | 701 ++++++++++++++++++ .../pay-kit/src/__tests__/x402-shared.test.ts | 280 +++++++ .../pay-kit/src/__tests__/x402.test.ts | 22 +- .../pay-kit/src/adapters/x402-shared.ts | 64 ++ .../pay-kit/src/adapters/x402-upto.ts | 86 ++- .../packages/pay-kit/src/adapters/x402.ts | 126 +++- typescript/packages/pay-kit/src/index.ts | 1 + .../packages/pay-kit/src/replay-store.ts | 64 ++ 8 files changed, 1302 insertions(+), 42 deletions(-) create mode 100644 typescript/packages/pay-kit/src/__tests__/x402-adapter.test.ts create mode 100644 typescript/packages/pay-kit/src/__tests__/x402-shared.test.ts create mode 100644 typescript/packages/pay-kit/src/replay-store.ts diff --git a/typescript/packages/pay-kit/src/__tests__/x402-adapter.test.ts b/typescript/packages/pay-kit/src/__tests__/x402-adapter.test.ts new file mode 100644 index 000000000..8377573dc --- /dev/null +++ b/typescript/packages/pay-kit/src/__tests__/x402-adapter.test.ts @@ -0,0 +1,701 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { InvalidProofError } from '../errors.js'; + +// --- Boundary mocks ------------------------------------------------------- +// The x402 exact adapter settles through an in-process @x402/core facilitator +// (which needs a live RPC + real signatures) and reads a recent blockhash from +// @solana/kit. Both are stubbed so the adapter's own wiring (challenge +// building, header parsing, verify/settle branching) is exercised offline. + +type VerifyResult = { + invalidMessage?: string; + invalidReason?: string; + isValid: boolean; + payer?: string; +}; +type SettleResult = { + errorMessage?: string; + errorReason?: string; + payer?: string; + success: boolean; + transaction?: string; +}; + +const facilitatorControl: { + settle: () => Promise; + verify: () => Promise; +} = { + settle: () => Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }), + verify: () => Promise.resolve({ isValid: true, payer: 'VerifyPayer' }), +}; + +const blockhashControl: { impl: () => Promise<{ context: { slot: bigint }; value: unknown }> } = { + impl: () => + Promise.resolve({ + context: { slot: 314n }, + value: { blockhash: 'BlockHash111', lastValidBlockHeight: 4242n }, + }), +}; + +// Counts every getLatestBlockhash().send() invocation, and an optional gate the +// test releases once all concurrent challenges are in flight, before a +// non-single-flight cache would already have fired N RPCs. +const rpcMeter: { calls: number; gate?: Promise; release?: () => void } = { calls: 0 }; + +const decodeControl: { impl: (header: string) => unknown } = { + impl: () => ({ accepted: { network: 'solana:test' }, payload: {} }), +}; + +vi.mock('@x402/core/facilitator', () => { + class FakeFacilitator { + register(): this { + return this; + } + verify(): Promise { + return facilitatorControl.verify(); + } + settle(): Promise { + return facilitatorControl.settle(); + } + } + return { x402Facilitator: FakeFacilitator }; +}); + +vi.mock('@x402/svm', async () => { + const actual = await vi.importActual('@x402/svm'); + return { ...actual, toFacilitatorSvmSigner: () => ({}) }; +}); + +vi.mock('@x402/svm/exact/facilitator', () => ({ ExactSvmScheme: class {} })); + +vi.mock('@x402/core/http', () => ({ + decodePaymentSignatureHeader: (header: string) => decodeControl.impl(header), + encodePaymentRequiredHeader: () => 'ENCODED_PAYMENT_REQUIRED', + encodePaymentResponseHeader: () => 'ENCODED_PAYMENT_RESPONSE', +})); + +vi.mock('@solana/kit', async () => { + const actual = await vi.importActual('@solana/kit'); + return { + ...actual, + createSolanaRpc: () => ({ + getLatestBlockhash: () => ({ + send: async () => { + rpcMeter.calls += 1; + if (rpcMeter.gate) await rpcMeter.gate; + return blockhashControl.impl(); + }, + }), + }), + }; +}); + +const { createX402ExactAdapter } = await import('../adapters/x402.js'); +const { configure } = await import('../config.js'); +const { Gate } = await import('../gate.js'); +const { usd } = await import('../price.js'); +const { gateDefaults } = await import('../pricing.js'); +const { createMemoryReplayStore } = await import('../replay-store.js'); + +async function setup() { + const config = await configure({ + mpp: { challengeBindingSecret: 'x402-adapter-secret' }, + network: 'solana_localnet', + replayStore: createMemoryReplayStore(), + }); + return { adapter: createX402ExactAdapter(config), config }; +} + +function paidRequest(header = 'PAYMENT_CRED'): Request { + return new Request('http://localhost/report', { headers: { 'x-payment': header } }); +} + +async function gateFor(amount = usd('0.10')) { + const config = await configure({ + mpp: { challengeBindingSecret: 'x402-adapter-secret' }, + network: 'solana_localnet', + }); + return Gate.create({ amount, name: 'report' }, gateDefaults(config)); +} + +describe('createX402ExactAdapter', () => { + beforeEach(() => { + facilitatorControl.verify = () => Promise.resolve({ isValid: true, payer: 'VerifyPayer' }); + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + blockhashControl.impl = () => + Promise.resolve({ + context: { slot: 314n }, + value: { blockhash: 'BlockHash111', lastValidBlockHeight: 4242n }, + }); + decodeControl.impl = () => ({ accepted: { network: 'solana:test' }, payload: {} }); + rpcMeter.calls = 0; + rpcMeter.gate = undefined; + rpcMeter.release = undefined; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('exposes protocol/scheme metadata', async () => { + const { adapter } = await setup(); + expect(adapter.protocol).toBe('x402'); + expect(adapter.scheme).toBe('exact'); + }); + + it('embeds a server-fetched blockhash in the challenge requirements', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + const headers = await adapter.challengeHeaders(gate, new Request('http://localhost/report')); + expect(headers['payment-required']).toBe('ENCODED_PAYMENT_REQUIRED'); + }); + + it('falls back to bare requirements when the blockhash fetch fails', async () => { + blockhashControl.impl = () => Promise.reject(new Error('rpc down')); + const { adapter } = await setup(); + const gate = await gateFor(); + // Still produces a challenge; the catch branch swallows the RPC error. + const headers = await adapter.challengeHeaders(gate, new Request('http://localhost/report')); + expect(headers['payment-required']).toBe('ENCODED_PAYMENT_REQUIRED'); + }); + + it('collapses a concurrent challenge burst to a single getLatestBlockhash RPC', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + + // Latch the RPC so every concurrent challenge enters challengeRequirements + // before the first fetch resolves; without single-flight this fires N RPCs. + rpcMeter.gate = new Promise(resolve => { + rpcMeter.release = resolve; + }); + const bursts = Array.from({ length: 20 }, () => + adapter.challengeHeaders(gate, new Request('http://localhost/report')), + ); + // Give the microtask queue a chance to line every request up at the gate. + await Promise.resolve(); + rpcMeter.release?.(); + await Promise.all(bursts); + + expect(rpcMeter.calls).toBe(1); + }); + + it('reuses the cached blockhash for a second challenge within the TTL (no extra RPC)', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + await adapter.challengeHeaders(gate, new Request('http://localhost/report')); + await adapter.challengeHeaders(gate, new Request('http://localhost/report')); + expect(rpcMeter.calls).toBe(1); + }); + + it('rejects a request with no payment header', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, new Request('http://localhost/report'))).rejects.toMatchObject({ + code: 'missing_x402_payment_header', + }); + }); + + it('rejects an undecodable payment header', async () => { + decodeControl.impl = () => { + throw new Error('bad base64'); + }; + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, paidRequest())).rejects.toMatchObject({ + code: 'invalid_x402_payment_header', + }); + }); + + it('rejects when verification fails, surfacing the invalid reason', async () => { + facilitatorControl.verify = () => + Promise.resolve({ invalidMessage: 'nope', invalidReason: 'signature_consumed', isValid: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, paidRequest())).rejects.toMatchObject({ + code: 'signature_consumed', + }); + }); + + it('defaults the invalid reason when verification omits one', async () => { + facilitatorControl.verify = () => Promise.resolve({ isValid: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, paidRequest())).rejects.toMatchObject({ code: 'invalid_proof' }); + }); + + it('rejects when settlement fails, surfacing the error reason', async () => { + facilitatorControl.settle = () => + Promise.resolve({ errorMessage: 'rpc down', errorReason: 'settlement_failed', success: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, paidRequest())).rejects.toMatchObject({ + code: 'settlement_failed', + }); + }); + + it('defaults the error reason when settlement omits one', async () => { + facilitatorControl.settle = () => Promise.resolve({ success: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, paidRequest())).rejects.toMatchObject({ + code: 'settlement_failed', + }); + }); + + it('settles a valid payment and returns the payment record', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + const payment = await adapter.verifyAndSettle(gate, paidRequest('CRED123')); + expect(payment).toMatchObject({ + gateName: 'report', + payer: 'SettlePayer', + protocol: 'x402', + raw: 'CRED123', + scheme: 'exact', + transaction: 'TxSig', + }); + expect(payment.settlementHeaders['x-payment-response']).toBe('ENCODED_PAYMENT_RESPONSE'); + }); + + it('falls back to the verify payer when settlement omits one', async () => { + facilitatorControl.settle = () => Promise.resolve({ success: true, transaction: 'TxSig' }); + const { adapter } = await setup(); + const gate = await gateFor(); + const payment = await adapter.verifyAndSettle(gate, paidRequest()); + expect(payment.payer).toBe('VerifyPayer'); + }); + + it('reads the credential from the payment-signature header', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + const request = new Request('http://localhost/report', { headers: { 'payment-signature': 'SIG' } }); + const payment = await adapter.verifyAndSettle(gate, request); + expect(payment.raw).toBe('SIG'); + }); + + it('surfaces an InvalidProofError instance on missing header', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + await expect(adapter.verifyAndSettle(gate, new Request('http://localhost/report'))).rejects.toBeInstanceOf( + InvalidProofError, + ); + }); + + // ── replay / in-flight dedup ──────────────────────────────────────────── + + it('rejects a sequential replay of the same payment payload', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + + const first = await adapter.verifyAndSettle(gate, paidRequest('REPLAY_CRED')); + expect(first.transaction).toBe('TxSig'); + + await expect(adapter.verifyAndSettle(gate, paidRequest('REPLAY_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('rejects a concurrent duplicate payload while the first settles', async () => { + let settleCalls = 0; + facilitatorControl.settle = () => { + settleCalls += 1; + return new Promise(resolve => + setTimeout(() => resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }), 20), + ); + }; + const { adapter } = await setup(); + const gate = await gateFor(); + + const results = await Promise.allSettled([ + adapter.verifyAndSettle(gate, paidRequest('RACE_CRED')), + adapter.verifyAndSettle(gate, paidRequest('RACE_CRED')), + ]); + const fulfilled = results.filter(result => result.status === 'fulfilled'); + const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0]!.reason).toMatchObject({ code: 'x402_payment_replayed' }); + expect(settleCalls).toBe(1); + }); + + it('rejects facilitator-slot mutations without collapsing distinct transaction messages', async () => { + const kit = await vi.importActual('@solana/kit'); + const facilitator = await kit.generateKeyPairSigner(); + const client = await kit.generateKeyPairSigner(); + const message = kit.pipe( + kit.createTransactionMessage({ version: 0 }), + msg => kit.setTransactionMessageFeePayerSigner(facilitator, msg), + msg => + kit.appendTransactionMessageInstructions( + [ + { + accounts: [{ address: client.address, role: kit.AccountRole.READONLY_SIGNER }], + data: new Uint8Array(), + programAddress: kit.address('11111111111111111111111111111111'), + }, + ], + msg, + ), + msg => + kit.setTransactionMessageLifetimeUsingBlockhash( + { + blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N' as never, + lastValidBlockHeight: 0n, + }, + msg, + ), + ); + const compiled = kit.compileTransaction(message); + const unsignedFacilitator = { + ...compiled, + signatures: { + ...compiled.signatures, + [client.address]: new Uint8Array(64).fill(2), + [facilitator.address]: null, + }, + }; + const unsignedFacilitatorWire = kit.getBase64EncodedWireTransaction(unsignedFacilitator) as string; + const facilitatorSlotMutationWire = kit.getBase64EncodedWireTransaction({ + ...unsignedFacilitator, + signatures: { + ...unsignedFacilitator.signatures, + [facilitator.address]: new Uint8Array(64).fill(1), + }, + }) as string; + const distinct = kit.compileTransaction( + kit.appendTransactionMessageInstructions( + [ + { + accounts: [{ address: client.address, role: kit.AccountRole.READONLY_SIGNER }], + data: new Uint8Array([1]), + programAddress: kit.address('11111111111111111111111111111111'), + }, + ], + message, + ), + ); + const distinctWire = kit.getBase64EncodedWireTransaction({ + ...distinct, + signatures: { + ...distinct.signatures, + [client.address]: new Uint8Array(64).fill(2), + [facilitator.address]: null, + }, + }) as string; + + const unsigned = kit.getTransactionDecoder().decode(kit.getBase64Codec().encode(unsignedFacilitatorWire)); + const mutated = kit.getTransactionDecoder().decode(kit.getBase64Codec().encode(facilitatorSlotMutationWire)); + const distinctMessage = kit.getTransactionDecoder().decode(kit.getBase64Codec().encode(distinctWire)); + expect(mutated.messageBytes).toEqual(unsigned.messageBytes); + expect(mutated.signatures[facilitator.address]).not.toEqual(unsigned.signatures[facilitator.address]); + expect(distinctMessage.messageBytes).not.toEqual(unsigned.messageBytes); + + let settleCalls = 0; + facilitatorControl.settle = () => { + settleCalls += 1; + return Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + }; + decodeControl.impl = header => ({ + accepted: { network: 'solana:test' }, + payload: { + transaction: + header === 'UNSIGNED_FACILITATOR' + ? unsignedFacilitatorWire + : header === 'FACILITATOR_SLOT_MUTATION' + ? facilitatorSlotMutationWire + : distinctWire, + }, + }); + + const { adapter } = await setup(); + const gate = await gateFor(); + + await adapter.verifyAndSettle(gate, paidRequest('UNSIGNED_FACILITATOR')); + await expect(adapter.verifyAndSettle(gate, paidRequest('FACILITATOR_SLOT_MUTATION'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + await adapter.verifyAndSettle(gate, paidRequest('DISTINCT_TRANSACTION')); + expect(settleCalls).toBe(2); + }); + + it('releases the payload key on a pre-broadcast settle failure so a retry can proceed', async () => { + // A verification-class errorReason proves the transaction never + // broadcast, so the reservation is released for an honest retry. + facilitatorControl.settle = () => + Promise.resolve({ errorMessage: 're-verify failed', errorReason: 'verification_failed', success: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('RETRY_CRED'))).rejects.toMatchObject({ + code: 'verification_failed', + }); + + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + const retried = await adapter.verifyAndSettle(gate, paidRequest('RETRY_CRED')); + expect(retried.transaction).toBe('TxSig'); + }); + + it('keeps the payload key on a landed-but-unconfirmed settle failure (transaction_failed)', async () => { + // `@x402/svm` collapses a confirmation-poll timeout on a tx that may + // have landed into {success:false, errorReason:'transaction_failed'}. + // Releasing the reservation here would let another replica re-serve the + // same landed payment, so the key must stay claimed. + facilitatorControl.settle = () => + Promise.resolve({ errorMessage: 'confirm timeout', errorReason: 'transaction_failed', success: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('LANDED_CRED'))).rejects.toMatchObject({ + code: 'transaction_failed', + }); + + // A second attempt with the same payload is rejected as a replay: the + // reservation was NOT released, so the landed tx cannot be re-served + // even if the next call would have settled successfully. + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + await expect(adapter.verifyAndSettle(gate, paidRequest('LANDED_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('keeps the payload key on an unknown settle failure reason (fail closed)', async () => { + // Any reason outside the enumerated release-safe set defaults to KEEP. + facilitatorControl.settle = () => Promise.resolve({ errorReason: 'some_new_unmapped_reason', success: false }); + const { adapter } = await setup(); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('UNKNOWN_CRED'))).rejects.toMatchObject({ + code: 'some_new_unmapped_reason', + }); + + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + await expect(adapter.verifyAndSettle(gate, paidRequest('UNKNOWN_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('keeps the payload key when settlement throws so an ambiguous result cannot replay', async () => { + // Facilitator hooks and implementations can throw after a broadcast but + // before confirmation is returned. Without a typed, pre-broadcast-only + // failure signal, retaining the reservation is the safe outcome. + facilitatorControl.settle = () => Promise.reject(new Error('cosign failed')); + const { adapter } = await setup(); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('THROW_CRED'))).rejects.toThrow(/cosign failed/); + + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + await expect(adapter.verifyAndSettle(gate, paidRequest('THROW_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('surfaces the original error type when settlement throws pre-broadcast', async () => { + // The thrown value is wrapped as an InvalidProofError with a stable code + // so callers get the canonical taxonomy, not the raw internal error. + facilitatorControl.settle = () => Promise.reject(new Error('cosign boom')); + const { adapter } = await setup(); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('THROW_CODE_CRED'))).rejects.toBeInstanceOf( + InvalidProofError, + ); + }); + + it('lets distinct payloads settle independently', async () => { + const { adapter } = await setup(); + const gate = await gateFor(); + + const first = await adapter.verifyAndSettle(gate, paidRequest('CRED_ONE')); + const second = await adapter.verifyAndSettle(gate, paidRequest('CRED_TWO')); + expect(first.transaction).toBe('TxSig'); + expect(second.transaction).toBe('TxSig'); + }); + + it('expires consumed entries after the completion window', async () => { + vi.useFakeTimers(); + try { + const { adapter } = await setup(); + const gate = await gateFor(); + + await adapter.verifyAndSettle(gate, paidRequest('WINDOW_CRED')); + // Within the 300s window the replay is still rejected. + vi.setSystemTime(Date.now() + 299_000); + await expect(adapter.verifyAndSettle(gate, paidRequest('WINDOW_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + // Past the window the blockhash has expired on-chain; the local + // entry is pruned and the ledger owns dedup from here. + vi.setSystemTime(Date.now() + 302_000); + const late = await adapter.verifyAndSettle(gate, paidRequest('WINDOW_CRED')); + expect(late.transaction).toBe('TxSig'); + } finally { + vi.useRealTimers(); + } + }); + + // ── cross-process dedup via a reserving replay store ──────────────────── + + /** A reserving Store double: `reserve` is an atomic check-and-set. */ + function reservingStore() { + const map = new Map(); + const calls: { reserve: Array<{ key: string; ttlSeconds?: number }>; deletes: string[] } = { + reserve: [], + deletes: [], + }; + return { + calls, + store: { + get: (key: string) => Promise.resolve(map.get(key) ?? null), + put: (key: string, value: unknown) => { + map.set(key, value); + return Promise.resolve(); + }, + delete: (key: string) => { + calls.deletes.push(key); + map.delete(key); + return Promise.resolve(); + }, + reserve: (key: string, value: unknown = true, ttlSeconds?: number) => { + calls.reserve.push({ key, ttlSeconds }); + if (map.has(key)) return Promise.resolve(false); + map.set(key, value); + return Promise.resolve(true); + }, + }, + }; + } + + async function setupWithStore(store: unknown) { + const config = await configure({ + mpp: { challengeBindingSecret: 'x402-adapter-secret' }, + network: 'solana_localnet', + replayStore: store as never, + }); + return { adapter: createX402ExactAdapter(config) }; + } + + it('claims through the reserving store (with a TTL) when one is configured', async () => { + const reserving = reservingStore(); + const { adapter } = await setupWithStore(reserving.store); + const gate = await gateFor(); + + await adapter.verifyAndSettle(gate, paidRequest('STORE_CRED')); + expect(reserving.calls.reserve).toHaveLength(1); + expect(reserving.calls.reserve[0]!.key.startsWith('x402-svm-exact:consumed:')).toBe(true); + expect(reserving.calls.reserve[0]!.ttlSeconds).toBe(300); + }); + + it('rejects a sequential replay via the reserving store', async () => { + const reserving = reservingStore(); + const { adapter } = await setupWithStore(reserving.store); + const gate = await gateFor(); + + await adapter.verifyAndSettle(gate, paidRequest('DUP_CRED')); + await expect(adapter.verifyAndSettle(gate, paidRequest('DUP_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('rejects a concurrent duplicate via the reserving store', async () => { + let settleCalls = 0; + facilitatorControl.settle = () => { + settleCalls += 1; + return new Promise(resolve => + setTimeout(() => resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }), 20), + ); + }; + const reserving = reservingStore(); + const { adapter } = await setupWithStore(reserving.store); + const gate = await gateFor(); + + const results = await Promise.allSettled([ + adapter.verifyAndSettle(gate, paidRequest('RACE_STORE')), + adapter.verifyAndSettle(gate, paidRequest('RACE_STORE')), + ]); + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1); + expect(results.filter(r => r.status === 'rejected')).toHaveLength(1); + expect(settleCalls).toBe(1); + }); + + it('rejects replay across independent adapters sharing the atomic memory store', async () => { + const shared = createMemoryReplayStore(); + const [{ adapter: replicaA }, { adapter: replicaB }] = await Promise.all([ + setupWithStore(shared), + setupWithStore(shared), + ]); + const gate = await gateFor(); + + await replicaA.verifyAndSettle(gate, paidRequest('CROSS_INSTANCE_CRED')); + await expect(replicaB.verifyAndSettle(gate, paidRequest('CROSS_INSTANCE_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('releases the reserving-store key on a pre-broadcast settle failure', async () => { + facilitatorControl.settle = () => + Promise.resolve({ errorMessage: 're-verify failed', errorReason: 'verification_failed', success: false }); + const reserving = reservingStore(); + const { adapter } = await setupWithStore(reserving.store); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('REL_CRED'))).rejects.toMatchObject({ + code: 'verification_failed', + }); + expect(reserving.calls.deletes).toHaveLength(1); + + // The released key can be reclaimed by a retry. + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + const retried = await adapter.verifyAndSettle(gate, paidRequest('REL_CRED')); + expect(retried.transaction).toBe('TxSig'); + }); + + it('keeps the reserving-store key on a landed-but-unconfirmed settle failure', async () => { + // The cross-process invariant: a `transaction_failed` (confirm-timeout) + // must not delete the shared reservation, so a second replica reading + // the same store still rejects the payload as a replay. + facilitatorControl.settle = () => + Promise.resolve({ errorMessage: 'confirm timeout', errorReason: 'transaction_failed', success: false }); + const reserving = reservingStore(); + const { adapter } = await setupWithStore(reserving.store); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('KEEP_CRED'))).rejects.toMatchObject({ + code: 'transaction_failed', + }); + expect(reserving.calls.deletes).toHaveLength(0); + + // A second replica sharing the store still rejects the payload: the + // reservation was never released. + const { adapter: replica } = await setupWithStore(reserving.store); + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + await expect(replica.verifyAndSettle(gate, paidRequest('KEEP_CRED'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); + + it('keeps the reserving-store key when settlement throws', async () => { + facilitatorControl.settle = () => Promise.reject(new Error('cosign failed')); + const reserving = reservingStore(); + const { adapter } = await setupWithStore(reserving.store); + const gate = await gateFor(); + + await expect(adapter.verifyAndSettle(gate, paidRequest('THROW_STORE'))).rejects.toThrow(/cosign failed/); + expect(reserving.calls.deletes).toHaveLength(0); + + // Another replica sharing the store cannot reclaim an ambiguous payment. + facilitatorControl.settle = () => + Promise.resolve({ payer: 'SettlePayer', success: true, transaction: 'TxSig' }); + await expect(adapter.verifyAndSettle(gate, paidRequest('THROW_STORE'))).rejects.toMatchObject({ + code: 'x402_payment_replayed', + }); + }); +}); diff --git a/typescript/packages/pay-kit/src/__tests__/x402-shared.test.ts b/typescript/packages/pay-kit/src/__tests__/x402-shared.test.ts new file mode 100644 index 000000000..a6f52952b --- /dev/null +++ b/typescript/packages/pay-kit/src/__tests__/x402-shared.test.ts @@ -0,0 +1,280 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + assertPaymentHeaderWithinCap, + errorMessage, + MAX_PAYMENT_SIGNATURE_HEADER_LEN, + x402PaymentHeader, +} from '../adapters/x402-shared.js'; + +describe('x402PaymentHeader', () => { + it('reads the X-PAYMENT header', () => { + const request = new Request('http://t/', { headers: { 'x-payment': 'cred-a' } }); + expect(x402PaymentHeader(request)).toBe('cred-a'); + }); + + it('falls back to the PAYMENT-SIGNATURE header', () => { + const request = new Request('http://t/', { headers: { 'payment-signature': 'cred-b' } }); + expect(x402PaymentHeader(request)).toBe('cred-b'); + }); + + it('prefers X-PAYMENT when both are present', () => { + const request = new Request('http://t/', { + headers: { 'payment-signature': 'cred-b', 'x-payment': 'cred-a' }, + }); + expect(x402PaymentHeader(request)).toBe('cred-a'); + }); + + it('returns undefined when neither header is present', () => { + expect(x402PaymentHeader(new Request('http://t/'))).toBeUndefined(); + }); +}); + +describe('errorMessage', () => { + it('extracts the message from an Error', () => { + expect(errorMessage(new Error('boom'))).toBe('boom'); + }); + + it('returns undefined for a non-Error value', () => { + expect(errorMessage('boom')).toBeUndefined(); + expect(errorMessage(undefined)).toBeUndefined(); + expect(errorMessage({ message: 'nope' })).toBeUndefined(); + }); +}); + +describe('assertPaymentHeaderWithinCap', () => { + it('accepts a header at exactly the cap', () => { + expect(() => assertPaymentHeaderWithinCap('A'.repeat(MAX_PAYMENT_SIGNATURE_HEADER_LEN))).not.toThrow(); + }); + + it('rejects a header one byte over the cap', () => { + expect(() => assertPaymentHeaderWithinCap('A'.repeat(MAX_PAYMENT_SIGNATURE_HEADER_LEN + 1))).toThrow( + /exceeds maximum size/, + ); + }); + + it('measures the cap in UTF-8 bytes, not code units', () => { + // A multi-byte code point: a string of MAX/2 + 1 two-byte chars is under + // the cap by code-unit count but over it by byte count. The cap is + // byte-based (matching Rust's raw-header len()), so this must reject. + const twoByteChar = 'é'; // 'é', 2 bytes UTF-8, 1 UTF-16 code unit + const overByBytes = twoByteChar.repeat(MAX_PAYMENT_SIGNATURE_HEADER_LEN / 2 + 1); + expect(overByBytes.length).toBeLessThanOrEqual(MAX_PAYMENT_SIGNATURE_HEADER_LEN); + expect(Buffer.byteLength(overByBytes, 'utf8')).toBeGreaterThan(MAX_PAYMENT_SIGNATURE_HEADER_LEN); + expect(() => assertPaymentHeaderWithinCap(overByBytes)).toThrow(/exceeds maximum size/); + }); +}); + +// --- Adapter-level cap enforcement ---------------------------------------- +// A hostile client sending a multi-megabyte X-PAYMENT header must be rejected +// before either adapter hands the header to decodePaymentSignatureHeader (which +// does the base64 + JSON work). The decode is spied so the assertion proves the +// cap fires FIRST, so no decode work is done for an over-cap header. + +const decodeSpy = vi.fn((_header: string) => ({ accepted: { network: 'solana:test' }, payload: {} })); + +vi.mock('@x402/core/facilitator', () => { + class FakeFacilitator { + register(): this { + return this; + } + verify(): Promise<{ isValid: boolean; payer?: string }> { + return Promise.resolve({ isValid: true, payer: 'VerifyPayer' }); + } + settle(): Promise<{ success: boolean; transaction?: string }> { + return Promise.resolve({ success: true, transaction: 'TxSig' }); + } + } + return { x402Facilitator: FakeFacilitator }; +}); + +vi.mock('@x402/svm', async () => { + const actual = await vi.importActual('@x402/svm'); + return { ...actual, toFacilitatorSvmSigner: () => ({}) }; +}); + +vi.mock('@x402/svm/exact/facilitator', () => ({ ExactSvmScheme: class {} })); +vi.mock('@x402/svm/upto/facilitator', () => ({ UptoSvmScheme: class {} })); + +vi.mock('@x402/core/http', () => ({ + decodePaymentSignatureHeader: (header: string) => decodeSpy(header), + encodePaymentRequiredHeader: () => 'ENCODED_PAYMENT_REQUIRED', + encodePaymentResponseHeader: () => 'ENCODED_PAYMENT_RESPONSE', +})); + +vi.mock('@solana/kit', async () => { + const actual = await vi.importActual('@solana/kit'); + return { + ...actual, + createSolanaRpc: () => ({ + getLatestBlockhash: () => ({ + send: () => + Promise.resolve({ + context: { slot: 314n }, + value: { blockhash: 'BH', lastValidBlockHeight: 1n }, + }), + }), + }), + }; +}); + +const { createX402ExactAdapter } = await import('../adapters/x402.js'); +const { X402Upto } = await import('../adapters/x402-upto.js'); +const { configure } = await import('../config.js'); +const { Gate } = await import('../gate.js'); +const { usd } = await import('../price.js'); +const { gateDefaults } = await import('../pricing.js'); +const { InvalidProofError } = await import('../errors.js'); +const { createMemoryReplayStore } = await import('../replay-store.js'); + +async function payKitConfig() { + return configure({ + mpp: { challengeBindingSecret: 'x402-cap-secret' }, + network: 'solana_localnet', + replayStore: createMemoryReplayStore(), + }); +} + +function oversizedHeader(): string { + return 'A'.repeat(MAX_PAYMENT_SIGNATURE_HEADER_LEN + 1); +} + +describe('payment-header size cap enforced by the adapters', () => { + beforeEach(() => { + decodeSpy.mockClear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('exact adapter rejects an over-cap header before decoding it', async () => { + const config = await payKitConfig(); + const adapter = createX402ExactAdapter(config); + const gate = Gate.create({ amount: usd('0.10'), name: 'report' }, gateDefaults(config)); + const request = new Request('http://localhost/report', { headers: { 'x-payment': oversizedHeader() } }); + + await expect(adapter.verifyAndSettle(gate, request)).rejects.toMatchObject({ + code: 'x402_payment_header_too_large', + }); + await expect(adapter.verifyAndSettle(gate, request)).rejects.toBeInstanceOf(InvalidProofError); + expect(decodeSpy).not.toHaveBeenCalled(); + }); + + it('upto adapter rejects an over-cap header before decoding it', async () => { + const config = await payKitConfig(); + const upto = new X402Upto(config); + const request = new Request('http://localhost/meter', { headers: { 'x-payment': oversizedHeader() } }); + + await expect(upto.verifyOpen(request, usd('1.00'))).rejects.toMatchObject({ + code: 'x402_payment_header_too_large', + }); + await expect(upto.verifyOpen(request, usd('1.00'))).rejects.toBeInstanceOf(InvalidProofError); + expect(decodeSpy).not.toHaveBeenCalled(); + }); + + it('both adapters still decode an at-cap header (boundary is inclusive)', async () => { + const config = await payKitConfig(); + const adapter = createX402ExactAdapter(config); + const gate = Gate.create({ amount: usd('0.10'), name: 'report' }, gateDefaults(config)); + const atCap = 'A'.repeat(MAX_PAYMENT_SIGNATURE_HEADER_LEN); + const request = new Request('http://localhost/report', { headers: { 'x-payment': atCap } }); + + // At the cap the header is NOT rejected on size; decode is reached. + await adapter.verifyAndSettle(gate, request); + expect(decodeSpy).toHaveBeenCalledWith(atCap); + }); + + it('rejects one channel across independent upto engines sharing the atomic store', async () => { + vi.useFakeTimers({ now: 1_700_000_000_000 }); + try { + const config = await payKitConfig(); + const first = new X402Upto(config); + const second = new X402Upto(config); + decodeSpy.mockReturnValue({ + accepted: { network: 'solana:test' }, + payload: { channelId: 'shared-channel', expiresAt: 1_700_003_600, from: 'payer' }, + }); + const request = new Request('http://localhost/meter', { headers: { 'x-payment': 'credential' } }); + + await expect(first.verifyOpen(request, usd('1.00'))).resolves.toMatchObject({ + maxBaseUnits: 1_000_000n, + }); + vi.advanceTimersByTime(301_000); + await expect(second.verifyOpen(request, usd('1.00'))).rejects.toMatchObject({ + code: 'upto_channel_replayed', + }); + } finally { + vi.useRealTimers(); + } + }); + + it('accepts independent channels on independent usage routes', async () => { + const config = await payKitConfig(); + const first = new X402Upto(config); + const second = new X402Upto(config); + + decodeSpy + .mockReturnValueOnce({ + accepted: { network: 'solana:test' }, + payload: { channelId: 'route-a-channel', expiresAt: 1_700_003_600, from: 'payer' }, + }) + .mockReturnValueOnce({ + accepted: { network: 'solana:test' }, + payload: { channelId: 'route-b-channel', expiresAt: 1_700_003_600, from: 'payer' }, + }); + + await expect( + first.verifyOpen( + new Request('http://localhost/usage/a', { headers: { 'x-payment': 'credential-a' } }), + usd('1.00'), + ), + ).resolves.toBeDefined(); + await expect( + second.verifyOpen( + new Request('http://localhost/usage/b', { headers: { 'x-payment': 'credential-b' } }), + usd('1.00'), + ), + ).resolves.toBeDefined(); + }); + + it('binds a verified channel to one replay route, not the whole engine', async () => { + const config = await payKitConfig(); + const first = new X402Upto(config); + const second = new X402Upto(config); + decodeSpy.mockReturnValue({ + accepted: { network: 'solana:test' }, + payload: { channelId: 'same-channel', expiresAt: 1_700_003_600, from: 'payer' }, + }); + + await first.verifyOpen( + new Request('http://localhost/usage/a', { headers: { 'x-payment': 'credential' } }), + usd('1.00'), + ); + const request = new Request('http://localhost/usage/b', { headers: { 'x-payment': 'credential' } }); + + await expect(second.verifyOpen(request, usd('1.00'))).rejects.toMatchObject({ + code: 'upto_route_mismatch', + }); + }); + + it('does not claim that the current upto wire binds the challenge route', async () => { + const config = await payKitConfig(); + const upto = new X402Upto(config); + decodeSpy.mockReturnValue({ + accepted: { network: 'solana:test' }, + payload: { channelId: 'unbound-route-channel', expiresAt: 1_700_003_600, from: 'payer' }, + }); + + // `accepts()` only describes the challenge. The upstream payload has + // no signed resource pathname, so a credential created from that + // challenge cannot be checked against `/usage/a` here. + await upto.accepts(usd('1.00'), new Request('http://localhost/usage/a')); + await expect( + upto.verifyOpen( + new Request('http://localhost/usage/b', { headers: { 'x-payment': 'credential' } }), + usd('1.00'), + ), + ).resolves.toBeDefined(); + }); +}); diff --git a/typescript/packages/pay-kit/src/__tests__/x402.test.ts b/typescript/packages/pay-kit/src/__tests__/x402.test.ts index f83ab51fd..d105dead2 100644 --- a/typescript/packages/pay-kit/src/__tests__/x402.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/x402.test.ts @@ -32,9 +32,14 @@ import { configure, type PayKitConfig } from '../config.js'; import { Gate } from '../gate.js'; import { usd } from '../price.js'; import { gateDefaults } from '../pricing.js'; +import { createMemoryReplayStore } from '../replay-store.js'; async function testConfig(): Promise { - return await configure({ mpp: { challengeBindingSecret: 'x402-test-secret' }, network: 'solana_localnet' }); + return await configure({ + mpp: { challengeBindingSecret: 'x402-test-secret' }, + network: 'solana_localnet', + replayStore: createMemoryReplayStore(), + }); } function gateFor(config: PayKitConfig, amount = usd('0.10')): Gate { @@ -56,6 +61,21 @@ describe('x402 exact adapter', () => { expect((entry.extra as { feePayer?: string }).feePayer).toBe(config.operator.signer.pubkey); }); + it('binds exact requirements to the request pathname', async () => { + const config = await testConfig(); + const adapter = createX402ExactAdapter(config); + const gate = gateFor(config); + + const first = await adapter.acceptsEntry(gate, new Request('http://localhost/reports/a')); + const second = await adapter.acceptsEntry(gate, new Request('http://localhost/reports/b')); + const firstMemo = (first.extra as { memo?: unknown } | undefined)?.memo; + const secondMemo = (second.extra as { memo?: unknown } | undefined)?.memo; + + expect(firstMemo).toBe('/reports/a'); + expect(secondMemo).toBe('/reports/b'); + expect(firstMemo).not.toBe(secondMemo); + }); + it('detects the x402 payment header', async () => { const config = await testConfig(); const adapter = createX402ExactAdapter(config); diff --git a/typescript/packages/pay-kit/src/adapters/x402-shared.ts b/typescript/packages/pay-kit/src/adapters/x402-shared.ts index 83855117a..c9290a873 100644 --- a/typescript/packages/pay-kit/src/adapters/x402-shared.ts +++ b/typescript/packages/pay-kit/src/adapters/x402-shared.ts @@ -1,3 +1,9 @@ +import { createSolanaRpc } from '@solana/kit'; + +import { InvalidProofError } from '../errors.js'; + +export { isReservingReplayStore, type ReservingReplayStore } from '../replay-store.js'; + /** The x402 payment credential header, read from either accepted name. */ export function x402PaymentHeader(request: Request): string | undefined { return request.headers.get('x-payment') ?? request.headers.get('payment-signature') ?? undefined; @@ -7,3 +13,61 @@ export function x402PaymentHeader(request: Request): string | undefined { export function errorMessage(error: unknown): string | undefined { return error instanceof Error ? error.message : undefined; } + +/** Maximum UTF-8 byte length accepted for an x402 credential header. */ +export const MAX_PAYMENT_SIGNATURE_HEADER_LEN = 16 * 1024; + +/** Reject oversized credentials before base64 or JSON decoding. */ +export function assertPaymentHeaderWithinCap(header: string): void { + if (Buffer.byteLength(header, 'utf8') > MAX_PAYMENT_SIGNATURE_HEADER_LEN) { + throw new InvalidProofError( + 'x402_payment_header_too_large', + `payment header exceeds maximum size of ${MAX_PAYMENT_SIGNATURE_HEADER_LEN} bytes`, + ); + } +} + +export type CachedBlockhash = { + readonly blockhash: string; + readonly fetchedAtMs: number; + readonly lastValidBlockHeight: string; + readonly recentSlot: string; +}; + +/** Keep challenge blockhashes fresh while collapsing unauthenticated RPC bursts. */ +export const CHALLENGE_BLOCKHASH_TTL_MS = 5_000; + +/** Short-TTL, single-flight blockhash cache shared by the exact and upto adapters. */ +export class ChallengeBlockhashCache { + readonly #cache = new Map(); + readonly #inFlight = new Map>(); + + async recentBlockhash(rpcUrl: string): Promise { + const cached = this.#cache.get(rpcUrl); + if (cached !== undefined && Date.now() - cached.fetchedAtMs < CHALLENGE_BLOCKHASH_TTL_MS) { + return cached; + } + const pending = this.#inFlight.get(rpcUrl); + if (pending !== undefined) return await pending; + + const fetchPromise = (async (): Promise => { + try { + const { context, value } = await createSolanaRpc(rpcUrl).getLatestBlockhash().send(); + const entry: CachedBlockhash = { + blockhash: value.blockhash, + fetchedAtMs: Date.now(), + lastValidBlockHeight: value.lastValidBlockHeight.toString(), + recentSlot: context.slot.toString(), + }; + this.#cache.set(rpcUrl, entry); + return entry; + } catch { + return undefined; + } finally { + this.#inFlight.delete(rpcUrl); + } + })(); + this.#inFlight.set(rpcUrl, fetchPromise); + return await fetchPromise; + } +} diff --git a/typescript/packages/pay-kit/src/adapters/x402-upto.ts b/typescript/packages/pay-kit/src/adapters/x402-upto.ts index fbea74dcc..bbe6223b1 100644 --- a/typescript/packages/pay-kit/src/adapters/x402-upto.ts +++ b/typescript/packages/pay-kit/src/adapters/x402-upto.ts @@ -17,10 +17,16 @@ import { UptoSvmScheme as UptoSvmFacilitator } from '@x402/svm/upto/facilitator' import { requireMint, resolveCoin } from '../coin.js'; import type { PayKitConfig } from '../config.js'; -import { InvalidProofError } from '../errors.js'; +import { ConfigurationError, InvalidProofError } from '../errors.js'; import type { Price } from '../price.js'; import { caip2 } from '../protocol.js'; -import { errorMessage, x402PaymentHeader } from './x402-shared.js'; +import { + assertPaymentHeaderWithinCap, + ChallengeBlockhashCache, + errorMessage, + isReservingReplayStore, + x402PaymentHeader, +} from './x402-shared.js'; /** Settlement-response header mirrored by the x402 SDK family. */ const PAYMENT_RESPONSE_HEADER = 'x-payment-response'; @@ -30,6 +36,8 @@ const X402_VERSION = 2; const MAX_TIMEOUT_SECONDS = 300; const DEFAULT_WITHDRAW_DELAY_SECONDS = 900; const BASIS_POINTS_DENOMINATOR = 10_000; +const CONSUMED_PREFIX = 'x402-svm-upto:consumed:'; +const ROUTE_PREFIX = 'x402-svm-upto:route:'; /** * Usage meter handed to a usage-gated handler. The handler reports the actual @@ -104,7 +112,9 @@ export class X402Upto { readonly #signer: PayKitConfig['operator']['signer']; readonly #recipient: string; readonly #rpcUrl: string; + readonly #replayStore: import('../replay-store.js').ReservingReplayStore; readonly #stablecoins: readonly string[]; + readonly #blockhashCache = new ChallengeBlockhashCache(); constructor(config: PayKitConfig) { this.#network = caip2(config.network) as Network; @@ -113,6 +123,10 @@ export class X402Upto { this.#signer = config.operator.signer; this.#recipient = config.operator.recipient; this.#rpcUrl = config.rpcUrl; + if (config.replayStore === undefined || !isReservingReplayStore(config.replayStore)) { + throw new ConfigurationError('x402 upto requires a replayStore with atomic reserve capability.'); + } + this.#replayStore = config.replayStore; this.#stablecoins = config.stablecoins; this.#facilitator = new x402Facilitator().register( this.#network, @@ -150,7 +164,8 @@ export class X402Upto { * requirement (`extra.recentBlockhash` + `extra.recentSlot`) the header * carries, so body-based `upto` clients can build the channel open too. */ - async accepts(maxPrice: Price): Promise { + async accepts(maxPrice: Price, request?: Request): Promise { + void request; return [await this.#challengeRequirements(maxPrice)]; } @@ -163,6 +178,7 @@ export class X402Upto { async verifyOpen(request: Request, maxPrice: Price): Promise { const header = x402PaymentHeader(request); if (!header) throw new InvalidProofError('missing_x402_payment_header'); + assertPaymentHeaderWithinCap(header); let payload: PaymentPayload; try { @@ -176,7 +192,20 @@ export class X402Upto { if (!verification.isValid) { throw new InvalidProofError(verification.invalidReason ?? 'invalid_proof', verification.invalidMessage); } - return { maxBaseUnits: BigInt(requirements.amount), payer: verification.payer ?? '', payload, requirements }; + const channel = parseUptoPayload(payload); + const ttlSeconds = Math.max(MAX_TIMEOUT_SECONDS, channel.expiresAt - Math.floor(Date.now() / 1000)); + await this.#bindChannelRoute(channel.channelId, request, ttlSeconds); + const channelId = channel.channelId; + const replayKey = `${CONSUMED_PREFIX}${channelId}`; + if (!(await this.#replayStore.reserve(replayKey, true, ttlSeconds))) { + throw new InvalidProofError('upto_channel_replayed', 'channel already used or in flight'); + } + return { + maxBaseUnits: BigInt(requirements.amount), + payer: verification.payer ?? '', + payload, + requirements, + }; } /** @@ -261,6 +290,35 @@ export class X402Upto { return getBase58Decoder().decode(signature); } + /** + * Bind a verified channel's replay scope to the first route that accepts it. + * + * The x402 `upto` wire commits the channel ID in the payer-signed open + * transaction, so this reservation is channel-specific and does not block + * unrelated routes. It does not prove which HTTP route issued the + * challenge: the upstream `upto` transaction has no resource-path field. + * A route-specific cryptographic guarantee therefore requires a protocol + * extension; this is only a fail-closed same-channel replay binding. + * + * `reserve` and the fallback `get` are separate store operations because + * the public replay-store contract has no compare-and-swap. At the TTL + * boundary another caller may replace the value between them; that can + * only produce a mismatch rejection here, never acceptance for a different + * route. A future store contract can collapse this to atomic bind-or-verify. + */ + async #bindChannelRoute(channelId: string, request: Request, ttlSeconds: number): Promise { + const route = new URL(request.url).pathname; + const key = `${ROUTE_PREFIX}${this.#network}:${this.#recipient}:${channelId}`; + if (await this.#replayStore.reserve(key, route, ttlSeconds)) return; + const existing = await this.#replayStore.get(key); + if (existing !== route) { + throw new InvalidProofError( + 'upto_route_mismatch', + `x402 upto is already bound to route ${String(existing)}`, + ); + } + } + #requirements(maxPrice: Price): PaymentRequirements { const coin = resolveCoin(maxPrice, this.#stablecoins); const mint = requireMint(coin, resolveStablecoinMint(coin, this.#network), this.#network); @@ -296,21 +354,17 @@ export class X402Upto { */ async #challengeRequirements(maxPrice: Price): Promise { const base = this.#requirements(maxPrice); - let context, value; - try { - ({ context, value } = await createSolanaRpc(this.#rpcUrl).getLatestBlockhash().send()); - } catch (error) { - throw new Error( - `x402 upto challenge requires extra.recentBlockhash/recentSlot; getLatestBlockhash failed: ${errorMessage(error)}`, - ); + const cached = await this.#blockhashCache.recentBlockhash(this.#rpcUrl); + if (cached === undefined) { + throw new Error('x402 upto challenge requires extra.recentBlockhash/recentSlot; getLatestBlockhash failed'); } return { ...base, extra: { ...base.extra, - lastValidBlockHeight: value.lastValidBlockHeight.toString(), - recentBlockhash: value.blockhash, - recentSlot: context.slot.toString(), + lastValidBlockHeight: cached.lastValidBlockHeight, + recentBlockhash: cached.blockhash, + recentSlot: cached.recentSlot, }, }; } @@ -328,7 +382,9 @@ function parseUptoPayload(payload: PaymentPayload): UptoPaymentChannelPayload { !raw || typeof raw.channelId !== 'string' || typeof raw.from !== 'string' || - typeof raw.expiresAt !== 'number' + typeof raw.expiresAt !== 'number' || + !Number.isSafeInteger(raw.expiresAt) || + raw.expiresAt <= 0 ) { throw new InvalidProofError('invalid_upto_payload'); } diff --git a/typescript/packages/pay-kit/src/adapters/x402.ts b/typescript/packages/pay-kit/src/adapters/x402.ts index 74d7d720b..b0efcf038 100644 --- a/typescript/packages/pay-kit/src/adapters/x402.ts +++ b/typescript/packages/pay-kit/src/adapters/x402.ts @@ -1,4 +1,6 @@ -import { createSolanaRpc } from '@solana/kit'; +import { createHash } from 'node:crypto'; + +import { getBase64Codec, getTransactionDecoder } from '@solana/kit'; import { x402Facilitator } from '@x402/core/facilitator'; import { decodePaymentSignatureHeader, @@ -13,11 +15,17 @@ import type { ProtocolAdapter } from '../adapter.js'; import type { AcceptsEntry } from '../challenge.js'; import { requireMint, resolveCoin } from '../coin.js'; import type { PayKitConfig } from '../config.js'; -import { InvalidProofError } from '../errors.js'; +import { ConfigurationError, InvalidProofError } from '../errors.js'; import type { Gate } from '../gate.js'; import type { Payment } from '../payment.js'; import { caip2 } from '../protocol.js'; -import { errorMessage, x402PaymentHeader } from './x402-shared.js'; +import { + assertPaymentHeaderWithinCap, + ChallengeBlockhashCache, + errorMessage, + isReservingReplayStore, + x402PaymentHeader, +} from './x402-shared.js'; /** x402 v2 protocol version advertised in the challenge envelope. */ const X402_VERSION = 2; @@ -27,6 +35,24 @@ const MAX_TIMEOUT_SECONDS = 300; const PAYMENT_RESPONSE_HEADER = 'x-payment-response'; /** 402 challenge header read by x402 clients (alongside the JSON body). */ const PAYMENT_REQUIRED_HEADER = 'payment-required'; +const CONSUMED_PREFIX = 'x402-svm-exact:consumed:'; + +const RELEASE_SAFE_SETTLE_REASONS: ReadonlySet = new Set([ + 'verification_failed', + 'unsupported_scheme', + 'network_mismatch', + 'fee_payer_not_managed_by_facilitator', + 'invalid_exact_svm_payload_missing_fee_payer', + 'invalid_exact_svm_payload_transaction_could_not_be_decoded', + 'invalid_exact_svm_payload_transaction_instructions_length', + 'invalid_exact_svm_payload_no_transfer_instruction', + 'invalid_exact_svm_payload_mint_mismatch', + 'invalid_exact_svm_payload_recipient_mismatch', + 'invalid_exact_svm_payload_amount_mismatch', + 'invalid_exact_svm_payload_memo_count', + 'invalid_exact_svm_payload_memo_mismatch', + 'invalid_exact_svm_payload_transaction_fee_payer_transferring_funds', +]); /** * The x402 `exact` protocol adapter: wraps `@x402/svm`'s exact scheme behind @@ -39,6 +65,7 @@ const PAYMENT_REQUIRED_HEADER = 'payment-required'; export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { const network = caip2(config.network) as Network; const operator = config.operator.signer.pubkey; + const blockhashCache = new ChallengeBlockhashCache(); // In-process facilitator: the operator both fee-pays and signs settlement. const facilitator = new x402Facilitator().register( @@ -47,6 +74,40 @@ export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { toFacilitatorSvmSigner(config.operator.signer.signer, { defaultRpcUrl: config.rpcUrl }), ), ); + if (config.replayStore === undefined || !isReservingReplayStore(config.replayStore)) { + throw new ConfigurationError('x402 exact requires a replayStore with atomic reserve capability.'); + } + const reserveStore = config.replayStore; + + async function claimPayload(key: string): Promise { + return await reserveStore.reserve(`${CONSUMED_PREFIX}${key}`, true, MAX_TIMEOUT_SECONDS); + } + + async function releasePayload(key: string): Promise { + await reserveStore.delete(`${CONSUMED_PREFIX}${key}`); + } + + function payloadKey(header: string, payload: PaymentPayload, requirements: PaymentRequirements): string { + const transaction = (payload.payload as { transaction?: unknown } | undefined)?.transaction; + if (typeof transaction !== 'string' || transaction === '') return `header:${header}`; + try { + const decoded = getTransactionDecoder().decode(getBase64Codec().encode(transaction)); + const identity = JSON.stringify({ + amount: requirements.amount, + asset: requirements.asset, + extra: requirements.extra, + maxTimeoutSeconds: requirements.maxTimeoutSeconds, + message: getBase64Codec().decode(decoded.messageBytes), + network: requirements.network, + payTo: requirements.payTo, + scheme: requirements.scheme, + }); + return `msg:${createHash('sha256').update(identity).digest('hex')}`; + } catch { + // Verification remains authoritative; malformed transactions fall back to raw bytes. + } + return `tx:${transaction}`; + } function mintFor(gate: Gate): string { const coin = resolveCoin(gate.amount, config.stablecoins); @@ -54,11 +115,11 @@ export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { } /** The route's pinned requirements — the credential is bound to this exact amount. */ - function requirementsFor(gate: Gate): PaymentRequirements { + function requirementsFor(gate: Gate, request: Request): PaymentRequirements { return { amount: gate.total().baseUnits().toString(), asset: mintFor(gate), - extra: { feePayer: operator }, + extra: { feePayer: operator, memo: new URL(request.url).pathname }, maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, network, payTo: gate.payTo, @@ -72,32 +133,29 @@ export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { * round-trip (mirroring MPP's `recentBlockhash`). Falls back to the bare * requirements if the fetch fails (the client then fetches its own). */ - async function challengeRequirements(gate: Gate): Promise { - const base = requirementsFor(gate); - try { - const { value } = await createSolanaRpc(config.rpcUrl).getLatestBlockhash().send(); - return { - ...base, - extra: { - ...base.extra, - lastValidBlockHeight: value.lastValidBlockHeight.toString(), - recentBlockhash: value.blockhash, - }, - }; - } catch { - return base; - } + async function challengeRequirements(gate: Gate, request: Request): Promise { + const base = requirementsFor(gate, request); + const cached = await blockhashCache.recentBlockhash(config.rpcUrl); + if (cached === undefined) return base; + return { + ...base, + extra: { + ...base.extra, + lastValidBlockHeight: cached.lastValidBlockHeight, + recentBlockhash: cached.blockhash, + }, + }; } return { - acceptsEntry(gate: Gate): Promise { - const requirements = requirementsFor(gate); + acceptsEntry(gate: Gate, request: Request): Promise { + const requirements = requirementsFor(gate, request); return Promise.resolve({ ...requirements, protocol: 'x402' }); }, async challengeHeaders(gate: Gate, request: Request): Promise>> { const paymentRequired: PaymentRequired = { - accepts: [await challengeRequirements(gate)], + accepts: [await challengeRequirements(gate, request)], resource: { url: new URL(request.url).pathname }, x402Version: X402_VERSION, }; @@ -114,6 +172,7 @@ export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { async verifyAndSettle(gate: Gate, request: Request): Promise { const header = x402PaymentHeader(request); if (!header) throw new InvalidProofError('missing_x402_payment_header'); + assertPaymentHeaderWithinCap(header); let payload: PaymentPayload; try { @@ -122,15 +181,30 @@ export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { throw new InvalidProofError('invalid_x402_payment_header', errorMessage(error)); } - const requirements = requirementsFor(gate); + const requirements = requirementsFor(gate, request); const verification = await facilitator.verify(payload, requirements); if (!verification.isValid) { throw new InvalidProofError(verification.invalidReason ?? 'invalid_proof', verification.invalidMessage); } - const settlement = await facilitator.settle(payload, requirements); + const key = payloadKey(header, payload, requirements); + if (!(await claimPayload(key))) { + throw new InvalidProofError('x402_payment_replayed', 'payment payload already used or in flight'); + } + + let settlement: Awaited>; + try { + settlement = await facilitator.settle(payload, requirements); + } catch (error) { + // A facilitator can throw after broadcast (for example while + // awaiting confirmation), so its side-effect boundary is + // ambiguous. Preserve the reservation and fail closed. + throw new InvalidProofError('settlement_failed', errorMessage(error)); + } if (!settlement.success) { - throw new InvalidProofError(settlement.errorReason ?? 'settlement_failed', settlement.errorMessage); + const reason = settlement.errorReason ?? 'settlement_failed'; + if (RELEASE_SAFE_SETTLE_REASONS.has(reason)) await releasePayload(key); + throw new InvalidProofError(reason, settlement.errorMessage); } return { diff --git a/typescript/packages/pay-kit/src/index.ts b/typescript/packages/pay-kit/src/index.ts index 1a065af06..e3de29ee4 100644 --- a/typescript/packages/pay-kit/src/index.ts +++ b/typescript/packages/pay-kit/src/index.ts @@ -90,5 +90,6 @@ export { } from './pricing.js'; export { caip2, type Network, type NetworkSlug, toNetwork, type Protocol, toSolanaNetwork } from './protocol.js'; export { type KeychainSigner, type PayKitSigner, Signer } from './signer.js'; +export { createMemoryReplayStore, isReservingReplayStore, type ReservingReplayStore } from './replay-store.js'; // Replay-protection stores (memory, Redis, Upstash, Cloudflare KV) come from mppx. export { Store } from 'mppx'; diff --git a/typescript/packages/pay-kit/src/replay-store.ts b/typescript/packages/pay-kit/src/replay-store.ts new file mode 100644 index 000000000..68db9e86b --- /dev/null +++ b/typescript/packages/pay-kit/src/replay-store.ts @@ -0,0 +1,64 @@ +import type { Store } from 'mppx'; + +/** Replay store capability required by x402's reserve-before-settle lifecycle. */ +export interface ReservingReplayStore extends Store.Store { + /** Alternative capability name for a durable shared backend. */ + readonly isDurable?: boolean; + /** Explicitly affirm that reservations survive restarts and span replicas. */ + readonly isShared?: boolean; + reserve(key: string, value?: unknown, ttlSeconds?: number): Promise; +} + +/** Whether a store provides an atomic reserve operation. */ +export function isReservingReplayStore(store: Store.Store): store is ReservingReplayStore { + return typeof (store as Partial).reserve === 'function'; +} + +/** Whether a replay store has declared a production-safe shared/durable backend. */ +export function isProductionReplayStore(store: Store.Store): boolean { + const candidate = store as Partial; + return candidate.isShared === true && candidate.isDurable === true; +} + +/** + * Process-local atomic replay store for local development and explicit insecure + * off-localnet opt-in. Share the returned instance between handlers when they + * run in one process; production replicas should inject a durable implementation + * whose `reserve` maps to a datastore-native compare-and-set (for example SET NX). + */ +export function createMemoryReplayStore(): ReservingReplayStore { + const entries = new Map(); + + function live(key: string): { expiresAt?: number; value: unknown } | undefined { + const entry = entries.get(key); + if (entry?.expiresAt !== undefined && entry.expiresAt <= Date.now()) { + entries.delete(key); + return undefined; + } + return entry; + } + + return { + delete(key: string): Promise { + entries.delete(key); + return Promise.resolve(); + }, + get(key: string) { + return Promise.resolve(live(key)?.value ?? null); + }, + isDurable: false, + isShared: false, + put(key: string, value: unknown): Promise { + entries.set(key, { value }); + return Promise.resolve(); + }, + reserve(key: string, value: unknown = true, ttlSeconds?: number): Promise { + if (live(key) !== undefined) return Promise.resolve(false); + entries.set(key, { + expiresAt: ttlSeconds === undefined ? undefined : Date.now() + ttlSeconds * 1000, + value, + }); + return Promise.resolve(true); + }, + }; +} From 5de48fda95e1ab0159c1f6eb3a9cd594adc8a0ab Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:31:26 +0300 Subject: [PATCH 02/11] fix(harness): scope x402 adapters to one shared replay directory per run Create a single run-scoped temp directory and export it to every spawned adapter as PAY_KIT_HARNESS_REPLAY_STORE_DIR so per-language x402 servers share one durable replay root instead of splitting workers onto process-local stores. Remove the directory on exit and on SIGINT/SIGHUP/SIGTERM, and cover the signal-cleanup path with a test. --- harness/src/process.ts | 31 +++++++++++++++++++++ harness/test/process.test.ts | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/harness/src/process.ts b/harness/src/process.ts index 7b8e833c5..bf835229d 100644 --- a/harness/src/process.ts +++ b/harness/src/process.ts @@ -1,4 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createInterface } from "node:readline"; import { setTimeout as delay } from "node:timers/promises"; import { @@ -24,6 +27,31 @@ type RunningServer = { const ADAPTER_OUTPUT_TIMEOUT_MS = 120_000; const STDERR_RING_BUFFER_BYTES = 1024; +const REPLAY_STORE_DIRECTORY = mkdtempSync( + join(tmpdir(), "pay-kit-harness-replay-"), +); + +let replayStoreDirectoryRemoved = false; + +function removeReplayStoreDirectory(): void { + if (replayStoreDirectoryRemoved) return; + replayStoreDirectoryRemoved = true; + rmSync(REPLAY_STORE_DIRECTORY, { force: true, recursive: true }); +} + +process.once("exit", removeReplayStoreDirectory); +process.once("SIGINT", () => { + removeReplayStoreDirectory(); + process.exit(130); +}); +process.once("SIGHUP", () => { + removeReplayStoreDirectory(); + process.exit(129); +}); +process.once("SIGTERM", () => { + removeReplayStoreDirectory(); + process.exit(143); +}); const stderrCaptures = new WeakMap(); @@ -157,6 +185,9 @@ function spawnAdapter( env: { ...process.env, ...extraEnv, + // Reserved harness capability: callers cannot split workers onto + // process-local replay roots by overriding this value per child. + PAY_KIT_HARNESS_REPLAY_STORE_DIR: REPLAY_STORE_DIRECTORY, }, // Capture stderr to a ring buffer so adapter failures can attach the // last 1 KiB of stderr to the rejection. We also forward to the parent diff --git a/harness/test/process.test.ts b/harness/test/process.test.ts index 04e79f34f..67c443b9e 100644 --- a/harness/test/process.test.ts +++ b/harness/test/process.test.ts @@ -1,3 +1,8 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { startServer, runClient } from "../src/process"; import type { ImplementationDefinition } from "../src/implementations"; @@ -44,4 +49,51 @@ describe("process.startServer error enrichment", () => { /synthetic-fail-client.*exited with code 3.*last stderr:.*client boom: env=http:\/\/127.0.0.1:65535\/missing/s, ); }); + + it.skipIf(process.platform === "win32")( + "removes the run-scoped replay directory on SIGHUP", + async () => { + const temporaryRoot = mkdtempSync( + join(tmpdir(), "pay-kit-harness-sighup-"), + ); + const child = spawn( + process.execPath, + [ + "--import", + "tsx", + "--input-type=module", + "--eval", + "await import('./src/process.ts'); console.log('ready'); setInterval(() => {}, 1000);", + ], + { + cwd: process.cwd(), + env: { ...process.env, TMPDIR: temporaryRoot }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const replayDirectories = () => + readdirSync(temporaryRoot).filter((entry) => + entry.startsWith("pay-kit-harness-replay-"), + ); + + try { + await Promise.race([ + once(child.stdout!, "data"), + once(child, "exit").then(([code]) => { + throw new Error(`cleanup probe exited before ready with code ${code}`); + }), + ]); + expect(replayDirectories()).toHaveLength(1); + child.kill("SIGHUP"); + const [code, signal] = await once(child, "exit"); + expect({ code, signal }).toEqual({ code: 129, signal: null }); + expect(replayDirectories()).toEqual([]); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + rmSync(temporaryRoot, { force: true, recursive: true }); + } + }, + ); }); From e0debe1a201d253d5ccbf16d30c20dcd22110a4e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:31:26 +0300 Subject: [PATCH 03/11] fix(php): inject x402 replay adapters into framework wrappers Wire the x402 exact adapter and its replay store through the Laravel service provider and the Symfony DI extension, and fail closed off localnet when an x402-accepting app has no replay adapter injected. Skip x402 DI entirely for mpp-only apps so they boot without an x402 replay store. Harden the exact verifier's u64 handling and canonical replay identity, and boot the PHP harness server against the run-scoped shared replay directory. --- .github/workflows/php.yml | 3 + harness/php-server/HarnessReplayStore.php | 31 + harness/php-server/server.php | 16 +- harness/test/php-x402-startup.test.ts | 53 ++ .../Laravel/PayKitServiceProvider.php | 54 ++ .../Laravel/RequirePaymentMiddleware.php | 4 +- php/src/Frameworks/Laravel/config/paykit.php | 4 + .../DependencyInjection/PayKitExtension.php | 15 +- .../EventListener/RequirePaymentListener.php | 4 +- php/src/Protocols/X402/Adapter.php | 103 +++- php/src/Protocols/X402/Exact/Verifier.php | 55 +- .../Frameworks/ReplayStoreInjectionTest.php | 575 ++++++++++++++++++ php/tests/Harness/HarnessReplayStoreTest.php | 80 +++ php/tests/Middleware/RequirePaymentTest.php | 43 +- php/tests/Protocols/X402/AdapterTest.php | 169 ++++- php/tests/Protocols/X402/ConfirmationTest.php | 1 + .../X402/Exact/AtaCreateRejectTest.php | 24 +- .../Protocols/X402/Exact/VerifierTest.php | 33 + .../ExplicitCapabilityTestReplayStore.php | 33 + php/tests/Protocols/X402/LegacyWireTest.php | 38 +- 20 files changed, 1274 insertions(+), 64 deletions(-) create mode 100644 harness/php-server/HarnessReplayStore.php create mode 100644 harness/test/php-x402-startup.test.ts create mode 100644 php/tests/Frameworks/ReplayStoreInjectionTest.php create mode 100644 php/tests/Harness/HarnessReplayStoreTest.php create mode 100644 php/tests/Protocols/X402/ExplicitCapabilityTestReplayStore.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 39e300bc9..36e4d69f3 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -58,6 +58,9 @@ jobs: - name: Install PHP harness dependencies working-directory: php run: composer install --no-interaction --no-progress + - name: Verify PHP x402 replay-store startup + working-directory: harness + run: pnpm exec vitest run test/php-x402-startup.test.ts # Cross-SDK conformance vectors for PHP: the PHP exact verifier must execute # the x402 exact-reject fund-safety vectors, mirroring the python leg. The # conformance runner (harness/runners/php.json) launches diff --git a/harness/php-server/HarnessReplayStore.php b/harness/php-server/HarnessReplayStore.php new file mode 100644 index 000000000..88450cdd8 --- /dev/null +++ b/harness/php-server/HarnessReplayStore.php @@ -0,0 +1,31 @@ +store = new FileStore($directory); + } + + public function putIfAbsent(string $key, mixed $value): bool + { + return $this->store->putIfAbsent($key, $value); + } + + public function providesDurableSharedReplayProtection(): bool + { + // The harness injects one run-scoped directory shared across workers. + return true; + } +} diff --git a/harness/php-server/server.php b/harness/php-server/server.php index 959c9d54a..9b2b52416 100644 --- a/harness/php-server/server.php +++ b/harness/php-server/server.php @@ -23,8 +23,10 @@ ini_set('display_errors', 'stderr'); require __DIR__ . '/../../php/vendor/autoload.php'; +require_once __DIR__ . '/HarnessReplayStore.php'; use Nyholm\Psr7\Factory\Psr17Factory; +use PayKitHarness\PhpServer\HarnessReplayStore; use PayKit\PayKit; use PayKit\Config; use PayKit\PayCore\Currency; @@ -140,13 +142,17 @@ function secret_key_from_json(string $raw): string mpp: new MppConfig(challengeBindingSecret: 'unused-x402'), preflight: false, )); - // The X402 adapter fails closed off localnet unless it is handed a shared - // replay store (the default in-memory store is process-local). The harness - // runs the x402 matrix on a non-localnet CAIP-2 network, so inject a - // process-scoped FileStore here, mirroring the MPP charge path below. + $replayStoreDirectory = getenv('PAY_KIT_HARNESS_REPLAY_STORE_DIR'); + if (!is_string($replayStoreDirectory) || trim($replayStoreDirectory) === '') { + throw new RuntimeException('PAY_KIT_HARNESS_REPLAY_STORE_DIR is required for the PHP x402 harness'); + } + // Every PHP worker in this harness run receives the same temporary root. + // The Node harness owns its lifecycle and removes it when the run exits. $adapter = new X402Adapter( $client->config, - replayStore: new FileStore(sys_get_temp_dir() . '/x402-php-harness-replay-' . getmypid()), + replayStore: new HarnessReplayStore( + rtrim($replayStoreDirectory, DIRECTORY_SEPARATOR) . '/php-x402', + ), ); $gate = new Gate(amount: Price::usd(format_decimal_amount($amountUnits))); } else { diff --git a/harness/test/php-x402-startup.test.ts b/harness/test/php-x402-startup.test.ts new file mode 100644 index 000000000..1f5385415 --- /dev/null +++ b/harness/test/php-x402-startup.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { ImplementationDefinition } from "../src/implementations"; +import { startServer, stopServer } from "../src/process"; + +const KEYPAIR_BYTES = [ + 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102, 48, 49, 50, + 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102, 35, 188, 84, 145, 44, + 30, 110, 146, 196, 168, 104, 37, 200, 103, 226, 127, 253, 197, 85, 191, 251, + 212, 36, 79, 23, 162, 106, 191, 255, 238, 150, 93, +]; +const KEYPAIR_PUBKEY = "3QVq8D876hmq5C5L6J3CKpWXPhvbHASz8qFsddSXFDP2"; + +const phpX402Server: ImplementationDefinition = { + id: "php-x402-startup", + label: "PHP x402 harness startup", + role: "server", + command: ["php", "php-server/server.php"], + enabled: true, + reportsAs: "php", +}; + +let running: Awaited> | undefined; + +afterEach(async () => { + if (running) { + await stopServer(running); + running = undefined; + } +}); + +describe("PHP x402 harness fixture", () => { + it("boots on the non-localnet x402 network with its explicit replay capability", async () => { + running = await startServer(phpX402Server, { + PAY_KIT_HARNESS_PROTOCOL: "x402", + X402_HARNESS_RPC_URL: "http://127.0.0.1:1", + X402_HARNESS_NETWORK: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + X402_HARNESS_MINT: "USDC", + X402_HARNESS_AMOUNT: "1000", + X402_HARNESS_PAY_TO: KEYPAIR_PUBKEY, + X402_HARNESS_FACILITATOR_SECRET_KEY: JSON.stringify(KEYPAIR_BYTES), + // startServer owns this reserved path and must ignore per-child attempts + // to replace it with an unshared or unmanaged directory. + PAY_KIT_HARNESS_REPLAY_STORE_DIR: "", + }); + + expect(running.ready).toMatchObject({ + type: "ready", + role: "server", + implementation: "php", + }); + expect(running.ready.port).toBeGreaterThan(0); + }); +}); diff --git a/php/src/Frameworks/Laravel/PayKitServiceProvider.php b/php/src/Frameworks/Laravel/PayKitServiceProvider.php index 5f11c5f33..09ce22037 100644 --- a/php/src/Frameworks/Laravel/PayKitServiceProvider.php +++ b/php/src/Frameworks/Laravel/PayKitServiceProvider.php @@ -15,9 +15,13 @@ use PayKit\Pricing; use PayKit\Protocol; use PayKit\Protocols\Mpp\MppConfig; +use PayKit\Protocols\X402\Adapter as X402Adapter; use PayKit\Protocols\X402\X402Config; use PayKit\Signer; use PayKit\PayCore\Stablecoin; +use PayKit\Store\Store; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; /** * Laravel service provider. Registers: @@ -41,6 +45,40 @@ public function register(): void $cfg = $app['config']->get('paykit', []); return new PayKit(self::buildConfig($cfg)); }); + + /** @var array $cfg */ + $cfg = $this->app['config']->get('paykit', []); + $configuredAccept = $cfg['accept'] ?? ['x402', 'mpp']; + $acceptsX402 = is_array($configuredAccept) + && in_array(Protocol::X402->value, $configuredAccept, true); + + if ($acceptsX402) { + $this->app->singleton(X402Adapter::class, function (Application $app): X402Adapter { + /** @var mixed $replayStoreId */ + $replayStoreId = $app['config']->get('paykit.x402_replay_store'); + $replayStore = self::resolveReplayStore($app, $replayStoreId); + + return new X402Adapter( + $app->make(PayKit::class)->config, + replayStore: $replayStore, + ); + }); + } + + $this->app->bind(RequirePaymentMiddleware::class, function (Application $app): RequirePaymentMiddleware { + $client = $app->make(PayKit::class); + $x402 = in_array(Protocol::X402, $client->config->accept, true) + ? $app->make(X402Adapter::class) + : null; + + return new RequirePaymentMiddleware( + $client, + $app, + $app->make(PsrHttpFactory::class), + $app->make(HttpFoundationFactory::class), + $x402, + ); + }); } public function boot(Router $router): void @@ -160,4 +198,20 @@ private static function signerFromValue(string $raw): ?\PayKit\Signer\LocalSigne } return Signer::base58($trimmed); } + + private static function resolveReplayStore(Application $app, mixed $configured): ?Store + { + if ($configured === null || $configured === '') { + return null; + } + + $store = is_string($configured) ? $app->make($configured) : $configured; + if (!$store instanceof Store) { + throw new \LogicException( + 'pay_kit: paykit.x402_replay_store must resolve to a PayKit\\Store\\Store service', + ); + } + + return $store; + } } diff --git a/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php b/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php index 5553678d4..8bed21b02 100644 --- a/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php +++ b/php/src/Frameworks/Laravel/RequirePaymentMiddleware.php @@ -13,6 +13,7 @@ use PayKit\PayCore\HttpFactory; use PayKit\Payment; use PayKit\Pricing; +use PayKit\Protocols\X402\Adapter as X402Adapter; use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -41,6 +42,7 @@ public function __construct( private readonly Container $container, private readonly PsrHttpFactory $psrFactory, private readonly HttpFoundationFactory $httpFactory, + private readonly ?X402Adapter $x402 = null, ) { } @@ -81,7 +83,7 @@ public function handle(\Psr\Http\Message\ServerRequestInterface $request): \Psr\ } }; - $mw = new RequirePayment($this->client, $gateRef, $pricing); + $mw = new RequirePayment($this->client, $gateRef, $pricing, x402: $this->x402); $psrResponse = $mw->process($psrRequest, $handler); if ($psrResponse->getStatusCode() === 402) { diff --git a/php/src/Frameworks/Laravel/config/paykit.php b/php/src/Frameworks/Laravel/config/paykit.php index 4abae2b47..bba078184 100644 --- a/php/src/Frameworks/Laravel/config/paykit.php +++ b/php/src/Frameworks/Laravel/config/paykit.php @@ -13,6 +13,10 @@ 'fee_payer' => true, ], 'x402_facilitator_url' => env('PAY_KIT_X402_FACILITATOR_URL'), + // Service id or class name for a Store with durable, shared, atomic + // replay protection. Required when x402 is accepted outside + // solana_localnet; ignored by MPP-only deployments. + 'x402_replay_store' => env('PAY_KIT_X402_REPLAY_STORE'), 'mpp_challenge_binding_secret' => env('PAY_KIT_MPP_CHALLENGE_BINDING_SECRET'), 'mpp' => [ // Leave unset to derive a per-recipient realm (audit #15). Set diff --git a/php/src/Frameworks/Symfony/DependencyInjection/PayKitExtension.php b/php/src/Frameworks/Symfony/DependencyInjection/PayKitExtension.php index 69e2745cd..62e093564 100644 --- a/php/src/Frameworks/Symfony/DependencyInjection/PayKitExtension.php +++ b/php/src/Frameworks/Symfony/DependencyInjection/PayKitExtension.php @@ -10,6 +10,7 @@ use PayKit\Operator; use PayKit\Protocol; use PayKit\Protocols\Mpp\MppConfig; +use PayKit\Protocols\X402\Adapter as X402Adapter; use PayKit\Protocols\X402\X402Config; use PayKit\Signer; use PayKit\PayCore\Stablecoin; @@ -36,11 +37,22 @@ public function load(array $configs, ContainerBuilder $container): void $client = new PayKit($payKitConfig); $container->set(PayKit::class, $client); - $listener = $container->register(RequirePaymentListener::class) + $x402 = null; + if (in_array(Protocol::X402, $payKitConfig->accept, true)) { + $adapter = $container->register(X402Adapter::class, X402Adapter::class) + ->setArgument('$config', $payKitConfig); + if ($config['x402_replay_store'] !== null) { + $adapter->setArgument('$replayStore', new Reference($config['x402_replay_store'])); + } + $x402 = new Reference(X402Adapter::class); + } + + $listener = $container->register(RequirePaymentListener::class, RequirePaymentListener::class) ->setArgument('$client', new Reference(PayKit::class)) ->setArgument('$pricing', null) ->setArgument('$psrFactory', new Reference('paykit.psr_http_factory')) ->setArgument('$httpFactory', new Reference('paykit.http_foundation_factory')) + ->setArgument('$x402', $x402) ->setAutowired(true) ->setPublic(true); $listener->addTag('kernel.event_listener', [ @@ -77,6 +89,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->scalarNode('x402_facilitator_url')->defaultNull()->end() + ->scalarNode('x402_replay_store')->defaultNull()->end() ->scalarNode('mpp_challenge_binding_secret')->defaultNull()->end() ->booleanNode('preflight')->defaultTrue()->end() ->end(); diff --git a/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php b/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php index 6700d8f92..0f9c73e7e 100644 --- a/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php +++ b/php/src/Frameworks/Symfony/EventListener/RequirePaymentListener.php @@ -7,6 +7,7 @@ use PayKit\PayKit; use PayKit\Middleware\RequirePayment as PsrRequirePayment; use PayKit\Pricing; +use PayKit\Protocols\X402\Adapter as X402Adapter; use PayKit\Frameworks\Symfony\Attribute\RequirePayment; use ReflectionMethod; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; @@ -26,6 +27,7 @@ public function __construct( private readonly ?Pricing $pricing, private readonly PsrHttpFactory $psrFactory, private readonly HttpFoundationFactory $httpFactory, + private readonly ?X402Adapter $x402 = null, ) { } @@ -37,7 +39,7 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo return; } $psrRequest = $this->psrFactory->createRequest($event->getRequest()); - $middleware = new PsrRequirePayment($this->client, $attribute->gate, $this->pricing); + $middleware = new PsrRequirePayment($this->client, $attribute->gate, $this->pricing, x402: $this->x402); $psrResponse = $middleware->process( $psrRequest, new class () implements \Psr\Http\Server\RequestHandlerInterface { diff --git a/php/src/Protocols/X402/Adapter.php b/php/src/Protocols/X402/Adapter.php index e868f92ea..8587469a3 100644 --- a/php/src/Protocols/X402/Adapter.php +++ b/php/src/Protocols/X402/Adapter.php @@ -4,9 +4,13 @@ namespace PayKit\Protocols\X402; +use Brick\Math\BigInteger; +use Brick\Math\Exception\RoundingNecessaryException; use PayKit\Config; +use PayKit\Exception\ConfigurationException; use PayKit\Exception\InvalidProofException; use PayKit\Gate; +use PayKit\PayCore\Network; use PayKit\Payment; use PayKit\Protocol; use PayKit\PayCore\Rpc\RpcGateway; @@ -14,12 +18,14 @@ use PayKit\Protocols\X402\Exact\PaymentExtensions; use PayKit\Protocols\X402\Exact\Verifier; use PayKit\Store\MemoryStore; +use PayKit\Store\ReplayStoreCapability; use PayKit\Store\Store; use Psr\Http\Message\ServerRequestInterface; use RuntimeException; use SolanaPhpSdk\Keypair\Keypair; use SolanaPhpSdk\Rpc\RpcClient; use SolanaPhpSdk\Transaction\VersionedTransaction; +use SolanaPhpSdk\Util\Base58; use Throwable; /** @@ -69,6 +75,8 @@ final class Adapter * dev-only warning is emitted: a single-process memory store * loses replay protection across workers/restarts, so production * deployments MUST inject a shared atomic store (Redis, Postgres). + * An explicitly injected store on non-localnet must also declare + * the durable, shared, atomic replay capability. * @param ?RpcGateway $rpc Confirmation/broadcast gateway. Defaults to a * {@see SolanaRpcGateway} over the configured `rpcUrl`, created * lazily on first settlement. Inject a fake for unit tests. @@ -91,23 +99,55 @@ public function __construct( . 'leave X402Config::$facilitatorUrl null for self-hosted', ); } - if ($replayStore === null) { - self::warnDefaultReplayStore(); - $replayStore = new MemoryStore(); + if ( + $replayStore !== null + && $config->network !== Network::SolanaLocalnet + && (!$replayStore instanceof ReplayStoreCapability + || !$replayStore->providesDurableSharedReplayProtection()) + ) { + throw new ConfigurationException( + 'pay_kit: x402 replayStore must explicitly declare durable shared replay protection outside localnet', + ); } - $this->replayStore = $replayStore; + $this->replayStore = $replayStore ?? self::defaultReplayStore($config->network); $this->recentBlockhashProvider = $recentBlockhashProvider; $this->rpc = $rpc; } - private static function warnDefaultReplayStore(): void + /** + * Resolve the fallback replay store when the caller passed none. + * + * The in-memory default is only safe on localnet (single-process dev). Off + * localnet it is a replay hole: markers are process-local, so a restart or a + * second worker/replica would accept a replayed settlement. Fail closed + * unless `PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1` acknowledges single-process + * scope. Mirrors the MPP adapter guard. + */ + private static function defaultReplayStore(Network $network): Store { - if (function_exists('error_log')) { + $allowInMemory = getenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE') === '1'; + if ($network === Network::SolanaMainnet && $allowInMemory) { + throw new ConfigurationException( + 'pay_kit: PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 is forbidden on mainnet; ' + . 'inject a durable shared replay store', + ); + } + if ($network !== Network::SolanaLocalnet && !$allowInMemory) { + throw new ConfigurationException( + 'pay_kit: a shared replay store is required outside localnet. The default in-memory ' + . 'store is process-local, so a second replica or a restart would accept a replayed ' + . 'settlement. Inject a shared, persistent Store (ideally one with an atomic reserve, ' + . 'e.g. Redis SET NX) into the x402 adapter, or set ' + . 'PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 to acknowledge single-process replay scope.', + ); + } + if ($network !== Network::SolanaLocalnet && function_exists('error_log')) { error_log( - 'pay_kit: WARN: x402 adapter using in-memory replay store; ' - . 'dev-only. Inject a shared atomic Store (Redis/Postgres) in production.', + 'pay_kit: WARN: x402 adapter using in-memory replay store off localnet ' + . '(PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1); replay protection is process-local.', ); } + return new MemoryStore(); } private function rpc(): RpcGateway @@ -130,7 +170,7 @@ public function acceptsEntry(Gate $gate, ServerRequestInterface $request): array $asset = \PayKit\PayCore\Solana\Mints::resolve($coin, $this->config->network->mintsLabel()) ?? $coin; $tokenProgram = \PayKit\PayCore\Solana\Mints::tokenProgramFor($coin, $this->config->network->mintsLabel()); $payTo = $gate->payTo ?? $this->config->effectiveRecipient(); - $amount = (string) $gate->total()->amount->multipliedBy(1_000_000)->toInt(); + $amount = self::exactBaseUnits($gate); $signer = $this->config->effectiveX402Signer(); $extra = [ 'feePayer' => $signer?->pubkey() ?? '', @@ -162,6 +202,29 @@ public function acceptsEntry(Gate $gate, ServerRequestInterface $request): array ]; } + /** + * Convert the gate total to an exact unsigned u64 wire value. + * + * JSON can represent native PHP integers exactly through ``PHP_INT_MAX``. + * Above that boundary keep the decimal form as a string, which preserves + * the full u64 value without asking a JSON consumer to round it. + */ + private static function exactBaseUnits(Gate $gate): string + { + try { + $amount = $gate->total()->amount->multipliedBy(1_000_000)->toBigInteger(); + } catch (RoundingNecessaryException $e) { + throw new ConfigurationException( + 'pay_kit: x402 amount has more precision than the token supports', + previous: $e, + ); + } + if ($amount->isNegative() || $amount->isGreaterThan(BigInteger::of('18446744073709551615'))) { + throw new ConfigurationException('pay_kit: x402 amount must fit an unsigned u64'); + } + return (string) $amount; + } + private function fetchRecentBlockhash(): ?string { if ($this->recentBlockhashProvider !== null) { @@ -304,6 +367,14 @@ public function verifyAndSettle(Gate $gate, ServerRequestInterface $request): Pa $kp = Keypair::fromSecretKey($signer->secretKey()); $tx->partialSign($kp); $cosignedWire = $tx->serialize(verifySignatures: false); + $expectedSig = Base58::encode($tx->signatures[0]); + + // Claim before the first RPC side effect. A transport error cannot + // prove the node did not accept the transaction, so the marker is + // deliberately retained on every broadcast/confirmation ambiguity. + if (!$this->replayStore->putIfAbsent(self::REPLAY_KEY_PREFIX . $expectedSig, true)) { + throw new InvalidProofException('pay_kit: signature_consumed'); + } // Broadcast via the raw-wire path so PHP doesn't have to // reconstruct a SignedTransaction wrapper just to send. @@ -322,16 +393,8 @@ public function verifyAndSettle(Gate $gate, ServerRequestInterface $request): Pa if (!is_string($sig) || $sig === '') { throw new InvalidProofException('pay_kit: empty broadcast result'); } - - // Reserve in the replay store BETWEEN broadcast and confirmation. - // RPC has accepted the transaction, so it may land even if the - // await below times out or the process crashes. Reserving first - // means a retry of the same credential trips the consumed guard - // rather than re-settling. Mirrors the MPP SolanaChargeHandler - // (settle() reserves between sendRawTransaction and - // awaitConfirmation; PR #85 Greptile P1 / audit gap G05). - if (!$this->replayStore->putIfAbsent(self::REPLAY_KEY_PREFIX . $sig, true)) { - throw new InvalidProofException('pay_kit: signature_consumed'); + if ($sig !== $expectedSig) { + throw new InvalidProofException('pay_kit: broadcast signature mismatch'); } // Confirm BEFORE returning the payment-response success. RPC @@ -449,7 +512,7 @@ private function matchCanonicalCredential(array $envelope, array $offer): void if (!is_array($accepted)) { throw new InvalidProofException('invalid_exact_svm_payload_envelope'); } - foreach (['scheme', 'network', 'asset', 'payTo'] as $key) { + foreach (['scheme', 'network', 'asset', 'payTo', 'amount', 'maxAmountRequired'] as $key) { if (($accepted[$key] ?? null) !== ($offer[$key] ?? null)) { throw new InvalidProofException( 'pay_kit: charge_request_mismatch: ' diff --git a/php/src/Protocols/X402/Exact/Verifier.php b/php/src/Protocols/X402/Exact/Verifier.php index f82bbe3af..3ed637f8f 100644 --- a/php/src/Protocols/X402/Exact/Verifier.php +++ b/php/src/Protocols/X402/Exact/Verifier.php @@ -4,6 +4,7 @@ namespace PayKit\Protocols\X402\Exact; +use Brick\Math\BigInteger; use PayKit\Exception\InvalidProofException; use PayKit\PayCore\Solana\Mints; use SolanaPhpSdk\Keypair\PublicKey; @@ -56,7 +57,7 @@ final class Verifier * @param array $requirement The x402 accepts[] entry. * @param list $managedSigners Server-managed pubkeys (typically the facilitator). * - * @return array{program:string,source:string,mint:string,destination:string,authority:string,amount:int} + * @return array{program:string,source:string,mint:string,destination:string,authority:string,amount:string} */ public static function verify( string $transactionBase64, @@ -164,7 +165,7 @@ private static function verifyComputePrice(object $ix, array $accountKeys): void ); } $micro = self::readU64Le($data, 1); - if ($micro > self::MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS) { + if ($micro->isGreaterThan(self::MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS)) { throw new InvalidProofException( 'invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high', ); @@ -176,7 +177,7 @@ private static function verifyComputePrice(object $ix, array $accountKeys): void * @param list $accountKeys * @param array $requirement * @param list $managedSigners - * @return array{program:string,source:string,mint:string,destination:string,authority:string,amount:int} + * @return array{program:string,source:string,mint:string,destination:string,authority:string,amount:string} */ private static function verifyTransfer( object $ix, @@ -240,7 +241,7 @@ private static function verifyTransfer( // Rule 8: amount match. $amount = self::readU64Le($data, 1); $expectedAmount = self::amountField($requirement); - if ($amount !== $expectedAmount) { + if (!$amount->isEqualTo($expectedAmount)) { throw new InvalidProofException('invalid_exact_svm_payload_amount_mismatch'); } @@ -250,7 +251,7 @@ private static function verifyTransfer( 'mint' => $mint, 'destination' => $destination, 'authority' => $authority, - 'amount' => $amount, + 'amount' => (string) $amount, ]; } @@ -325,24 +326,56 @@ private static function stringExtra(array $requirement, string $key, bool $requi return is_string($v) ? $v : null; } + /** Maximum unsigned u64, the wire upper bound for base-unit amounts. */ + private static function u64Max(): BigInteger + { + return BigInteger::of('18446744073709551615'); + } + /** + * Parses the requirement amount into an exact unsigned u64. Wire + * amounts are u64, but PHP's native int is signed 64-bit: `(int)` on a + * decimal string above PHP_INT_MAX saturates, so a high-bit amount + * would never compare equal to (or, worse, could be made to collide + * with) the transferred amount. Anything that is not a canonical + * non-negative integer within the u64 range fails closed. + * * @param array $requirement */ - private static function amountField(array $requirement): int + private static function amountField(array $requirement): BigInteger { $v = $requirement['amount'] ?? $requirement['maxAmountRequired'] ?? null; - if (!is_string($v) && !is_int($v)) { + if (is_int($v)) { + if ($v < 0) { + throw new InvalidProofException('invalid_exact_svm_payload_missing_field_amount'); + } + return BigInteger::of($v); + } + if (!is_string($v) || preg_match('/^[0-9]+$/', $v) !== 1) { throw new InvalidProofException('invalid_exact_svm_payload_missing_field_amount'); } - return (int) $v; + $normalized = ltrim($v, '0'); + $amount = BigInteger::of($normalized === '' ? '0' : $normalized); + if ($amount->isGreaterThan(self::u64Max())) { + throw new InvalidProofException('invalid_exact_svm_payload_missing_field_amount'); + } + return $amount; } - private static function readU64Le(string $data, int $offset): int + /** + * Reads a little-endian u64 as an exact unsigned value. `unpack('P')` + * yields a signed PHP int, so values in [2^63, 2^64) come back + * negative; rebuild from two unsigned 32-bit halves instead. + */ + private static function readU64Le(string $data, int $offset): BigInteger { if (strlen($data) < $offset + 8) { throw new InvalidProofException('invalid_exact_svm_payload_no_transfer_instruction'); } - $b = unpack('P', substr($data, $offset, 8)); - return $b === false ? 0 : (int) $b[1]; + $parts = unpack('Vlo/Vhi', substr($data, $offset, 8)); + if ($parts === false) { + throw new InvalidProofException('invalid_exact_svm_payload_no_transfer_instruction'); + } + return BigInteger::of($parts['hi'])->shiftedLeft(32)->plus($parts['lo']); } } diff --git a/php/tests/Frameworks/ReplayStoreInjectionTest.php b/php/tests/Frameworks/ReplayStoreInjectionTest.php new file mode 100644 index 000000000..33d04d4a8 --- /dev/null +++ b/php/tests/Frameworks/ReplayStoreInjectionTest.php @@ -0,0 +1,575 @@ + */ + private array $factories; + + public function __construct(mixed ...$factories) + { + $this->factories = $factories; + } + + public function createRequest(mixed $request): \Psr\Http\Message\ServerRequestInterface + { + $method = method_exists($request, 'getMethod') ? $request->getMethod() : 'GET'; + $uri = method_exists($request, 'getRequestUri') ? $request->getRequestUri() : '/paid'; + + return (new \Nyholm\Psr7\Factory\Psr17Factory())->createServerRequest($method, $uri); + } + } + } + + if (!class_exists(HttpFoundationFactory::class)) { + final class HttpFoundationFactory + { + public function createResponse(\Psr\Http\Message\ResponseInterface $response): \Symfony\Component\HttpFoundation\Response + { + return new \Symfony\Component\HttpFoundation\Response( + (string) $response->getBody(), + $response->getStatusCode(), + $response->getHeaders(), + ); + } + } + } +} + +namespace Illuminate\Contracts\Container { + if (!interface_exists(Container::class)) { + interface Container + { + public function bound(string $abstract): bool; + + public function make(string $abstract): mixed; + } + } +} + +namespace Illuminate\Contracts\Foundation { + if (!interface_exists(Application::class)) { + interface Application extends \Illuminate\Contracts\Container\Container, \ArrayAccess + { + public function singleton(string $abstract, \Closure $concrete): void; + + public function bind(string $abstract, \Closure $concrete): void; + + public function configPath(string $path = ''): string; + } + } +} + +namespace Illuminate\Support { + if (!class_exists(ServiceProvider::class)) { + abstract class ServiceProvider + { + public function __construct(protected \Illuminate\Contracts\Foundation\Application $app) + { + } + + protected function mergeConfigFrom(string $path, string $key): void + { + } + + /** @param array $paths */ + protected function publishes(array $paths, ?string $group = null): void + { + } + } + } +} + +namespace Illuminate\Routing { + if (!class_exists(Router::class)) { + final class Router + { + /** @var array */ + private array $aliases = []; + + /** @param class-string $class */ + public function aliasMiddleware(string $name, string $class): void + { + $this->aliases[$name] = $class; + } + + public function middlewareAlias(string $name): ?string + { + return $this->aliases[$name] ?? null; + } + } + } +} + +namespace Illuminate\Http { + if (!class_exists(Request::class)) { + final class Request + { + public object $attributes; + + public function __construct() + { + $this->attributes = new class () { + /** @var array */ + private array $values = []; + + public function get(string $key): mixed + { + return $this->values[$key] ?? null; + } + + public function set(string $key, mixed $value): void + { + $this->values[$key] = $value; + } + }; + } + } + } +} + +namespace PayKit\Tests\Frameworks { + use Illuminate\Contracts\Container\Container as LaravelContainerContract; + use Illuminate\Contracts\Foundation\Application as LaravelApplicationContract; + use Illuminate\Http\Request as LaravelRequest; + use Illuminate\Routing\Router as LaravelRouter; + use Nyholm\Psr7\Factory\Psr17Factory; + use PayKit\Config; + use PayKit\Exception\ConfigurationException; + use PayKit\Frameworks\Laravel\PayKitServiceProvider; + use PayKit\Frameworks\Laravel\RequirePaymentMiddleware; + use PayKit\Frameworks\Symfony\Attribute\RequirePayment as SymfonyRequirePayment; + use PayKit\Frameworks\Symfony\DependencyInjection\PayKitExtension; + use PayKit\Frameworks\Symfony\EventListener\RequirePaymentListener; + use PayKit\Gate; + use PayKit\Operator; + use PayKit\PayCore\Network; + use PayKit\PayKit; + use PayKit\Price; + use PayKit\Pricing; + use PayKit\Protocol; + use PayKit\Protocols\Mpp\MppConfig; + use PayKit\Protocols\X402\Adapter as X402Adapter; + use PayKit\Signer; + use PayKit\Store\MemoryStore; + use PayKit\Store\ReplayStoreCapability; + use PayKit\Store\Store; + use PHPUnit\Framework\TestCase; + use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; + use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; + use Symfony\Component\DependencyInjection\ContainerBuilder; + use Symfony\Component\DependencyInjection\Reference; + use Symfony\Component\HttpFoundation\Request as SymfonyRequest; + use Symfony\Component\HttpFoundation\Response; + use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; + use Symfony\Component\HttpKernel\HttpKernelInterface; + + final class ReplayStoreInjectionTest extends TestCase + { + private PayKit $client; + + protected function setUp(): void + { + $signer = Signer::generate(); + $this->client = new PayKit(new Config( + network: Network::SolanaDevnet, + accept: [Protocol::X402], + operator: new Operator( + recipient: $signer->pubkey(), + signer: $signer, + feePayer: true, + ), + mpp: new MppConfig( + challengeBindingSecret: 'framework-test-secret-0123456789abcdef', + replayStore: new FrameworkDurableSharedReplayStore(), + ), + preflight: false, + )); + } + + public function testLaravelUsesInjectedX402Adapter(): void + { + $middleware = new RequirePaymentMiddleware( + $this->client, + new FrameworkLaravelContainer($this->pricing()), + $this->psrFactory(), + new HttpFoundationFactory(), + $this->x402Adapter(), + ); + + $response = $middleware->handle( + new LaravelRequest(), + static fn (): Response => new Response('next'), + 'paid', + ); + + self::assertSame(402, $response->getStatusCode()); + } + + public function testLaravelFailsClosedWithoutAnInjectedX402Adapter(): void + { + $middleware = new RequirePaymentMiddleware( + $this->client, + new FrameworkLaravelContainer($this->pricing()), + $this->psrFactory(), + new HttpFoundationFactory(), + ); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('shared replay store is required outside localnet'); + $middleware->handle(new LaravelRequest(), static fn (): Response => new Response('next'), 'paid'); + } + + public function testLaravelMppOnlyProviderBootsAndRunsWithoutX402State(): void + { + $app = $this->laravelApplication([ + 'network' => 'solana_localnet', + 'accept' => ['mpp'], + 'preflight' => false, + 'mpp_challenge_binding_secret' => 'framework-test-secret-0123456789abcdef', + 'x402_replay_store' => 'missing.x402.replay_store', + ]); + $provider = new PayKitServiceProvider($app); + + $provider->register(); + $router = new LaravelRouter(); + $provider->boot($router); + + self::assertFalse($app->bound(X402Adapter::class)); + self::assertSame(RequirePaymentMiddleware::class, $router->middlewareAlias('paykit')); + $middleware = $app->make(RequirePaymentMiddleware::class); + self::assertInstanceOf(RequirePaymentMiddleware::class, $middleware); + + $response = $middleware->handle( + new LaravelRequest(), + static fn (): Response => new Response('next'), + 'paid', + ); + self::assertSame(402, $response->getStatusCode()); + self::assertNotSame('', $response->headers->get('www-authenticate', '')); + } + + public function testLaravelDefaultProtocolsStillRegisterX402Adapter(): void + { + $app = $this->laravelApplication([ + 'network' => 'solana_localnet', + 'preflight' => false, + 'mpp_challenge_binding_secret' => 'framework-test-secret-0123456789abcdef', + ]); + + (new PayKitServiceProvider($app))->register(); + + self::assertTrue($app->bound(X402Adapter::class)); + self::assertInstanceOf(RequirePaymentMiddleware::class, $app->make(RequirePaymentMiddleware::class)); + } + + public function testSymfonyUsesInjectedX402Adapter(): void + { + $listener = new RequirePaymentListener( + $this->client, + $this->pricing(), + $this->psrFactory(), + new HttpFoundationFactory(), + $this->x402Adapter(), + ); + $event = $this->symfonyEvent(); + + $listener->onKernelControllerArguments($event); + + $response = ($event->getController())(); + self::assertInstanceOf(Response::class, $response); + self::assertSame(402, $response->getStatusCode()); + } + + public function testSymfonyFailsClosedWithoutAnInjectedX402Adapter(): void + { + $listener = new RequirePaymentListener( + $this->client, + $this->pricing(), + $this->psrFactory(), + new HttpFoundationFactory(), + ); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('shared replay store is required outside localnet'); + $listener->onKernelControllerArguments($this->symfonyEvent()); + } + + public function testSymfonyMppOnlyContainerBootsAndRunsWithoutX402State(): void + { + $container = new ContainerBuilder(); + $container->set('paykit.psr_http_factory', $this->psrFactory()); + $container->set('paykit.http_foundation_factory', new HttpFoundationFactory()); + + (new PayKitExtension())->load([[ + 'network' => 'solana_localnet', + 'accept' => ['mpp'], + 'preflight' => false, + 'mpp_challenge_binding_secret' => 'framework-test-secret-0123456789abcdef', + 'x402_replay_store' => 'missing.x402.replay_store', + ]], $container); + + self::assertFalse($container->hasDefinition(X402Adapter::class)); + $listenerDefinition = $container->getDefinition(RequirePaymentListener::class); + self::assertNull($listenerDefinition->getArgument('$x402')); + $listenerDefinition->setArgument('$pricing', $this->pricing()); + + $listener = $container->get(RequirePaymentListener::class); + self::assertInstanceOf(RequirePaymentListener::class, $listener); + $event = $this->symfonyEvent(); + $listener->onKernelControllerArguments($event); + + $response = ($event->getController())(); + self::assertInstanceOf(Response::class, $response); + self::assertSame(402, $response->getStatusCode()); + self::assertNotSame('', $response->headers->get('www-authenticate', '')); + } + + public function testSymfonyDefaultProtocolsInjectConfiguredReplayStoreIntoTheX402Adapter(): void + { + $container = new ContainerBuilder(); + $container->set('app.replay_store', new FrameworkDurableSharedReplayStore()); + $container->set('paykit.psr_http_factory', new \stdClass()); + $container->set('paykit.http_foundation_factory', new \stdClass()); + + (new PayKitExtension())->load([[ + 'network' => 'solana_devnet', + 'preflight' => false, + 'x402_replay_store' => 'app.replay_store', + ]], $container); + + $adapter = $container->getDefinition(X402Adapter::class); + $replayStore = $adapter->getArgument('$replayStore'); + self::assertInstanceOf(Reference::class, $replayStore); + self::assertSame('app.replay_store', (string) $replayStore); + + $listener = $container->getDefinition(RequirePaymentListener::class); + $listenerAdapter = $listener->getArgument('$x402'); + self::assertInstanceOf(Reference::class, $listenerAdapter); + self::assertSame(X402Adapter::class, (string) $listenerAdapter); + self::assertInstanceOf(X402Adapter::class, $container->get(X402Adapter::class)); + } + + private function pricing(): Pricing + { + return new class () extends Pricing { + public readonly Gate $paid; + + public function __construct() + { + $this->paid = new Gate(amount: Price::usd('0.10')); + } + }; + } + + private function x402Adapter(): X402Adapter + { + return new X402Adapter( + $this->client->config, + replayStore: new FrameworkDurableSharedReplayStore(), + recentBlockhashProvider: fn () => null, + ); + } + + /** @param array $config */ + private function laravelApplication(array $config): FrameworkLaravelApplication + { + $app = new FrameworkLaravelApplication($config); + $app->instance(Pricing::class, $this->pricing()); + $app->instance(PsrHttpFactory::class, $this->psrFactory()); + $app->instance(HttpFoundationFactory::class, new HttpFoundationFactory()); + + return $app; + } + + private function psrFactory(): PsrHttpFactory + { + $constructor = (new \ReflectionClass(PsrHttpFactory::class))->getConstructor(); + $arguments = array_fill(0, $constructor?->getNumberOfRequiredParameters() ?? 0, new Psr17Factory()); + + return new PsrHttpFactory(...$arguments); + } + + private function symfonyEvent(): ControllerArgumentsEvent + { + return new ControllerArgumentsEvent( + new FrameworkKernel(), + [new FrameworkPaidController(), 'paid'], + [], + SymfonyRequest::create('/paid'), + HttpKernelInterface::MAIN_REQUEST, + ); + } + } + + final class FrameworkLaravelContainer implements LaravelContainerContract + { + public function __construct(private readonly Pricing $pricing) + { + } + + public function bound(string $abstract): bool + { + return $abstract === Pricing::class; + } + + public function make(string $abstract): mixed + { + if ($abstract === Pricing::class) { + return $this->pricing; + } + + throw new \LogicException("Unexpected Laravel container resolution: $abstract"); + } + } + + final class FrameworkLaravelApplication implements LaravelApplicationContract + { + /** @var array */ + private array $instances = []; + + /** @var array */ + private array $singletons = []; + + /** @var array */ + private array $bindings = []; + + /** @param array $config */ + public function __construct(array $config) + { + $this->instances['config'] = new FrameworkLaravelConfigRepository(['paykit' => $config]); + } + + public function singleton(string $abstract, \Closure $concrete): void + { + $this->singletons[$abstract] = $concrete; + } + + public function bind(string $abstract, \Closure $concrete): void + { + $this->bindings[$abstract] = $concrete; + } + + public function configPath(string $path = ''): string + { + return '/tmp/' . ltrim($path, '/'); + } + + public function instance(string $abstract, object $instance): void + { + $this->instances[$abstract] = $instance; + } + + public function bound(string $abstract): bool + { + return isset($this->instances[$abstract]) + || isset($this->singletons[$abstract]) + || isset($this->bindings[$abstract]); + } + + public function make(string $abstract): mixed + { + if (isset($this->instances[$abstract])) { + return $this->instances[$abstract]; + } + if (isset($this->singletons[$abstract])) { + $instance = ($this->singletons[$abstract])($this); + if (!is_object($instance)) { + throw new \LogicException("Laravel singleton did not resolve an object: $abstract"); + } + return $this->instances[$abstract] = $instance; + } + if (isset($this->bindings[$abstract])) { + return ($this->bindings[$abstract])($this); + } + + throw new \LogicException("Unexpected Laravel application resolution: $abstract"); + } + + public function offsetExists(mixed $offset): bool + { + return is_string($offset) && isset($this->instances[$offset]); + } + + public function offsetGet(mixed $offset): mixed + { + return is_string($offset) ? $this->make($offset) : null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + if (!is_string($offset) || !is_object($value)) { + throw new \LogicException('Laravel application offsets require a string and object'); + } + $this->instances[$offset] = $value; + } + + public function offsetUnset(mixed $offset): void + { + if (is_string($offset)) { + unset($this->instances[$offset]); + } + } + } + + final class FrameworkLaravelConfigRepository + { + /** @param array $values */ + public function __construct(private readonly array $values) + { + } + + public function get(string $key, mixed $default = null): mixed + { + $value = $this->values; + foreach (explode('.', $key) as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + return $default; + } + $value = $value[$segment]; + } + + return $value; + } + } + + final class FrameworkKernel implements HttpKernelInterface + { + public function handle(SymfonyRequest $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } + } + + final class FrameworkPaidController + { + #[SymfonyRequirePayment('paid')] + public function paid(): Response + { + return new Response('paid'); + } + } + + final class FrameworkDurableSharedReplayStore implements Store, ReplayStoreCapability + { + private MemoryStore $store; + + public function __construct() + { + $this->store = new MemoryStore(); + } + + public function putIfAbsent(string $key, mixed $value): bool + { + return $this->store->putIfAbsent($key, $value); + } + + public function providesDurableSharedReplayProtection(): bool + { + return true; + } + } +} diff --git a/php/tests/Harness/HarnessReplayStoreTest.php b/php/tests/Harness/HarnessReplayStoreTest.php new file mode 100644 index 000000000..13dd2f223 --- /dev/null +++ b/php/tests/Harness/HarnessReplayStoreTest.php @@ -0,0 +1,80 @@ +directory = sys_get_temp_dir() . '/pay-kit-harness-replay-' . bin2hex(random_bytes(8)); + } + + protected function tearDown(): void + { + foreach (glob($this->directory . '/*') ?: [] as $path) { + unlink($path); + } + if (is_dir($this->directory)) { + rmdir($this->directory); + } + } + + public function testSatisfiesTheExplicitCapabilityAcrossHarnessWorkers(): void + { + $store = new HarnessReplayStore($this->directory); + + self::assertInstanceOf(Store::class, $store); + self::assertInstanceOf(ReplayStoreCapability::class, $store); + self::assertTrue($store->providesDurableSharedReplayProtection()); + self::assertTrue($store->putIfAbsent('settlement', 'first')); + self::assertFalse( + (new HarnessReplayStore($this->directory))->putIfAbsent('settlement', 'second'), + 'a second harness worker must observe the first worker replay marker', + ); + + $script = sprintf( + 'require %s; require %s; $store = new %s(%s); exit($store->putIfAbsent(%s, %s) ? 1 : 0);', + var_export(dirname(__DIR__, 2) . '/vendor/autoload.php', true), + var_export(dirname(__DIR__, 3) . '/harness/php-server/HarnessReplayStore.php', true), + '\\' . HarnessReplayStore::class, + var_export($this->directory, true), + var_export('settlement', true), + var_export('child', true), + ); + exec(escapeshellarg(PHP_BINARY) . ' -r ' . escapeshellarg($script), $output, $exitCode); + self::assertSame(0, $exitCode, 'a restarted PHP worker must observe the existing replay marker'); + + $signer = Signer::generate(); + $adapter = new Adapter( + new Config( + network: Network::SolanaDevnet, + operator: new Operator( + recipient: $signer->pubkey(), + signer: $signer, + feePayer: true, + ), + preflight: false, + ), + replayStore: $store, + recentBlockhashProvider: fn () => null, + ); + + self::assertInstanceOf(Adapter::class, $adapter); + } +} diff --git a/php/tests/Middleware/RequirePaymentTest.php b/php/tests/Middleware/RequirePaymentTest.php index c124f4ef0..22ade68ab 100644 --- a/php/tests/Middleware/RequirePaymentTest.php +++ b/php/tests/Middleware/RequirePaymentTest.php @@ -7,6 +7,7 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PayKit\PayKit; use PayKit\Config; +use PayKit\Exception\ConfigurationException; use PayKit\PayCore\Currency; use PayKit\Gate; use PayKit\Middleware\RequirePayment; @@ -17,6 +18,7 @@ use PayKit\Pricing; use PayKit\Protocol; use PayKit\Protocols\Mpp\MppConfig; +use PayKit\Protocols\X402\Adapter as X402Adapter; use PayKit\Signer; use PayKit\Store\MemoryStore; use PayKit\Store\ReplayStoreCapability; @@ -45,6 +47,23 @@ protected function setUp(): void $this->factory = new Psr17Factory(); } + /** + * @param Gate|string|\Closure(ServerRequestInterface):Gate $gateRef + */ + private function middleware(Gate|string|\Closure $gateRef, ?Pricing $pricing = null): RequirePayment + { + return new RequirePayment( + $this->client, + $gateRef, + $pricing, + x402: new X402Adapter( + $this->client->config, + replayStore: new MiddlewareDurableSharedReplayStore(), + recentBlockhashProvider: fn () => null, + ), + ); + } + private function nextHandler(): RequestHandlerInterface { $factory = $this->factory; @@ -64,7 +83,7 @@ public function handle(ServerRequestInterface $req): ResponseInterface public function testEmits402WhenNoCredentialPresent(): void { $gate = new Gate(amount: Price::usd('0.10')); - $mw = new RequirePayment($this->client, $gate); + $mw = $this->middleware($gate); $request = $this->factory->createServerRequest('GET', '/paid'); $response = $mw->process($request, $this->nextHandler()); $this->assertSame(402, $response->getStatusCode()); @@ -76,7 +95,7 @@ public function testEmits402WhenNoCredentialPresent(): void public function test402BodyCarriesAcceptsEntries(): void { $gate = new Gate(amount: Price::usd('0.10')); - $mw = new RequirePayment($this->client, $gate); + $mw = $this->middleware($gate); $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); $body = json_decode((string) $response->getBody(), true); $this->assertGreaterThanOrEqual(1, count($body['accepts'])); @@ -88,7 +107,7 @@ public function test402SetsCacheControlNoStore(): void // cached. Without no-store a CDN could replay a stale challenge // (different blockhash / expiry / amount) to a later client. $gate = new Gate(amount: Price::usd('0.10')); - $mw = new RequirePayment($this->client, $gate); + $mw = $this->middleware($gate); $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); $this->assertSame(402, $response->getStatusCode()); $this->assertSame('no-store', $response->getHeaderLine('cache-control')); @@ -97,7 +116,7 @@ public function test402SetsCacheControlNoStore(): void public function testWwwAuthenticateHeaderStampedFromMpp(): void { $gate = new Gate(amount: Price::usd('0.10')); - $mw = new RequirePayment($this->client, $gate); + $mw = $this->middleware($gate); $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); $this->assertNotEmpty($response->getHeaderLine('www-authenticate')); } @@ -111,7 +130,7 @@ public function __construct() $this->reportGate = new Gate(amount: Price::usd('0.10')); } }; - $mw = new RequirePayment($this->client, 'reportGate', $pricing); + $mw = $this->middleware('reportGate', $pricing); $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); $this->assertSame(402, $response->getStatusCode()); } @@ -119,14 +138,14 @@ public function __construct() public function testClosureGateInvoked(): void { $closure = fn (ServerRequestInterface $req): Gate => new Gate(amount: Price::usd('0.25')); - $mw = new RequirePayment($this->client, $closure); + $mw = $this->middleware($closure); $response = $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); $this->assertSame(402, $response->getStatusCode()); } public function testStringHandleWithoutPricingRaises(): void { - $mw = new RequirePayment($this->client, 'reportGate'); + $mw = $this->middleware('reportGate'); $this->expectException(\LogicException::class); $mw->process($this->factory->createServerRequest('GET', '/paid'), $this->nextHandler()); } @@ -134,7 +153,7 @@ public function testStringHandleWithoutPricingRaises(): void public function testMalformedAuthorizationFallsThroughTo402(): void { $gate = new Gate(amount: Price::usd('0.10')); - $mw = new RequirePayment($this->client, $gate); + $mw = $this->middleware($gate); $request = $this->factory->createServerRequest('GET', '/paid') ->withHeader('Authorization', 'Payment garbage-not-valid'); $response = $mw->process($request, $this->nextHandler()); @@ -170,6 +189,14 @@ public function testRequirePaymentNamespaceFunctionRaisesWithoutPayment(): void $this->expectException(\PayKit\Exception\PaymentRequiredException::class); \PayKit\Middleware\requirePayment($request); } + + public function testMissingX402ReplayAdapterFailsClosedOnDevnet(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('shared replay store is required outside localnet'); + + new RequirePayment($this->client, new Gate(amount: Price::usd('0.10'))); + } } final class MiddlewareDurableSharedReplayStore implements Store, ReplayStoreCapability diff --git a/php/tests/Protocols/X402/AdapterTest.php b/php/tests/Protocols/X402/AdapterTest.php index dda611586..4f1e1c1b4 100644 --- a/php/tests/Protocols/X402/AdapterTest.php +++ b/php/tests/Protocols/X402/AdapterTest.php @@ -6,11 +6,15 @@ use Nyholm\Psr7\Factory\Psr17Factory; use PayKit\Config; +use PayKit\Exception\ConfigurationException; use PayKit\Exception\InvalidProofException; use PayKit\Gate; use PayKit\PayCore\Network; use PayKit\Operator; use PayKit\Price; +use PayKit\Store\FileStore; +use PayKit\Store\MemoryStore; +use PayKit\Store\Store; use PayKit\Protocols\X402\Adapter; use PayKit\Protocols\X402\X402Config; use PayKit\Signer; @@ -18,10 +22,12 @@ final class AdapterTest extends TestCase { - private function makeConfig(?X402Config $x402 = null): Config - { + private function makeConfig( + ?X402Config $x402 = null, + Network $network = Network::SolanaDevnet, + ): Config { return new Config( - network: Network::SolanaDevnet, + network: $network, operator: new Operator( recipient: Signer::generate()->pubkey(), signer: Signer::generate(), @@ -32,10 +38,15 @@ private function makeConfig(?X402Config $x402 = null): Config ); } + private function replayStore(): Store + { + return new ExplicitCapabilityTestReplayStore(); + } + public function testAcceptsEntryHasCanonicalX402Shape(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => 'BLOCKHASH-STUB'); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => 'BLOCKHASH-STUB'); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); $entry = $adapter->acceptsEntry($gate, $req); @@ -49,11 +60,57 @@ public function testAcceptsEntryHasCanonicalX402Shape(): void $this->assertNotEmpty($entry['extra']['feePayer']); } + public function testAcceptsEntrySupportsExactMaximumU64Amount(): void + { + $adapter = new Adapter( + $this->makeConfig(), + replayStore: $this->replayStore(), + recentBlockhashProvider: fn () => null, + ); + $gate = new Gate(amount: Price::usd('18446744073709.551615')); + $request = (new Psr17Factory())->createServerRequest('GET', '/paid'); + + $entry = $adapter->acceptsEntry($gate, $request); + + $this->assertSame('18446744073709551615', $entry['amount']); + $this->assertSame('18446744073709551615', $entry['maxAmountRequired']); + } + + public function testAcceptsEntryRejectsAmountAboveU64(): void + { + $adapter = new Adapter( + $this->makeConfig(), + replayStore: $this->replayStore(), + recentBlockhashProvider: fn () => null, + ); + $gate = new Gate(amount: Price::usd('18446744073709.551616')); + $request = (new Psr17Factory())->createServerRequest('GET', '/paid'); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('unsigned u64'); + $adapter->acceptsEntry($gate, $request); + } + + public function testAcceptsEntryRejectsFractionalBaseUnit(): void + { + $adapter = new Adapter( + $this->makeConfig(), + replayStore: $this->replayStore(), + recentBlockhashProvider: fn () => null, + ); + $gate = new Gate(amount: Price::usd('0.0000001')); + $request = (new Psr17Factory())->createServerRequest('GET', '/paid'); + + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('more precision'); + $adapter->acceptsEntry($gate, $request); + } + public function testAcceptsEntryEmbedsRecentBlockhash(): void { // Ruby PR #142 caveat #5. $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => 'ABC123BLOCKHASH'); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => 'ABC123BLOCKHASH'); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); $entry = $adapter->acceptsEntry($gate, $req); @@ -63,7 +120,7 @@ public function testAcceptsEntryEmbedsRecentBlockhash(): void public function testAcceptsEntryOmitsBlockhashWhenProviderReturnsNull(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => null); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); $entry = $adapter->acceptsEntry($gate, $req); @@ -73,7 +130,7 @@ public function testAcceptsEntryOmitsBlockhashWhenProviderReturnsNull(): void public function testChallengeHeadersAreBase64JsonEnvelope(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => null); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); $headers = $adapter->challengeHeaders($gate, $req); @@ -93,10 +150,102 @@ public function testDelegatedModeRejectedAtConstruction(): void new Adapter($cfg); } + public function testRequiresSharedStoreOffLocalnet(): void + { + // H3: off localnet, constructing without a store must fail closed rather + // than silently use the process-local in-memory store. + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'); + $cfg = $this->makeConfig(); // SolanaDevnet + $this->expectException(ConfigurationException::class); + new Adapter($cfg, recentBlockhashProvider: fn () => null); + } + + public function testInMemoryOptOutAllowedOffLocalnet(): void + { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1'); + try { + $adapter = new Adapter($this->makeConfig(), recentBlockhashProvider: fn () => null); + $this->assertInstanceOf(Adapter::class, $adapter); + } finally { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'); + } + } + + public function testInMemoryOptOutRejectedOnMainnet(): void + { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1'); + try { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('forbidden on mainnet'); + new Adapter( + $this->makeConfig(network: Network::SolanaMainnet), + recentBlockhashProvider: fn () => null, + ); + } finally { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'); + } + } + + public function testRejectsMemoryStoreInjectionOffLocalnet(): void + { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1'); + try { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('must explicitly declare durable shared replay protection'); + new Adapter( + $this->makeConfig(), + replayStore: new MemoryStore(), + recentBlockhashProvider: fn () => null, + ); + } finally { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'); + } + } + + public function testRejectsFileStoreInjectionOffLocalnet(): void + { + $directory = sys_get_temp_dir() . '/pay-kit-x402-' . bin2hex(random_bytes(8)); + try { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage('must explicitly declare durable shared replay protection'); + new Adapter( + $this->makeConfig(), + replayStore: new FileStore($directory), + recentBlockhashProvider: fn () => null, + ); + } finally { + if (is_dir($directory)) { + rmdir($directory); + } + } + } + + public function testAcceptsDurableSharedReplayStoreOffLocalnet(): void + { + $adapter = new Adapter( + $this->makeConfig(), + replayStore: $this->replayStore(), + recentBlockhashProvider: fn () => null, + ); + + $this->assertInstanceOf(Adapter::class, $adapter); + } + + public function testAllowsMemoryStoreInjectionOnLocalnet(): void + { + $adapter = new Adapter( + $this->makeConfig(network: Network::SolanaLocalnet), + replayStore: new MemoryStore(), + recentBlockhashProvider: fn () => null, + ); + + $this->assertInstanceOf(Adapter::class, $adapter); + } + public function testVerifyAndSettleRaisesWithoutPaymentSignature(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => null); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid'); $this->expectException(InvalidProofException::class); @@ -106,7 +255,7 @@ public function testVerifyAndSettleRaisesWithoutPaymentSignature(): void public function testVerifyAndSettleRaisesOnMalformedBase64(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => null); $gate = new Gate(amount: Price::usd('0.10')); $req = (new Psr17Factory())->createServerRequest('GET', '/paid') ->withHeader('Payment-Signature', '@@@-not-base64-@@@'); @@ -117,7 +266,7 @@ public function testVerifyAndSettleRaisesOnMalformedBase64(): void public function testVerifyAndSettleRaisesOnInvalidVersion(): void { $cfg = $this->makeConfig(); - $adapter = new Adapter($cfg, recentBlockhashProvider: fn () => null); + $adapter = new Adapter($cfg, replayStore: $this->replayStore(), recentBlockhashProvider: fn () => null); $gate = new Gate(amount: Price::usd('0.10')); $envelope = base64_encode(json_encode(['x402Version' => 99]) ?: '{}'); $req = (new Psr17Factory())->createServerRequest('GET', '/paid') diff --git a/php/tests/Protocols/X402/ConfirmationTest.php b/php/tests/Protocols/X402/ConfirmationTest.php index 78d275800..59c2c530c 100644 --- a/php/tests/Protocols/X402/ConfirmationTest.php +++ b/php/tests/Protocols/X402/ConfirmationTest.php @@ -43,6 +43,7 @@ private function makeAdapter(FakeRpcGateway $rpc, int $attempts = 3): Adapter ); return new Adapter( $config, + replayStore: new ExplicitCapabilityTestReplayStore(), recentBlockhashProvider: fn () => null, rpc: $rpc, confirmationAttempts: $attempts, diff --git a/php/tests/Protocols/X402/Exact/AtaCreateRejectTest.php b/php/tests/Protocols/X402/Exact/AtaCreateRejectTest.php index 8ebaec045..1bba6902c 100644 --- a/php/tests/Protocols/X402/Exact/AtaCreateRejectTest.php +++ b/php/tests/Protocols/X402/Exact/AtaCreateRejectTest.php @@ -48,6 +48,8 @@ private function buildTransaction( ?string $authority = null, array $signerTail = [], string $tokenProgram = self::TOKEN_PROGRAM, + string $amount = '100000', + ?string $amountWire = null, ): array { $payer = Keypair::generate()->getPublicKey()->toBase58(); $authority ??= Keypair::generate()->getPublicKey()->toBase58(); @@ -55,12 +57,12 @@ private function buildTransaction( $payTo = Keypair::generate()->getPublicKey()->toBase58(); $mint = self::USDC_MINT; $destination = Mints::deriveAta($payTo, $mint, $tokenProgram); - $amount = 100000; + $amountWire ??= pack('P', (int) $amount); // Instruction data blobs. $computeLimitData = chr(2) . pack('V', 200000); // tag 2 + u32 $computePriceData = chr(3) . pack('P', 1); // tag 3 + u64 - $transferData = chr(12) . pack('P', $amount) . chr(6); // tag 12 + u64 + u8 decimals + $transferData = chr(12) . $amountWire . chr(6); // tag 12 + u64 + u8 decimals // Build the ordered account-key table and an index resolver. $keys = []; @@ -143,10 +145,20 @@ public function testTransactionWithoutOptionalsVerifies(): void [$tx, $req] = $this->buildTransaction([]); $result = Verifier::verify($tx, $req, [$this->unrelatedManagedSigner()]); $this->assertSame(self::TOKEN_PROGRAM, $result['program']); - $this->assertSame(100000, $result['amount']); + $this->assertSame('100000', $result['amount']); $this->assertArrayNotHasKey('destinationCreateAta', $result); } + public function testUnsignedU64MaxAmountVerifiesThroughFullWirePath(): void + { + $max = '18446744073709551615'; + [$tx, $req] = $this->buildTransaction([], amount: $max, amountWire: str_repeat("\xff", 8)); + + $result = Verifier::verify($tx, $req, [$this->unrelatedManagedSigner()]); + + $this->assertSame($max, $result['amount']); + } + public function testLighthouseOptionalInstructionAccepted(): void { // Wallets (Phantom 1, Solflare 2) inject Lighthouse guard @@ -156,7 +168,7 @@ public function testLighthouseOptionalInstructionAccepted(): void ['program' => self::LIGHTHOUSE, 'data' => chr(0), 'accounts' => []], ]); $result = Verifier::verify($tx, $req, [$this->unrelatedManagedSigner()]); - $this->assertSame(100000, $result['amount']); + $this->assertSame('100000', $result['amount']); } public function testMemoOptionalInstructionAccepted(): void @@ -165,7 +177,7 @@ public function testMemoOptionalInstructionAccepted(): void ['program' => self::MEMO_PROGRAM, 'data' => 'abc123nonce', 'accounts' => []], ]); $result = Verifier::verify($tx, $req, [$this->unrelatedManagedSigner()]); - $this->assertSame(100000, $result['amount']); + $this->assertSame('100000', $result['amount']); } public function testOfferWithoutExtraTokenProgramStillVerifies(): void @@ -182,7 +194,7 @@ public function testOfferWithoutExtraTokenProgramStillVerifies(): void $result = Verifier::verify($tx, $req, [$this->unrelatedManagedSigner()]); $this->assertSame(self::TOKEN_PROGRAM, $result['program']); - $this->assertSame(100000, $result['amount']); + $this->assertSame('100000', $result['amount']); } public function testManagedSignerAsDirectSourceRejected(): void diff --git a/php/tests/Protocols/X402/Exact/VerifierTest.php b/php/tests/Protocols/X402/Exact/VerifierTest.php index e91eda849..5c7e8bb8d 100644 --- a/php/tests/Protocols/X402/Exact/VerifierTest.php +++ b/php/tests/Protocols/X402/Exact/VerifierTest.php @@ -64,4 +64,37 @@ public function testAssociatedTokenProgramConstantRemoved(): void 'ASSOCIATED_TOKEN_PROGRAM must be removed: ATA-create is not an allowed optional slot', ); } + + public function testUnsignedU64DecodingStaysExactAbovePhpIntMax(): void + { + $readU64 = new \ReflectionMethod(Verifier::class, 'readU64Le'); + + $this->assertSame( + '9223372036854775808', + (string) $readU64->invoke(null, "\x00\x00\x00\x00\x00\x00\x00\x80", 0), + ); + $this->assertSame( + '18446744073709551615', + (string) $readU64->invoke(null, str_repeat("\xff", 8), 0), + ); + } + + public function testRequirementAmountParsesTheFullU64Range(): void + { + $amountField = new \ReflectionMethod(Verifier::class, 'amountField'); + + $this->assertSame( + '18446744073709551615', + (string) $amountField->invoke(null, ['amount' => '18446744073709551615']), + ); + } + + public function testRequirementAmountAboveU64FailsClosed(): void + { + $amountField = new \ReflectionMethod(Verifier::class, 'amountField'); + $this->expectException(InvalidProofException::class); + $this->expectExceptionMessage('invalid_exact_svm_payload_missing_field_amount'); + + $amountField->invoke(null, ['amount' => '18446744073709551616']); + } } diff --git a/php/tests/Protocols/X402/ExplicitCapabilityTestReplayStore.php b/php/tests/Protocols/X402/ExplicitCapabilityTestReplayStore.php new file mode 100644 index 000000000..e9704fa00 --- /dev/null +++ b/php/tests/Protocols/X402/ExplicitCapabilityTestReplayStore.php @@ -0,0 +1,33 @@ +store = new MemoryStore(); + } + + public function putIfAbsent(string $key, mixed $value): bool + { + return $this->store->putIfAbsent($key, $value); + } + + public function providesDurableSharedReplayProtection(): bool + { + return true; + } +} diff --git a/php/tests/Protocols/X402/LegacyWireTest.php b/php/tests/Protocols/X402/LegacyWireTest.php index b7a98e96b..09fceb060 100644 --- a/php/tests/Protocols/X402/LegacyWireTest.php +++ b/php/tests/Protocols/X402/LegacyWireTest.php @@ -45,7 +45,7 @@ private function makeAdapter(Network $network = Network::SolanaDevnet): Adapter ), preflight: false, ); - return new Adapter($config, recentBlockhashProvider: fn () => null); + return new Adapter($config, replayStore: new ExplicitCapabilityTestReplayStore(), recentBlockhashProvider: fn () => null); } private function makeGate(): Gate @@ -76,6 +76,42 @@ private function invoke(Adapter $adapter, string $method, mixed ...$args): mixed return $ref->invoke($adapter, ...$args); } + /** @return array */ + private function canonicalOffer(): array + { + return [ + 'scheme' => 'exact', + 'network' => 'solana:devnet', + 'asset' => 'USDC', + 'payTo' => 'recipient', + 'amount' => '100000', + 'maxAmountRequired' => '100000', + 'extra' => ['feePayer' => 'payer', 'tokenProgram' => 'token', 'memo' => '/paid'], + ]; + } + + public function testCanonicalCredentialRejectsTamperedAmount(): void + { + $offer = $this->canonicalOffer(); + $accepted = $offer; + $accepted['amount'] = '999999'; + + $this->expectException(InvalidProofException::class); + $this->expectExceptionMessage('charge_request_mismatch'); + $this->invoke($this->makeAdapter(), 'matchCanonicalCredential', ['accepted' => $accepted], $offer); + } + + public function testCanonicalCredentialRejectsTamperedMaximumAmount(): void + { + $offer = $this->canonicalOffer(); + $accepted = $offer; + $accepted['maxAmountRequired'] = '999999'; + + $this->expectException(InvalidProofException::class); + $this->expectExceptionMessage('charge_request_mismatch'); + $this->invoke($this->makeAdapter(), 'matchCanonicalCredential', ['accepted' => $accepted], $offer); + } + // ── caip2ForCluster: legacy plain-slug -> CAIP-2 normalization ────────── public function testCaip2ForClusterMapsPlainSlugs(): void From cbc16a6656f9c47f24af0b4c8bc6dbe55dda79d2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:31:26 +0300 Subject: [PATCH 04/11] fix(go): close the x402 exact replay race and separate the upto path Reserve the canonical replay identity before settlement on the exact adapter so a concurrent duplicate cannot slip through the settle window, and keep the reservation across ambiguous broadcast outcomes. Stop the x402 upto path from reusing the exact reserve-before-settle setup, and thread the replay-store capability through the client and middleware. --- go/paykit/adapters/x402/binding_test.go | 294 ++++++++++- go/paykit/adapters/x402/exact.go | 230 ++++++++- go/paykit/adapters/x402/exact_test.go | 5 +- go/paykit/adapters/x402/replay_test.go | 636 ++++++++++++++++++++++++ go/paykit/adapters/x402/verify_test.go | 53 +- go/paykit/client.go | 5 + go/paykit/client_test.go | 48 +- go/paykit/middleware.go | 4 +- go/paykit/types.go | 23 + 9 files changed, 1244 insertions(+), 54 deletions(-) create mode 100644 go/paykit/adapters/x402/replay_test.go diff --git a/go/paykit/adapters/x402/binding_test.go b/go/paykit/adapters/x402/binding_test.go index 2da3e482c..a7ef98368 100644 --- a/go/paykit/adapters/x402/binding_test.go +++ b/go/paykit/adapters/x402/binding_test.go @@ -1,6 +1,8 @@ package x402 import ( + "bytes" + "context" "encoding/base64" "encoding/json" "errors" @@ -9,6 +11,7 @@ import ( "github.com/solana-foundation/pay-kit/go/paycore/signer" "github.com/solana-foundation/pay-kit/go/paykit" proto "github.com/solana-foundation/pay-kit/go/protocols/x402" + solana "github.com/solana-foundation/solana-go/v2" ) func bindingAdapter(t *testing.T) *Adapter { @@ -35,11 +38,75 @@ func encodeCredential(t *testing.T, cred proto.Credential) string { return base64.StdEncoding.EncodeToString(raw) } +func TestTotalUnitsRejectsPositiveSubBaseUnitPrice(t *testing.T) { + a := bindingAdapter(t) + gate := &paykit.Gate{Amount: paykit.MustParseUSD("0.0000009")} + + if _, err := a.totalUnits(gate, "USDC"); err == nil { + t.Fatal("expected a positive price below one base unit to be rejected") + } + if entry, err := a.AcceptsEntry(gate); err == nil || entry != nil { + t.Fatalf("sub-base-unit price must fail accepts entry construction, got entry=%v err=%v", entry, err) + } + if headers, err := a.ChallengeHeaders(gate); err == nil || headers != nil { + t.Fatalf("sub-base-unit price must fail challenge construction, got headers=%v err=%v", headers, err) + } + + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{ + Gate: gate, + PaymentSig: encodeCredential(t, proto.Credential{}), + }) + var perr *paykit.PaymentError + if !errors.As(err, &perr) || perr.Code != "invalid_gate" { + t.Fatalf("expected invalid_gate for sub-base-unit price, got %v", err) + } +} + +func TestTotalUnitsRejectsFractionalBaseUnitPrice(t *testing.T) { + a := bindingAdapter(t) + gate := &paykit.Gate{Amount: paykit.MustParseUSD("0.0000019")} + + if _, err := a.totalUnits(gate, "USDC"); err == nil { + t.Fatal("expected a fractional base-unit price to be rejected") + } +} + +func TestTotalUnitsBindsExactlyOneBaseUnit(t *testing.T) { + a := bindingAdapter(t) + gate := &paykit.Gate{Amount: paykit.MustParseUSD("0.000001")} + + units, err := a.totalUnits(gate, "USDC") + if err != nil { + t.Fatalf("one base unit should be accepted: %v", err) + } + if units != "1" { + t.Fatalf("totalUnits = %q, want %q", units, "1") + } + + route, err := a.routeAccepts(gate) + if err != nil { + t.Fatalf("routeAccepts: %v", err) + } + if route.Amount != "1" || route.MaxAmountRequired != "1" { + t.Fatalf("route amounts = (%q, %q), want (1, 1)", route.Amount, route.MaxAmountRequired) + } + reqs, err := a.transferRequirements(gate) + if err != nil { + t.Fatalf("transferRequirements: %v", err) + } + if reqs.Amount != 1 { + t.Fatalf("transfer amount = %d, want 1", reqs.Amount) + } +} + func TestVerifyRejectsLyingAcceptedAmount(t *testing.T) { a := bindingAdapter(t) gate := paykit.Gate{Amount: paykit.MustParseUSD("0.10")} - route := a.routeAccepts(&gate) + route, err := a.routeAccepts(&gate) + if err != nil { + t.Fatal(err) + } tampered := route tampered.Amount = "999999999" tampered.MaxAmountRequired = "999999999" @@ -49,7 +116,7 @@ func TestVerifyRejectsLyingAcceptedAmount(t *testing.T) { Payload: proto.CredentialPayload{Transaction: base64.StdEncoding.EncodeToString([]byte("ignored"))}, Accepted: &tampered, } - _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) + _, err = a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) var perr *paykit.PaymentError if !errors.As(err, &perr) || perr.Code != "charge_request_mismatch" { t.Fatalf("expected charge_request_mismatch for lying amount, got %v", err) @@ -60,7 +127,10 @@ func TestVerifyRejectsLyingAcceptedRecipient(t *testing.T) { a := bindingAdapter(t) gate := paykit.Gate{Amount: paykit.MustParseUSD("0.10")} - route := a.routeAccepts(&gate) + route, err := a.routeAccepts(&gate) + if err != nil { + t.Fatal(err) + } tampered := route tampered.PayTo = string(signer.Generate().Pubkey()) @@ -69,24 +139,236 @@ func TestVerifyRejectsLyingAcceptedRecipient(t *testing.T) { Payload: proto.CredentialPayload{Transaction: base64.StdEncoding.EncodeToString([]byte("ignored"))}, Accepted: &tampered, } - _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) + _, err = a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) var perr *paykit.PaymentError if !errors.As(err, &perr) || perr.Code != "charge_request_mismatch" { t.Fatalf("expected charge_request_mismatch for lying recipient, got %v", err) } } +// TestCosignRefusesOperatorOutsideFeePayerSlot is the fee-payer-slot-pin +// regression: the +// operator must only ever co-sign the fee-payer slot (account key index 0). A +// transaction that places the operator key at any other index with a zero +// signature slot must NOT be co-signed, even though the operator's key appears +// in the account list, matching the Rust/TS index-0 pin. Signing an arbitrary +// slot would let a crafted transaction spend the operator's signature on an +// instruction it never intended to authorize. +// +// Before the fix cosign scanned for the first zero-signature slot matching the +// operator anywhere in AccountKeys and signed it, so a slot-2 operator was +// co-signed. +func TestCosignRefusesOperatorOutsideFeePayerSlot(t *testing.T) { + op := signer.Generate() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + a := &Adapter{ + cfg: paykit.Config{ + Network: paykit.SolanaMainnet, + Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, + X402: paykit.X402Config{Scheme: "exact"}, + }, + signer: op, + } + + // Operator key sits at index 2, not the fee-payer index 0. + feePayer := solana.NewWallet().PublicKey() + other := solana.NewWallet().PublicKey() + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: solana.PublicKeySlice{feePayer, other, opPub}, + Header: solana.MessageHeader{NumRequiredSignatures: 3}, + }, + // Three signature slots, all zero: slot 0 (fee payer), slot 1, slot 2 + // (the operator). A pre-fix cosign would fill slot 2. + Signatures: []solana.Signature{{}, {}, {}}, + } + rawTx, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + + wire, err := a.cosign(context.Background(), tx, rawTx) + if err != nil { + t.Fatalf("cosign should not error when the operator is not the fee payer, got %v", err) + } + // The operator must not have signed any slot: the wire bytes are unchanged. + if !bytes.Equal(wire, rawTx) { + t.Fatal("cosign signed a non-fee-payer slot; operator must only co-sign account key index 0") + } +} + +// TestCosignSignsFeePayerSlotZero is the fee-payer-slot-pin positive: when the operator IS the +// fee payer (account key index 0) and its signature slot is zero, cosign fills +// exactly slot 0 and leaves every other slot untouched. +func TestCosignSignsFeePayerSlotZero(t *testing.T) { + op := signer.Generate() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + a := &Adapter{ + cfg: paykit.Config{ + Network: paykit.SolanaMainnet, + Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, + X402: paykit.X402Config{Scheme: "exact"}, + }, + signer: op, + } + + clientSig := solana.MustSignatureFromBase58(sampleClientSig) + other := solana.NewWallet().PublicKey() + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: solana.PublicKeySlice{opPub, other}, + Header: solana.MessageHeader{NumRequiredSignatures: 2}, + }, + // Slot 0 (operator/fee payer) zero; slot 1 already carries a client + // signature that must be preserved. + Signatures: []solana.Signature{{}, clientSig}, + } + rawTx, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + + wire, err := a.cosign(context.Background(), tx, rawTx) + if err != nil { + t.Fatalf("cosign: %v", err) + } + if bytes.Equal(wire, rawTx) { + t.Fatal("cosign left slot 0 unsigned; the operator fee-payer slot must be filled") + } + // Slot 1 (offset 1+64=65 .. 129) must be byte-identical to the client's + // signature: only slot 0 changed. + if !bytes.Equal(wire[1+64:1+128], rawTx[1+64:1+128]) { + t.Fatal("cosign mutated a non-fee-payer signature slot") + } +} + +// TestCosignSkipsAlreadyFilledFeePayerSlot covers the no-op path: the operator +// is the fee payer at index 0 but slot 0 already carries a signature, so cosign +// leaves the wire untouched. +func TestCosignSkipsAlreadyFilledFeePayerSlot(t *testing.T) { + op := signer.Generate() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + a := &Adapter{ + cfg: paykit.Config{Network: paykit.SolanaMainnet, Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, X402: paykit.X402Config{Scheme: "exact"}}, + signer: op, + } + filled := solana.MustSignatureFromBase58(sampleClientSig) + tx := &solana.Transaction{ + Message: solana.Message{AccountKeys: solana.PublicKeySlice{opPub}, Header: solana.MessageHeader{NumRequiredSignatures: 1}}, + Signatures: []solana.Signature{filled}, + } + rawTx, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + wire, err := a.cosign(context.Background(), tx, rawTx) + if err != nil { + t.Fatalf("cosign: %v", err) + } + if !bytes.Equal(wire, rawTx) { + t.Fatal("cosign overwrote an already-filled fee-payer slot") + } +} + +// TestCosignSupportsWideSignerVector proves co-signing uses the transaction +// encoder instead of relying on a one-byte short-vector prefix offset. +func TestCosignSupportsWideSignerVector(t *testing.T) { + op := signer.Generate() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + a := &Adapter{ + cfg: paykit.Config{Network: paykit.SolanaMainnet, Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, X402: paykit.X402Config{Scheme: "exact"}}, + signer: op, + } + keys := make(solana.PublicKeySlice, 128) + keys[0] = opPub + for i := 1; i < 128; i++ { + keys[i] = solana.NewWallet().PublicKey() + } + sigs := make([]solana.Signature, 128) // slot 0 zero, operator is fee payer. + tx := &solana.Transaction{ + Message: solana.Message{AccountKeys: keys, Header: solana.MessageHeader{NumRequiredSignatures: 128}}, + Signatures: sigs, + } + rawTx, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + wire, err := a.cosign(context.Background(), tx, rawTx) + if err != nil { + t.Fatalf("cosign wide signer vector: %v", err) + } + if len(wire) == 0 || tx.Signatures[0].IsZero() { + t.Fatal("cosign must fill the fee-payer signature in a wide signer vector") + } + for i := 1; i < len(tx.Signatures); i++ { + if !tx.Signatures[i].IsZero() { + t.Fatalf("cosign mutated non-fee-payer signature slot %d", i) + } + } +} + +func TestCosignRejectsSignerVectorMismatchedWithMessage(t *testing.T) { + op := signer.Generate() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + a := &Adapter{ + cfg: paykit.Config{Network: paykit.SolanaMainnet, Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, X402: paykit.X402Config{Scheme: "exact"}}, + signer: op, + } + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: solana.PublicKeySlice{opPub}, + Header: solana.MessageHeader{NumRequiredSignatures: 1}, + }, + } + rawTx, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if _, err := a.cosign(context.Background(), tx, rawTx); err == nil { + t.Fatal("cosign must reject an in-memory transaction whose signature vector does not match the message") + } +} + +func TestCosignRejectsReadOnlyFeePayer(t *testing.T) { + op := signer.Generate() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + a := &Adapter{ + cfg: paykit.Config{Network: paykit.SolanaMainnet, Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, X402: paykit.X402Config{Scheme: "exact"}}, + signer: op, + } + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: solana.PublicKeySlice{opPub}, + Header: solana.MessageHeader{ + NumRequiredSignatures: 1, + NumReadonlySignedAccounts: 1, + }, + }, + Signatures: []solana.Signature{{}}, + } + rawTx, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + if _, err := a.cosign(context.Background(), tx, rawTx); err == nil { + t.Fatal("cosign must reject a read-only fee payer") + } +} + func TestVerifyAcceptsHonestAcceptedThenProceeds(t *testing.T) { a := bindingAdapter(t) gate := paykit.Gate{Amount: paykit.MustParseUSD("0.10")} - honest := a.routeAccepts(&gate) + honest, err := a.routeAccepts(&gate) + if err != nil { + t.Fatal(err) + } cred := proto.Credential{ X402Version: proto.X402Version, Payload: proto.CredentialPayload{Transaction: base64.StdEncoding.EncodeToString([]byte("not-a-tx"))}, Accepted: &honest, } - _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) + _, err = a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &gate, PaymentSig: encodeCredential(t, cred)}) var perr *paykit.PaymentError if !errors.As(err, &perr) { t.Fatalf("expected a PaymentError, got %v", err) diff --git a/go/paykit/adapters/x402/exact.go b/go/paykit/adapters/x402/exact.go index de0aa94bb..ee649cb02 100644 --- a/go/paykit/adapters/x402/exact.go +++ b/go/paykit/adapters/x402/exact.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "strconv" "sync" "time" @@ -14,17 +15,43 @@ import ( bin "github.com/gagliardetto/binary" "github.com/solana-foundation/pay-kit/go/paycore" "github.com/solana-foundation/pay-kit/go/paykit" + mppcore "github.com/solana-foundation/pay-kit/go/protocols/mpp/core" proto "github.com/solana-foundation/pay-kit/go/protocols/x402" solana "github.com/solana-foundation/solana-go/v2" "github.com/solana-foundation/solana-go/v2/rpc" + "github.com/solana-foundation/solana-go/v2/rpc/jsonrpc" ) +// consumedPrefix namespaces the replay markers this adapter writes into the +// shared store so they never collide with other schemes' keys. Mirrors the +// MPP charge server's consumed-signature namespace. +const consumedPrefix = "x402-svm-exact:consumed:" + type Adapter struct { cfg paykit.Config signer paykit.Signer rpc proto.RPCClient - replay sync.Map + replay mppcore.Store + replayOnce sync.Once blockhashProvider func() (string, error) + // confirmAttempts and confirmDelay bound the settlement confirmation + // poll. Zero values fall back to the package defaults; tests set them + // small to exercise the confirmation-timeout path deterministically. + confirmAttempts int + confirmDelay time.Duration +} + +// replayStore returns the adapter's consumed-signature store, lazily +// defaulting to a process-local in-memory store when the adapter was built +// without one (New always injects one; a bare literal, e.g. in tests, may +// not). Cached so repeated settlements share the same replay set. +func (a *Adapter) replayStore() mppcore.Store { + a.replayOnce.Do(func() { + if a.replay == nil { + a.replay = mppcore.NewMemoryStore() + } + }) + return a.replay } func New(cfg paykit.Config) (paykit.Adapter, error) { @@ -39,10 +66,33 @@ func New(cfg paykit.Config) (paykit.Adapter, error) { if sgn == nil { sgn = cfg.Operator.Signer } + allowUnsafeMemoryStore := os.Getenv("PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE") == "1" + if cfg.Network == paykit.SolanaMainnet && allowUnsafeMemoryStore { + return nil, errors.New("protocols/x402: PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE is forbidden on mainnet; inject a durable shared replay store") + } + store := cfg.X402.ReplayStore + if store == nil { + if cfg.Network == paykit.SolanaMainnet { + return nil, errors.New("protocols/x402: mainnet exact settlement requires X402.ReplayStore with durable shared replay capability") + } + if cfg.Network != paykit.SolanaLocalnet && !allowUnsafeMemoryStore { + return nil, errors.New("protocols/x402: non-localnet exact settlement requires X402.ReplayStore with durable shared replay capability; inject a shared atomic store or set PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 to acknowledge process-local replay protection") + } + store = mppcore.NewMemoryStore() + } else if cfg.Network != paykit.SolanaLocalnet && !allowUnsafeMemoryStore { + capability, ok := store.(paykit.ReplayStoreCapability) + if !ok || !capability.ProvidesDurableSharedReplayProtection() { + if cfg.Network == paykit.SolanaMainnet { + return nil, errors.New("protocols/x402: mainnet X402.ReplayStore must implement ReplayStoreCapability and report durable shared replay protection") + } + return nil, errors.New("protocols/x402: non-localnet X402.ReplayStore must implement ReplayStoreCapability and report durable shared replay protection; set PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 only for an explicit process-local override") + } + } a := &Adapter{ cfg: cfg, signer: sgn, rpc: rpc.New(rpcURL), + replay: store, blockhashProvider: cfg.RecentBlockhashProvider, } return a, nil @@ -60,7 +110,10 @@ func (a *Adapter) AcceptsEntry(gate *paykit.Gate) (paykit.AcceptsEntry, error) { coin := a.settlementCoin(gate) label := a.cfg.Network.MintsLabel() mint := paycore.ResolveMint(coin, label) - amount := a.totalUnits(gate, coin) + amount, err := a.totalUnits(gate, coin) + if err != nil { + return nil, err + } payTo := a.payTo(gate) extra := proto.Extra{ FeePayer: true, @@ -132,6 +185,14 @@ func (a *Adapter) advertisedExtensions() json.RawMessage { func (a *Adapter) VerifyAndSettle(req *paykit.AdapterRequest) (*paykit.Payment, error) { ctx := context.Background() + gate := *req.Gate + if req.Path != "" { + gate.Desc = req.Path + } + req.Gate = &gate + if _, err := a.totalUnits(req.Gate, a.settlementCoin(req.Gate)); err != nil { + return nil, &paykit.PaymentError{Code: "invalid_gate", Err: err, Gate: req.Gate} + } sig := req.PaymentSig if sig == "" { sig = req.PaymentSigLegacy @@ -217,13 +278,38 @@ func (a *Adapter) VerifyAndSettle(req *paykit.AdapterRequest) (*paykit.Payment, if err != nil { return nil, &paykit.PaymentError{Code: "invalid_payload", Err: err, Gate: req.Gate} } - if _, loaded := a.replay.LoadOrStore(replayKey, struct{}{}); loaded { + // Reserve the credential before broadcast so a concurrent duplicate is + // rejected during the verify + cosign window. The reservation is released + // ONLY on a definitive pre-broadcast failure (cosign error, send error), + // cases where the transaction provably never reached the chain. Once the + // broadcast succeeds the marker becomes permanent and is never deleted on + // a confirmation timeout, because the transaction may still land on-chain; + // deleting it would reopen a double-serve window on this replica (and, with + // a shared store, across replicas) while the original settlement lands. + // Mirrors the MPP charge server's consumed-signature invariant + // (protocols/mpp/server/server.go: cleanupConsumed pinned to false right + // after SendTransaction succeeds, never released on a confirmation timeout). + consumedKey := consumedPrefix + replayKey + store := a.replayStore() + inserted, err := store.PutIfAbsent(ctx, consumedKey, true) + if err != nil { + // A store I/O failure (e.g. a shared Redis outage) is NOT a replay: the + // credential's consumption state is simply unknown. Surface a distinct + // code so operators debugging an outage don't see honest clients told + // their credential was already spent (which could cause a client to + // discard a valid, unsettled credential). "signature_consumed" is + // reserved for the provably-already-reserved (!inserted) branch below. + return nil, &paykit.PaymentError{Code: "replay_store_error", Err: err, Gate: req.Gate} + } + if !inserted { return nil, &paykit.PaymentError{Code: "signature_consumed", Err: errors.New("replay rejected"), Gate: req.Gate} } - settled := false + cleanupConsumed := true defer func() { - if !settled { - a.replay.Delete(replayKey) + if cleanupConsumed { + // Detach cancellation but keep values so the rollback still runs + // when the caller's context is already canceled. + _ = store.Delete(context.WithoutCancel(ctx), consumedKey) } }() wire, err := a.cosign(ctx, tx, rawTx) @@ -238,12 +324,30 @@ func (a *Adapter) VerifyAndSettle(req *paykit.AdapterRequest) (*paykit.Payment, }, ) if err != nil { + // A transport timeout or generic RPC error does not prove the node + // rejected the transaction before broadcast: it may have accepted the + // bytes and lost the response. Keep the reservation on every ambiguous + // send failure. Solana's explicit simulation failure (-32002) is the one + // definitive preflight rejection and is safe to release for a corrected + // retry. + if !definitivePreflightFailure(err) { + cleanupConsumed = false + } return nil, &paykit.PaymentError{Code: "send_failed", Err: err, Gate: req.Gate} } + // The RPC accepted the broadcast. From here the transaction may land even + // if confirmation polling times out, so pin the replay marker now: a + // confirmation/verify timeout must not delete it and reopen the credential + // for a second submission while the original lands and double-pays. + cleanupConsumed = false if err := a.awaitConfirmation(ctx, signature); err != nil { + // Release only when this transaction's exact recent blockhash is + // finalized-invalid and its signature is absent from full history. + if a.settlementNotLanded(ctx, signature, tx.Message.RecentBlockhash) { + _ = store.Delete(context.WithoutCancel(ctx), consumedKey) + } return nil, &paykit.PaymentError{Code: "settlement_failed", Err: err, Gate: req.Gate} } - settled = true respEnvelope := proto.SettlementResponse{ Success: true, Transaction: signature.String(), @@ -268,6 +372,11 @@ func (a *Adapter) VerifyAndSettle(req *paykit.AdapterRequest) (*paykit.Payment, }, nil } +func definitivePreflightFailure(err error) bool { + var rpcErr *jsonrpc.RPCError + return errors.As(err, &rpcErr) && rpcErr.Code == -32002 +} + func transactionReplayKey(tx *solana.Transaction) (string, error) { for _, sig := range tx.Signatures { if !sig.IsZero() { @@ -281,7 +390,10 @@ func (a *Adapter) verifyLegacyBinding(gate *paykit.Gate, credential *proto.Crede if credential.Scheme != proto.ExactScheme { return fmt.Errorf("scheme mismatch: expected %s, got %q", proto.ExactScheme, credential.Scheme) } - route := a.routeAccepts(gate) + route, err := a.routeAccepts(gate) + if err != nil { + return err + } got := normalizeNetwork(credential.Network) if got != route.Network { return fmt.Errorf("network mismatch: expected %s, got %s", route.Network, credential.Network) @@ -290,7 +402,10 @@ func (a *Adapter) verifyLegacyBinding(gate *paykit.Gate, credential *proto.Crede } func (a *Adapter) verifyAcceptedBinding(gate *paykit.Gate, accepted *proto.AcceptsEntry) error { - route := a.routeAccepts(gate) + route, err := a.routeAccepts(gate) + if err != nil { + return err + } if accepted.Network != route.Network { return fmt.Errorf("network mismatch: expected %s, got %s", route.Network, accepted.Network) } @@ -317,16 +432,20 @@ func (a *Adapter) verifyAcceptedBinding(gate *paykit.Gate, accepted *proto.Accep return nil } -func (a *Adapter) routeAccepts(gate *paykit.Gate) proto.AcceptsEntry { +func (a *Adapter) routeAccepts(gate *paykit.Gate) (proto.AcceptsEntry, error) { coin := a.settlementCoin(gate) label := a.cfg.Network.MintsLabel() + amount, err := a.totalUnits(gate, coin) + if err != nil { + return proto.AcceptsEntry{}, err + } return proto.AcceptsEntry{ Protocol: "x402", Scheme: a.cfg.X402.Scheme, Network: a.cfg.Network.CAIP2(), Asset: paycore.ResolveMint(coin, label), - Amount: a.totalUnits(gate, coin), - MaxAmountRequired: a.totalUnits(gate, coin), + Amount: amount, + MaxAmountRequired: amount, PayTo: string(a.payTo(gate)), MaxTimeoutSeconds: proto.DefaultMaxTimeoutSeconds, Extra: proto.Extra{ @@ -338,7 +457,7 @@ func (a *Adapter) routeAccepts(gate *paykit.Gate) proto.AcceptsEntry { TokenProgram: paycore.DefaultTokenProgramForCurrency(coin, label), Memo: gate.Desc, }, - } + }, nil } func canonicalAccepted(e *proto.AcceptsEntry) ([]byte, error) { @@ -369,7 +488,11 @@ func (a *Adapter) transferRequirements(gate *paykit.Gate) (proto.TransferRequire if err != nil { return proto.TransferRequirements{}, fmt.Errorf("operator pubkey: %w", err) } - amount, err := strconv.ParseUint(a.totalUnits(gate, coin), 10, 64) + amountText, err := a.totalUnits(gate, coin) + if err != nil { + return proto.TransferRequirements{}, fmt.Errorf("amount: %w", err) + } + amount, err := strconv.ParseUint(amountText, 10, 64) if err != nil { return proto.TransferRequirements{}, fmt.Errorf("amount: %w", err) } @@ -386,14 +509,41 @@ func (a *Adapter) transferRequirements(gate *paykit.Gate) (proto.TransferRequire } func (a *Adapter) cosign(ctx context.Context, tx *solana.Transaction, rawTx []byte) ([]byte, error) { + if tx == nil { + return nil, errors.New("transaction is nil") + } operator, err := solana.PublicKeyFromBase58(string(a.signer.Pubkey())) if err != nil { return nil, fmt.Errorf("operator pubkey: %w", err) } - // The fee payer is the canonical first required signer and therefore owns - // signature slot 0. Never sign a matching operator key in a later slot. - if len(tx.Message.AccountKeys) == 0 || len(tx.Signatures) == 0 || - !tx.Message.AccountKeys[0].Equals(operator) || !tx.Signatures[0].IsZero() { + // The operator co-signs ONLY the fee-payer slot, which on Solana is always + // account key index 0. Pinning to slot 0 (rather than scanning for the + // operator key anywhere in the account list) closes the operator-signs- + // attacker-instruction path: a crafted transaction could place a different + // signer at index 0 and the operator's key at some later signer index, and + // a scan would then spend the operator's signature on a slot it never + // intended to authorize while leaving the real fee payer unsigned. Mirrors + // the Rust/TS index-0 pin. + signers := tx.Message.Signers() + requiredSigners := int(tx.Message.Header.NumRequiredSignatures) + if requiredSigners == 0 || len(signers) != requiredSigners || len(tx.Signatures) != requiredSigners { + return nil, fmt.Errorf("invalid signer vector: message requires %d signatures, transaction carries %d", requiredSigners, len(tx.Signatures)) + } + // Solana debits the fee payer, so the first required signer must be + // writable. A message marking every required signer read-only is invalid; + // refuse to sign it instead of producing an operator signature for a + // transaction that cannot be a valid fee-paying transaction. + if int(tx.Message.Header.NumReadonlySignedAccounts) >= requiredSigners { + return nil, errors.New("invalid fee payer: first required signer is read-only") + } + if !signers[0].Equals(operator) { + // The operator is not this transaction's fee payer, so it has nothing to + // co-sign. Return the wire unchanged rather than signing an off-slot key. + return rawTx, nil + } + if !tx.Signatures[0].IsZero() { + // Slot 0 is either absent or already filled (the operator or client + // already signed): nothing to do. return rawTx, nil } msgBytes, err := tx.Message.MarshalBinary() @@ -469,8 +619,14 @@ func (a *Adapter) rejectManagedSourceOwner(ctx context.Context, tx *solana.Trans } func (a *Adapter) awaitConfirmation(ctx context.Context, signature solana.Signature) error { - const attempts = 40 - const delay = 250 * time.Millisecond + attempts := a.confirmAttempts + if attempts <= 0 { + attempts = 40 + } + delay := a.confirmDelay + if delay <= 0 { + delay = 250 * time.Millisecond + } for range attempts { statuses, err := a.rpc.GetSignatureStatuses(ctx, true, signature) if err == nil && statuses != nil && len(statuses.Value) > 0 { @@ -494,6 +650,26 @@ func (a *Adapter) awaitConfirmation(ctx context.Context, signature solana.Signat return fmt.Errorf("timed out confirming %s", signature) } +type blockhashValidator interface { + IsBlockhashValid(context.Context, solana.Hash, rpc.CommitmentType) (*rpc.IsValidBlockhashResult, error) +} + +// settlementNotLanded releases only with evidence tied to the transaction's +// signed blockhash. A fresh GetLatestBlockhash response cannot establish this +// transaction's expiry and must never be used as a substitute. +func (a *Adapter) settlementNotLanded(ctx context.Context, signature solana.Signature, blockhash solana.Hash) bool { + validator, ok := a.rpc.(blockhashValidator) + if !ok { + return false + } + validity, err := validator.IsBlockhashValid(ctx, blockhash, rpc.CommitmentFinalized) + if err != nil || validity == nil || validity.Value { + return false + } + statuses, err := a.rpc.GetSignatureStatuses(ctx, true, signature) + return err == nil && statuses != nil && len(statuses.Value) > 0 && statuses.Value[0] == nil +} + func (a *Adapter) recentBlockhash() (string, error) { if a.blockhashProvider != nil { return a.blockhashProvider() @@ -525,9 +701,17 @@ func (a *Adapter) payTo(gate *paykit.Gate) paykit.Address { return a.cfg.Operator.Recipient } -func (a *Adapter) totalUnits(gate *paykit.Gate, _ string) string { - scaled := gate.Total().Amount().Shift(int32(proto.StablecoinDecimals)) - return scaled.Truncate(0).String() +func (a *Adapter) totalUnits(gate *paykit.Gate, _ string) (string, error) { + total := gate.Total().Amount() + scaled := total.Shift(int32(proto.StablecoinDecimals)) + units := scaled.Truncate(0) + if total.IsPositive() && units.IsZero() { + return "", fmt.Errorf("price %s resolves below one base unit", total) + } + if !scaled.Equal(units) { + return "", fmt.Errorf("price %s is not representable in base units", total) + } + return units.String(), nil } func normalizeNetwork(network string) string { diff --git a/go/paykit/adapters/x402/exact_test.go b/go/paykit/adapters/x402/exact_test.go index da106ed97..786616d14 100644 --- a/go/paykit/adapters/x402/exact_test.go +++ b/go/paykit/adapters/x402/exact_test.go @@ -45,7 +45,10 @@ func TestVerifyAcceptedBindingDirect(t *testing.T) { } adapter := a.(*Adapter) g := &paykit.Gate{Amount: paykit.MustParseUSD("0.10")} - route := adapter.routeAccepts(g) + route, err := adapter.routeAccepts(g) + if err != nil { + t.Fatalf("routeAccepts: %v", err) + } if err := adapter.verifyAcceptedBinding(g, &route); err != nil { t.Errorf("matching route should bind: %v", err) diff --git a/go/paykit/adapters/x402/replay_test.go b/go/paykit/adapters/x402/replay_test.go new file mode 100644 index 000000000..41d152e47 --- /dev/null +++ b/go/paykit/adapters/x402/replay_test.go @@ -0,0 +1,636 @@ +package x402 + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/solana-foundation/pay-kit/go/paycore" + "github.com/solana-foundation/pay-kit/go/paycore/signer" + "github.com/solana-foundation/pay-kit/go/paycore/solanatx" + "github.com/solana-foundation/pay-kit/go/paykit" + mppcore "github.com/solana-foundation/pay-kit/go/protocols/mpp/core" + proto "github.com/solana-foundation/pay-kit/go/protocols/x402" + solana "github.com/solana-foundation/solana-go/v2" + "github.com/solana-foundation/solana-go/v2/rpc" + "github.com/solana-foundation/solana-go/v2/rpc/jsonrpc" +) + +// pendingRPC is a broadcast test double whose send always succeeds but whose +// confirmation status never advances past pending, so awaitConfirmation runs +// out its poll budget and returns a timeout. This models the "landed but +// unconfirmed" window: the RPC accepted the broadcast (the transaction may +// still land on-chain) yet the server never observes a confirmed status. +type pendingRPC struct { + sig solana.Signature + sends int +} + +func replaySourceAccount() *rpc.GetAccountInfoResult { + data := make([]byte, 165) + copy(data[32:64], solana.NewWallet().PublicKey().Bytes()) + return &rpc.GetAccountInfoResult{Value: &rpc.Account{ + Owner: solana.MustPublicKeyFromBase58(paycore.TokenProgram), + Data: rpc.DataBytesOrJSONFromBytes(data), + }} +} + +func (p *pendingRPC) GetAccountInfoWithOpts(context.Context, solana.PublicKey, *rpc.GetAccountInfoOpts) (*rpc.GetAccountInfoResult, error) { + return replaySourceAccount(), nil +} + +func (p *pendingRPC) SendEncodedTransactionWithOpts(_ context.Context, _ string, _ rpc.TransactionOpts) (solana.Signature, error) { + p.sends++ + return p.sig, nil +} + +func (p *pendingRPC) GetSignatureStatuses(_ context.Context, _ bool, _ ...solana.Signature) (*rpc.GetSignatureStatusesResult, error) { + // Empty confirmation status with no error: neither confirmed nor + // finalized, so awaitConfirmation keeps polling until the budget is + // exhausted. + st := &rpc.SignatureStatusesResult{} + return &rpc.GetSignatureStatusesResult{Value: []*rpc.SignatureStatusesResult{st}}, nil +} + +func (p *pendingRPC) GetLatestBlockhash(_ context.Context, _ rpc.CommitmentType) (*rpc.GetLatestBlockhashResult, error) { + return &rpc.GetLatestBlockhashResult{Value: &rpc.LatestBlockhashResult{}}, nil +} + +// exactCredential builds a structurally valid x402 exact transaction that pays +// the given operator, and returns the base64 credential a client would submit. +func exactCredential(t *testing.T, op paykit.Signer) string { + t.Helper() + opPub := solana.MustPublicKeyFromBase58(string(op.Pubkey())) + mint := solana.MustPublicKeyFromBase58(paycore.USDCMainnetMint) + tokenProgram := solana.MustPublicKeyFromBase58(paycore.TokenProgram) + authority := solana.NewWallet().PublicKey() + source := solana.NewWallet().PublicKey() + dest, err := solanatx.FindAssociatedTokenAddressWithProgram(opPub, mint, tokenProgram) + if err != nil { + t.Fatal(err) + } + computeBudget := solana.MustPublicKeyFromBase58(proto.ComputeBudgetProgram) + keys := solana.PublicKeySlice{opPub, authority, source, mint, dest, computeBudget, tokenProgram} + const amount = uint64(1000) + priceData := make([]byte, 9) + priceData[0] = 3 + binaryPutUint64(priceData[1:], 1000) + transferData := make([]byte, 10) + transferData[0] = 12 + binaryPutUint64(transferData[1:9], amount) + transferData[9] = 6 + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: keys, + Header: solana.MessageHeader{NumRequiredSignatures: 2}, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 5, Data: []byte{2, 0, 0, 0, 0}}, + {ProgramIDIndex: 5, Data: priceData}, + {ProgramIDIndex: 6, Accounts: []uint16{2, 3, 4, 1}, Data: transferData}, + }, + }, + Signatures: []solana.Signature{{}, solana.MustSignatureFromBase58(sampleClientSig)}, + } + wire, err := tx.MarshalBinary() + if err != nil { + t.Fatal(err) + } + cred := proto.Credential{X402Version: proto.X402Version, Payload: proto.CredentialPayload{Transaction: base64.StdEncoding.EncodeToString(wire)}} + credJSON, err := json.Marshal(cred) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(credJSON) +} + +func binaryPutUint64(b []byte, v uint64) { + for i := range 8 { + b[i] = byte(v >> (8 * uint(i))) + } +} + +func opConfig(op paykit.Signer, store mppcore.Store) paykit.Config { + return paykit.Config{ + Network: paykit.SolanaLocalnet, + Stablecoins: []paykit.Stablecoin{paykit.USDC}, + Operator: paykit.Operator{Signer: op, Recipient: op.Pubkey()}, + X402: paykit.X402Config{Scheme: "exact", ReplayStore: store}, + } +} + +// TestConfirmationTimeoutKeepsReplayMarker is the double-pay-protection +// regression: after a +// successful broadcast the confirmation poll times out (the transaction may +// have landed), so the credential MUST stay consumed. A retry with the same +// credential must be rejected as signature_consumed rather than re-broadcast. +// +// Before the fix the deferred rollback deleted the marker whenever settlement +// did not complete, including on a confirmation timeout, reopening a +// double-serve window; this test drives exactly that path. +func TestConfirmationTimeoutKeepsReplayMarker(t *testing.T) { + op := signer.Generate() + fake := &pendingRPC{sig: solana.MustSignatureFromBase58(sampleSig)} + a := &Adapter{ + cfg: opConfig(op, nil), + signer: op, + rpc: fake, + blockhashProvider: func() (string, error) { return "BH", nil }, + confirmAttempts: 2, + confirmDelay: time.Millisecond, + } + cred := exactCredential(t, op) + + // First submission: send succeeds, confirmation never lands -> timeout. + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errorsAs(err, &perr) || perr.Code != "settlement_failed" { + t.Fatalf("first submission: expected settlement_failed on confirmation timeout, got %v", err) + } + if fake.sends != 1 { + t.Fatalf("expected exactly one broadcast on the first submission, got %d", fake.sends) + } + + // Retry with the same credential: the marker must still be held, so the + // adapter rejects the replay instead of broadcasting a second time. + _, err = a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + if !errorsAs(err, &perr) || perr.Code != "signature_consumed" { + t.Fatalf("retry after timeout: expected signature_consumed, got %v", err) + } + if fake.sends != 1 { + t.Fatalf("replay must not reach broadcast; expected 1 total send, got %d", fake.sends) + } +} + +// notLandedRPC models the definitive post-timeout not-landed check. Broadcast +// always succeeds; confirmation polling never advances (so awaitConfirmation +// runs out its budget); the final searchTransactionHistory status lookup is +// scripted per field so a test can drive either the provably-not-landed release +// path or one of the keep-the-marker (ambiguous) branches. +type notLandedRPC struct { + sig solana.Signature + // statusErr, when set, makes the final status lookup fail (indeterminate: + // we cannot prove the tx did not land, so the marker must be KEPT). + statusErr error + // found, when true, returns a landed on-chain status for the signature (the + // marker must be KEPT because the tx occupied its slot). + found bool + // lastValidBlockHeight is echoed from GetLatestBlockhash so the adapter can + // capture the blockhash validity window at broadcast time. + lastValidBlockHeight uint64 + // currentHeight is returned by GetBlockHeight: when it is past + // lastValidBlockHeight the blockhash is provably expired. + currentHeight uint64 + // heightErr, when set, makes GetBlockHeight fail so expiry cannot be proven + // (the marker must be KEPT). + heightErr error + sends int + // statusCalls counts confirmation-status lookups; the final definitive + // check is the last one. + statusCalls int +} + +func (n *notLandedRPC) GetAccountInfoWithOpts(context.Context, solana.PublicKey, *rpc.GetAccountInfoOpts) (*rpc.GetAccountInfoResult, error) { + return replaySourceAccount(), nil +} + +func (n *notLandedRPC) SendEncodedTransactionWithOpts(_ context.Context, _ string, _ rpc.TransactionOpts) (solana.Signature, error) { + n.sends++ + return n.sig, nil +} + +func (n *notLandedRPC) GetSignatureStatuses(_ context.Context, _ bool, _ ...solana.Signature) (*rpc.GetSignatureStatusesResult, error) { + n.statusCalls++ + if n.statusErr != nil { + return nil, n.statusErr + } + if n.found { + st := &rpc.SignatureStatusesResult{ConfirmationStatus: rpc.ConfirmationStatusFinalized} + return &rpc.GetSignatureStatusesResult{Value: []*rpc.SignatureStatusesResult{st}}, nil + } + // Signature not found: the per-signature entry is nil (JSON null) while the + // outer Value slice is non-nil. This is how a node reports "unknown + // transaction" for a searched-but-absent signature. + return &rpc.GetSignatureStatusesResult{Value: []*rpc.SignatureStatusesResult{nil}}, nil +} + +func (n *notLandedRPC) GetLatestBlockhash(_ context.Context, _ rpc.CommitmentType) (*rpc.GetLatestBlockhashResult, error) { + return &rpc.GetLatestBlockhashResult{Value: &rpc.LatestBlockhashResult{LastValidBlockHeight: n.lastValidBlockHeight}}, nil +} + +func (n *notLandedRPC) GetBlockHeight(_ context.Context, _ rpc.CommitmentType) (uint64, error) { + if n.heightErr != nil { + return 0, n.heightErr + } + return n.currentHeight, nil +} + +func (n *notLandedRPC) IsBlockhashValid(_ context.Context, _ solana.Hash, _ rpc.CommitmentType) (*rpc.IsValidBlockhashResult, error) { + if n.heightErr != nil { + return nil, n.heightErr + } + return &rpc.IsValidBlockhashResult{Value: n.currentHeight <= n.lastValidBlockHeight}, nil +} + +// expiryRaceRPC models the precise TOCTOU in the old not-landed check. Before +// expiry is observed the signature is absent; once expiry is observed the +// transaction has landed in the final valid block and must keep its replay +// reservation. A status lookup before the height lookup would therefore +// incorrectly release the credential. +type expiryRaceRPC struct { + expired bool + calls []string + commitment rpc.CommitmentType + blockhash solana.Hash +} + +func (r *expiryRaceRPC) SendEncodedTransactionWithOpts(_ context.Context, _ string, _ rpc.TransactionOpts) (solana.Signature, error) { + return solana.Signature{}, nil +} + +func (r *expiryRaceRPC) GetSignatureStatuses(_ context.Context, _ bool, _ ...solana.Signature) (*rpc.GetSignatureStatusesResult, error) { + r.calls = append(r.calls, "status") + if r.expired { + return &rpc.GetSignatureStatusesResult{Value: []*rpc.SignatureStatusesResult{{ConfirmationStatus: rpc.ConfirmationStatusFinalized}}}, nil + } + return &rpc.GetSignatureStatusesResult{Value: []*rpc.SignatureStatusesResult{nil}}, nil +} + +func (r *expiryRaceRPC) GetLatestBlockhash(_ context.Context, _ rpc.CommitmentType) (*rpc.GetLatestBlockhashResult, error) { + return &rpc.GetLatestBlockhashResult{Value: &rpc.LatestBlockhashResult{}}, nil +} + +func (r *expiryRaceRPC) IsBlockhashValid(_ context.Context, blockhash solana.Hash, commitment rpc.CommitmentType) (*rpc.IsValidBlockhashResult, error) { + r.calls = append(r.calls, "validity") + r.commitment = commitment + r.blockhash = blockhash + r.expired = true + return &rpc.IsValidBlockhashResult{Value: false}, nil +} + +// TestSettlementReleasesReservationWhenProvablyNotLanded is the not-landed +// release regression: +// after a successful broadcast the confirmation poll times out, then the +// definitive searchTransactionHistory status lookup reports the signature is +// absent AND the blockhash is provably expired (current height past +// lastValidBlockHeight). Because the transaction can never land, the credential +// must be released so an honest client can rebuild/resubmit. +// +// Before the fix Go pins the marker permanently on ANY confirmation timeout, so +// the retry is rejected as signature_consumed and never re-broadcasts. +func TestSettlementReleasesReservationWhenProvablyNotLanded(t *testing.T) { + op := signer.Generate() + fake := ¬LandedRPC{ + sig: solana.MustSignatureFromBase58(sampleSig), + lastValidBlockHeight: 100, + currentHeight: 101, // past lastValidBlockHeight: blockhash expired. + } + a := &Adapter{ + cfg: opConfig(op, nil), + signer: op, + rpc: fake, + blockhashProvider: func() (string, error) { return "BH", nil }, + confirmAttempts: 2, + confirmDelay: time.Millisecond, + } + cred := exactCredential(t, op) + + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errors.As(err, &perr) || perr.Code != "settlement_failed" { + t.Fatalf("first submission: expected settlement_failed on not-landed timeout, got %v", err) + } + if fake.sends != 1 { + t.Fatalf("expected exactly one broadcast on the first submission, got %d", fake.sends) + } + + // The credential was provably never landed, so the reservation was + // released: a retry re-broadcasts instead of tripping signature_consumed. + _, err = a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + if !errors.As(err, &perr) || perr.Code != "settlement_failed" { + t.Fatalf("retry after not-landed release: expected re-broadcast then settlement_failed, got %v", err) + } + if fake.sends != 2 { + t.Fatalf("released credential must re-broadcast; expected 2 total sends, got %d", fake.sends) + } +} + +// TestSettlementKeepsReservationWhenBlockhashStillValid is the not-landed +// companion: +// the definitive lookup reports the signature absent, but the blockhash has NOT +// expired (current height still within lastValidBlockHeight), so the tx may +// still land. The marker MUST be kept (fail-closed) and a retry is rejected. +func TestSettlementKeepsReservationWhenBlockhashStillValid(t *testing.T) { + op := signer.Generate() + fake := ¬LandedRPC{ + sig: solana.MustSignatureFromBase58(sampleSig), + lastValidBlockHeight: 100, + currentHeight: 100, // not past lastValidBlockHeight: still valid. + } + a := &Adapter{ + cfg: opConfig(op, nil), + signer: op, + rpc: fake, + blockhashProvider: func() (string, error) { return "BH", nil }, + confirmAttempts: 2, + confirmDelay: time.Millisecond, + } + cred := exactCredential(t, op) + + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errors.As(err, &perr) || perr.Code != "settlement_failed" { + t.Fatalf("expected settlement_failed, got %v", err) + } + _, err = a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + if !errors.As(err, &perr) || perr.Code != "signature_consumed" { + t.Fatalf("blockhash still valid: retry must be rejected as signature_consumed, got %v", err) + } + if fake.sends != 1 { + t.Fatalf("marker must be kept; expected 1 total send, got %d", fake.sends) + } +} + +// TestSettlementKeepsReservationWhenStatusLookupErrors is the not-landed +// companion: +// the definitive status lookup itself fails (indeterminate). We cannot prove +// the tx did not land, so the marker MUST be kept. +func TestSettlementKeepsReservationWhenStatusLookupErrors(t *testing.T) { + op := signer.Generate() + fake := ¬LandedRPC{ + sig: solana.MustSignatureFromBase58(sampleSig), + statusErr: errors.New("rpc unavailable"), + lastValidBlockHeight: 100, + currentHeight: 101, + } + a := &Adapter{ + cfg: opConfig(op, nil), + signer: op, + rpc: fake, + blockhashProvider: func() (string, error) { return "BH", nil }, + confirmAttempts: 2, + confirmDelay: time.Millisecond, + } + cred := exactCredential(t, op) + + if _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}); err == nil { + t.Fatal("expected settlement_failed on the first submission") + } + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errors.As(err, &perr) || perr.Code != "signature_consumed" { + t.Fatalf("indeterminate status: retry must be rejected as signature_consumed, got %v", err) + } +} + +// TestReplayStoreErrorReportsDistinctCode is the store-outage-code regression: a ReplayStore +// I/O failure on PutIfAbsent (e.g. a shared Redis outage) must be reported with +// a distinct code, not the client-facing signature_consumed replay rejection. +// +// Before the fix the store-error branch surfaced code "signature_consumed", +// telling honest clients their credential was already spent during an outage. +func TestReplayStoreErrorReportsDistinctCode(t *testing.T) { + op := signer.Generate() + fake := &fakeRPC{sig: solana.MustSignatureFromBase58(sampleSig), confirm: rpc.ConfirmationStatusConfirmed} + a := &Adapter{ + cfg: opConfig(op, &errStore{err: errors.New("redis down")}), + signer: op, + rpc: fake, + replay: &errStore{err: errors.New("redis down")}, + blockhashProvider: func() (string, error) { return "BH", nil }, + } + cred := exactCredential(t, op) + + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errors.As(err, &perr) { + t.Fatalf("expected a PaymentError on a store outage, got %v", err) + } + if perr.Code != "replay_store_error" { + t.Fatalf("store outage must surface replay_store_error, got %q", perr.Code) + } +} + +// TestSettlementNotLandedBranches exercises the remaining guard branches of the +// evidence-based release decision directly, pinning each fail-closed exit. +func TestSettlementNotLandedBranches(t *testing.T) { + op := signer.Generate() + sig := solana.MustSignatureFromBase58(sampleSig) + blockhash := solana.Hash{1} + + // RPC without per-blockhash validity support: + // expiry cannot be proven, keep the marker. + a := &Adapter{cfg: opConfig(op, nil), signer: op, rpc: &fakeRPC{}} + if a.settlementNotLanded(context.Background(), sig, blockhash) { + t.Fatal("an RPC without IsBlockhashValid must never release") + } + + // Signature found (landed): keep the marker even though a window exists. + a.rpc = ¬LandedRPC{found: true, lastValidBlockHeight: 100, currentHeight: 200} + if a.settlementNotLanded(context.Background(), sig, blockhash) { + t.Fatal("a landed signature must never release") + } + + // Provably absent + expired: release. + a.rpc = ¬LandedRPC{lastValidBlockHeight: 100, currentHeight: 101} + if !a.settlementNotLanded(context.Background(), sig, blockhash) { + t.Fatal("provably absent + expired blockhash must release") + } + + // Block-height lookup fails: expiry unprovable, keep the marker. + a.rpc = ¬LandedRPC{lastValidBlockHeight: 100, heightErr: errors.New("rpc down")} + if a.settlementNotLanded(context.Background(), sig, blockhash) { + t.Fatal("a failed blockhash-validity lookup must never release") + } +} + +// TestSettlementNotLandedChecksExpiryBeforeStatus pins the ordering that makes +// the two RPC observations safe to compose. Once a finalized height proves the +// blockhash is expired, the later full-history status lookup cannot race a new +// landing with that blockhash. +func TestSettlementNotLandedChecksExpiryBeforeStatus(t *testing.T) { + op := signer.Generate() + fake := &expiryRaceRPC{} + a := &Adapter{cfg: opConfig(op, nil), signer: op, rpc: fake} + sig := solana.MustSignatureFromBase58(sampleSig) + + blockhash := solana.Hash{1, 2, 3} + if a.settlementNotLanded(context.Background(), sig, blockhash) { + t.Fatal("a transaction that lands as the blockhash expires must keep its reservation") + } + if len(fake.calls) != 2 || fake.calls[0] != "validity" || fake.calls[1] != "status" { + t.Fatalf("RPC call order = %v, want [validity status]", fake.calls) + } + if fake.commitment != rpc.CommitmentFinalized { + t.Fatalf("blockhash-validity commitment = %q, want finalized", fake.commitment) + } + if fake.blockhash != blockhash { + t.Fatalf("validated blockhash = %s, want transaction blockhash %s", fake.blockhash, blockhash) + } +} + +// errStore is a ReplayStore whose PutIfAbsent always fails, modelling a +// shared-store outage. +type errStore struct{ err error } + +func (e *errStore) Get(context.Context, string) (json.RawMessage, bool, error) { + return nil, false, e.err +} +func (e *errStore) Put(context.Context, string, any) error { return e.err } +func (e *errStore) Delete(context.Context, string) error { return nil } +func (e *errStore) PutIfAbsent(context.Context, string, any) (bool, error) { + return false, e.err +} + +// TestSharedReplayStoreRejectsCrossReplica is the cross-replica-replay +// regression: two adapter +// instances built over one injected shared store behave like two replicas +// behind a load balancer. When the first replica consumes a signature the +// second replica must reject the same credential as signature_consumed. +// +// Before the fix the consumed-signature set was a per-Adapter sync.Map with no +// injection point, so replicas could not share it and each would settle the +// same credential once. +func TestSharedReplayStoreRejectsCrossReplica(t *testing.T) { + op := signer.Generate() + shared := mppcore.NewMemoryStore() + + newReplica := func() *Adapter { + built, err := New(opConfig(op, shared)) + if err != nil { + t.Fatalf("New: %v", err) + } + a, ok := built.(*Adapter) + if !ok { + t.Fatalf("New returned %T, want *Adapter", built) + } + a.rpc = &fakeRPC{sig: solana.MustSignatureFromBase58(sampleSig), confirm: rpc.ConfirmationStatusFinalized} + a.blockhashProvider = func() (string, error) { return "BH", nil } + return a + } + + replicaA := newReplica() + replicaB := newReplica() + cred := exactCredential(t, op) + + if _, err := replicaA.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}); err != nil { + t.Fatalf("replica A settle: %v", err) + } + _, err := replicaB.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errorsAs(err, &perr) || perr.Code != "signature_consumed" { + t.Fatalf("replica B: expected signature_consumed via the shared store, got %v", err) + } +} + +// TestAmbiguousSendFailureKeepsReplayMarker proves that a timeout does not +// establish whether the node accepted the transaction. The reservation must +// stay pinned so the same credential cannot be broadcast twice. +func TestAmbiguousSendFailureKeepsReplayMarker(t *testing.T) { + op := signer.Generate() + fake := &fakeRPC{sendErr: context.DeadlineExceeded} + a := &Adapter{ + cfg: opConfig(op, nil), + signer: op, + rpc: fake, + blockhashProvider: func() (string, error) { return "BH", nil }, + } + cred := exactCredential(t, op) + + if _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}); err == nil { + t.Fatal("expected send_failed on the first submission") + } + // A healthy retry is rejected because the first send may still have landed. + fake.sendErr = nil + fake.sig = solana.MustSignatureFromBase58(sampleSig) + fake.confirm = rpc.ConfirmationStatusConfirmed + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}) + var perr *paykit.PaymentError + if !errorsAs(err, &perr) || perr.Code != "signature_consumed" { + t.Fatalf("retry after ambiguous send must be rejected, got %v", err) + } +} + +func TestDefinitivePreflightFailureReleasesReplayMarker(t *testing.T) { + op := signer.Generate() + fake := &fakeRPC{sendErr: &jsonrpc.RPCError{Code: -32002, Message: "transaction simulation failed"}} + a := &Adapter{ + cfg: opConfig(op, nil), + signer: op, + rpc: fake, + blockhashProvider: func() (string, error) { return "BH", nil }, + } + cred := exactCredential(t, op) + + if _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}); err == nil { + t.Fatal("expected send_failed on preflight rejection") + } + fake.sendErr = nil + fake.sig = solana.MustSignatureFromBase58(sampleSig) + fake.confirm = rpc.ConfirmationStatusConfirmed + if _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: &paykit.Gate{Amount: paykit.MustParseUSD("0.001")}, PaymentSig: cred}); err != nil { + t.Fatalf("retry after definitive preflight rejection should settle, got %v", err) + } +} + +func TestNonLocalnetRequiresSharedReplayStore(t *testing.T) { + t.Setenv("PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE", "") + op := signer.Generate() + cfg := opConfig(op, nil) + cfg.Network = paykit.SolanaDevnet + if _, err := New(cfg); err == nil { + t.Fatal("expected non-localnet configuration without a replay store to fail closed") + } + + cfg.X402.ReplayStore = mppcore.NewMemoryStore() + if _, err := New(cfg); err == nil { + t.Fatal("expected a process-local replay store to fail closed outside localnet") + } + + cfg.X402.ReplayStore = declaredReplayStore{Store: mppcore.NewMemoryStore()} + if _, err := New(cfg); err == nil { + t.Fatal("expected a store that reports non-durable replay protection to fail closed outside localnet") + } + + cfg.X402.ReplayStore = declaredReplayStore{Store: mppcore.NewMemoryStore(), durable: true} + if _, err := New(cfg); err != nil { + t.Fatalf("injected store declaring durable shared replay protection should be accepted: %v", err) + } +} + +func TestNonLocalnetInsecureMemoryOptIn(t *testing.T) { + t.Setenv("PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE", "1") + op := signer.Generate() + cfg := opConfig(op, nil) + cfg.Network = paykit.SolanaDevnet + if _, err := New(cfg); err != nil { + t.Fatalf("explicit insecure development opt-in should permit memory store: %v", err) + } +} + +func TestMainnetRejectsInsecureMemoryOptIn(t *testing.T) { + t.Setenv("PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE", "1") + op := signer.Generate() + cfg := opConfig(op, nil) + cfg.Network = paykit.SolanaMainnet + if _, err := New(cfg); err == nil { + t.Fatal("expected mainnet to reject the insecure memory-store opt-in") + } + + cfg.X402.ReplayStore = mppcore.NewMemoryStore() + if _, err := New(cfg); err == nil { + t.Fatal("expected mainnet to reject an injected process-local store under the insecure opt-in") + } +} + +// declaredReplayStore is a test double for an externally backed store. +// The embedded MemoryStore supplies behavior; the capability declaration is +// what New requires from a production implementation outside localnet. +type declaredReplayStore struct { + mppcore.Store + durable bool +} + +func (s declaredReplayStore) ProvidesDurableSharedReplayProtection() bool { return s.durable } diff --git a/go/paykit/adapters/x402/verify_test.go b/go/paykit/adapters/x402/verify_test.go index b242d89b3..39bf7b275 100644 --- a/go/paykit/adapters/x402/verify_test.go +++ b/go/paykit/adapters/x402/verify_test.go @@ -49,11 +49,11 @@ func newFixture(t *testing.T) fixture { } keys := solana.PublicKeySlice{ - feePayer, // 0 - source, // 1 - mint, // 2 - dest, // 3 - authority, // 4 + feePayer, // 0: fee payer signer + authority, // 1: transfer authority signer + source, // 2 + mint, // 3 + dest, // 4 computeBudget, // 5 tokenProgram, // 6 } @@ -74,7 +74,7 @@ func newFixture(t *testing.T) fixture { computePrice: solana.CompiledInstruction{ProgramIDIndex: 5, Data: priceData}, transfer: solana.CompiledInstruction{ ProgramIDIndex: 6, - Accounts: []uint16{1, 2, 3, 4}, + Accounts: []uint16{2, 3, 4, 1}, Data: transferData, }, req: proto.TransferRequirements{ @@ -92,8 +92,12 @@ func newFixture(t *testing.T) fixture { func (f fixture) tx(extra ...solana.CompiledInstruction) *solana.Transaction { ixs := append([]solana.CompiledInstruction{f.computeLimit, f.computePrice, f.transfer}, extra...) return &solana.Transaction{ - Message: solana.Message{AccountKeys: f.keys, Instructions: ixs}, - Signatures: []solana.Signature{{}}, + Message: solana.Message{ + AccountKeys: f.keys, + Header: solana.MessageHeader{NumRequiredSignatures: 2}, + Instructions: ixs, + }, + Signatures: []solana.Signature{{}, {}}, } } @@ -180,8 +184,12 @@ func TestVerifyEnforcesExpectedMemoMatch(t *testing.T) { func TestVerifyRejectsTooFewInstructions(t *testing.T) { f := newFixture(t) tx := &solana.Transaction{ - Message: solana.Message{AccountKeys: f.keys, Instructions: []solana.CompiledInstruction{f.computeLimit, f.computePrice}}, - Signatures: []solana.Signature{{}}, + Message: solana.Message{ + AccountKeys: f.keys, + Header: solana.MessageHeader{NumRequiredSignatures: 2}, + Instructions: []solana.CompiledInstruction{f.computeLimit, f.computePrice}, + }, + Signatures: []solana.Signature{{}, {}}, } if err := proto.VerifyExactTransaction(tx, f.req); err == nil { t.Error("expected rejection for <3 instructions") @@ -225,7 +233,7 @@ func TestVerifyRejectsWrongAmount(t *testing.T) { func TestVerifyRejectsWrongMint(t *testing.T) { f := newFixture(t) - f.keys[2] = solana.MustPublicKeyFromBase58(paycore.USDTMainnetMint) + f.keys[3] = solana.MustPublicKeyFromBase58(paycore.USDTMainnetMint) if err := proto.VerifyExactTransaction(f.tx(), f.req); err == nil { t.Error("expected rejection for mint mismatch") } @@ -233,7 +241,7 @@ func TestVerifyRejectsWrongMint(t *testing.T) { func TestVerifyRejectsWrongDestination(t *testing.T) { f := newFixture(t) - f.keys[3] = solana.NewWallet().PublicKey() // not the payTo ATA + f.keys[4] = solana.NewWallet().PublicKey() // not the payTo ATA if err := proto.VerifyExactTransaction(f.tx(), f.req); err == nil { t.Error("expected rejection for recipient ATA mismatch") } @@ -241,7 +249,7 @@ func TestVerifyRejectsWrongDestination(t *testing.T) { func TestVerifyRejectsFeePayerAsAuthority(t *testing.T) { f := newFixture(t) - f.keys[4] = f.req.ManagedSigners[0] // fee-payer moving the funds + f.keys[1] = f.req.ManagedSigners[0] // fee-payer moving the funds if err := proto.VerifyExactTransaction(f.tx(), f.req); err == nil { t.Error("expected rejection when fee-payer is the transfer authority") } @@ -342,7 +350,7 @@ func settleFixture(t *testing.T, fake *fakeRPC) (*Adapter, *paykit.Gate, string) } computeBudget := solana.MustPublicKeyFromBase58(proto.ComputeBudgetProgram) - keys := solana.PublicKeySlice{opPub, source, mint, dest, authority, computeBudget, tokenProgram} + keys := solana.PublicKeySlice{opPub, authority, source, mint, dest, computeBudget, tokenProgram} const amount = uint64(1000) priceData := make([]byte, 9) priceData[0] = 3 @@ -354,10 +362,11 @@ func settleFixture(t *testing.T, fake *fakeRPC) (*Adapter, *paykit.Gate, string) tx := &solana.Transaction{ Message: solana.Message{ AccountKeys: keys, + Header: solana.MessageHeader{NumRequiredSignatures: 2}, Instructions: []solana.CompiledInstruction{ {ProgramIDIndex: 5, Data: []byte{2, 0, 0, 0, 0}}, {ProgramIDIndex: 5, Data: priceData}, - {ProgramIDIndex: 6, Accounts: []uint16{1, 2, 3, 4}, Data: transferData}, + {ProgramIDIndex: 6, Accounts: []uint16{2, 3, 4, 1}, Data: transferData}, }, }, Signatures: []solana.Signature{{}, solana.MustSignatureFromBase58(sampleClientSig)}, @@ -451,19 +460,21 @@ func TestVerifyAndSettleConfirmationError(t *testing.T) { } } -func TestVerifyAndSettleSendFailureRollsBackReplay(t *testing.T) { +func TestVerifyAndSettleAmbiguousSendFailureKeepsReplay(t *testing.T) { fake := &fakeRPC{sendErr: context.DeadlineExceeded} a, gate, sig := settleFixture(t, fake) if _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: gate, PaymentSig: sig}); err == nil { t.Fatal("expected send_failed") } - // Replay reservation must have been rolled back: a retry with a - // working RPC then succeeds rather than tripping signature_consumed. + // The node may have accepted the transaction before the timeout, so the + // reservation stays pinned and a retry cannot broadcast it again. fake.sendErr = nil fake.sig = solana.MustSignatureFromBase58(sampleSig) fake.confirm = rpc.ConfirmationStatusConfirmed - if _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: gate, PaymentSig: sig}); err != nil { - t.Fatalf("retry after rollback should succeed, got %v", err) + _, err := a.VerifyAndSettle(&paykit.AdapterRequest{Gate: gate, PaymentSig: sig}) + var perr *paykit.PaymentError + if !errorsAs(err, &perr) || perr.Code != "signature_consumed" { + t.Fatalf("retry after ambiguous send must be rejected, got %v", err) } } @@ -671,6 +682,7 @@ func TestCosignPassthroughWhenOperatorAbsent(t *testing.T) { if err != nil { t.Fatal(err) } + tx.Signatures = make([]solana.Signature, len(tx.Message.Signers())) raw, err := tx.MarshalBinary() if err != nil { t.Fatal(err) @@ -701,6 +713,7 @@ func TestCosignDoesNotSignOperatorInLaterSignerSlot(t *testing.T) { if len(tx.Message.AccountKeys) < 2 || !tx.Message.AccountKeys[1].Equals(operator) { t.Fatalf("expected operator in later signer slot, got %v", tx.Message.AccountKeys) } + tx.Signatures = make([]solana.Signature, len(tx.Message.Signers())) raw, err := tx.MarshalBinary() if err != nil { t.Fatal(err) diff --git a/go/paykit/client.go b/go/paykit/client.go index 09cbeafa4..25a2741f2 100644 --- a/go/paykit/client.go +++ b/go/paykit/client.go @@ -189,6 +189,11 @@ func New(cfg Config) (*Client, error) { c := &Client{Config: cfg} for _, s := range cfg.Accept { + // The registered x402 adapter implements exact settlement and its + // replay-store policy. Upto-only deployments use the usage adapter below. + if s == X402 && cfg.X402.Scheme == "upto" { + continue + } b, ok := registeredBuilders[s] if !ok { continue diff --git a/go/paykit/client_test.go b/go/paykit/client_test.go index 245bf052e..18bf38f00 100644 --- a/go/paykit/client_test.go +++ b/go/paykit/client_test.go @@ -61,19 +61,61 @@ func TestX402ExactAllowsRecipientDifferentFromSigner(t *testing.T) { } } -func TestX402UptoWiresUsageAdapter(t *testing.T) { +func TestX402UptoOnNonLocalnetDoesNotRequireExactReplayStore(t *testing.T) { c, err := paykit.New(paykit.Config{ - Network: paykit.SolanaLocalnet, + Network: paykit.SolanaDevnet, Accept: []paykit.Protocol{paykit.X402}, Preflight: disabled(), X402: paykit.X402Config{Scheme: "upto"}, }) if err != nil { - t.Fatalf("x402 upto should wire usage adapter: %v", err) + t.Fatalf("non-localnet x402 upto should not require an exact replay store: %v", err) } if c.UsageAdapter() == nil { t.Fatal("expected usage adapter for x402 upto config") } + if c.X402Adapter() != nil { + t.Fatal("upto-only x402 config must not wire the exact adapter") + } +} + +func TestX402ExactOnNonLocalnetRequiresReplayStore(t *testing.T) { + _, err := paykit.New(paykit.Config{ + Network: paykit.SolanaDevnet, + Accept: []paykit.Protocol{paykit.X402}, + Preflight: disabled(), + X402: paykit.X402Config{Scheme: "exact"}, + }) + if err == nil { + t.Fatal("expected non-localnet exact x402 config without a replay store to fail closed") + } + if !strings.Contains(err.Error(), "ReplayStore") { + t.Fatalf("expected replay store error, got %v", err) + } +} + +func TestNewMixedMPPAndX402UptoWiresSelectedAdapters(t *testing.T) { + c, err := paykit.New(paykit.Config{ + Network: paykit.SolanaDevnet, + Accept: []paykit.Protocol{paykit.MPP, paykit.X402}, + Preflight: disabled(), + MPP: paykit.MPPConfig{ + ChallengeBindingSecret: []byte("test-secret"), + }, + X402: paykit.X402Config{Scheme: "upto"}, + }) + if err != nil { + t.Fatalf("mixed MPP and x402 upto config should wire selected adapters: %v", err) + } + if c.MppAdapter() == nil { + t.Fatal("expected MPP adapter for mixed config") + } + if c.UsageAdapter() == nil { + t.Fatal("expected x402 usage adapter for mixed config") + } + if c.X402Adapter() != nil { + t.Fatal("mixed config with x402 upto must not wire the exact adapter") + } } func TestSetErrorHandlerOverridesResponse(t *testing.T) { diff --git a/go/paykit/middleware.go b/go/paykit/middleware.go index c1a3ef93b..7595bdedf 100644 --- a/go/paykit/middleware.go +++ b/go/paykit/middleware.go @@ -145,7 +145,9 @@ func (c *Client) write402(w http.ResponseWriter, r *http.Request, gate *Gate, pe headers := map[string]string{} var challengeErrors []error if c.x402Adapter != nil && containsProtocol(accept, X402) && !gate.HasFees() { - if err := appendChallenge(gate, c.x402Adapter, &accepts, headers); err != nil { + x402Gate := *gate + x402Gate.Desc = r.URL.Path + if err := appendChallenge(&x402Gate, c.x402Adapter, &accepts, headers); err != nil { challengeErrors = append(challengeErrors, err) } } diff --git a/go/paykit/types.go b/go/paykit/types.go index 970272599..90abec997 100644 --- a/go/paykit/types.go +++ b/go/paykit/types.go @@ -6,6 +6,7 @@ import ( "time" "github.com/shopspring/decimal" + mppcore "github.com/solana-foundation/pay-kit/go/protocols/mpp/core" ) // Protocol enumerates the payment protocols the kit speaks. Order matters in @@ -205,6 +206,28 @@ type X402Config struct { // ChannelProgram overrides the payment-channels program id advertised // by x402 upto. Leave empty for the canonical mainnet deployment. ChannelProgram string + // ReplayStore backs the x402 exact adapter's consumed-signature guard, + // which rejects a second submission of an already-settled (or in-flight) + // credential. Non-localnet exact settlement requires an injected store that + // implements [ReplayStoreCapability] and reports durable, shared replay + // protection. Localnet may use the process-local memory default; devnet can + // explicitly acknowledge that insecure scope with + // PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1. Multi-replica deployments MUST + // inject a shared store so a signature consumed on one replica is rejected + // on every other. + ReplayStore mppcore.Store +} + +// ReplayStoreCapability is the durability declaration required of an x402 +// replay store outside localnet. The store must already implement +// [mppcore.Store], including atomic PutIfAbsent; this capability separately +// declares that its reservations are durable and visible to every serving +// replica. A process-local store must not implement this as true. +// +// The interface is intentionally opt-in: an arbitrary Store cannot be +// assumed to survive process restarts or coordinate across replicas. +type ReplayStoreCapability interface { + ProvidesDurableSharedReplayProtection() bool } // MPPConfig groups the MPP-charge-specific knobs. From 566c8d1540d6a796db495b570d9e76150f49bf16 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:55:59 +0300 Subject: [PATCH 05/11] fix(x402): auto-provision a localnet replay store instead of failing boot The rebuild dropped the localnet replay-store default, so an x402 config with no explicit replayStore threw ConfigurationError at adapter construction even on localnet, breaking playground-api (createPayKit accept:['x402','mpp'] network:'localnet' with no store). Resolve the reserving replay store once in x402-shared: an injected store wins, localnet (or the explicit off-localnet opt-in) gets a process-local in-memory store mirroring the MPP session adapter, and off localnet without the opt-in still fails closed. Both exact and upto route through it. --- .../pay-kit/src/__tests__/x402.test.ts | 28 +++++++++++++++++ .../pay-kit/src/adapters/x402-shared.ts | 30 ++++++++++++++++++- .../packages/pay-kit/src/adapters/x402.ts | 9 ++---- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/typescript/packages/pay-kit/src/__tests__/x402.test.ts b/typescript/packages/pay-kit/src/__tests__/x402.test.ts index d105dead2..b8cdf15c9 100644 --- a/typescript/packages/pay-kit/src/__tests__/x402.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/x402.test.ts @@ -174,3 +174,31 @@ describe('Charge meter', () => { expect(charge.settledBaseUnits()).toBe(250_000n); }); }); + +describe('x402 replay-store provisioning', () => { + it('boots on localnet with no explicit replayStore (exact + upto)', async () => { + const config = await configure({ accept: ['x402'], network: 'solana_localnet' }); + expect(config.replayStore).toBeUndefined(); + expect(() => createX402ExactAdapter(config)).not.toThrow(); + expect(() => new X402Upto(config)).not.toThrow(); + }); + + it('fails closed off localnet without a store or the opt-in (exact + upto)', async () => { + delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + const config = await configure({ accept: ['x402'], network: 'solana_devnet' }); + expect(config.replayStore).toBeUndefined(); + expect(() => createX402ExactAdapter(config)).toThrow(/atomic reserve capability/); + expect(() => new X402Upto(config)).toThrow(/atomic reserve capability/); + }); + + it('permits an in-memory store off localnet under the explicit opt-in', async () => { + process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = '1'; + try { + const config = await configure({ accept: ['x402'], network: 'solana_devnet' }); + expect(() => createX402ExactAdapter(config)).not.toThrow(); + expect(() => new X402Upto(config)).not.toThrow(); + } finally { + delete process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; + } + }); +}); diff --git a/typescript/packages/pay-kit/src/adapters/x402-shared.ts b/typescript/packages/pay-kit/src/adapters/x402-shared.ts index c9290a873..6c1324a8d 100644 --- a/typescript/packages/pay-kit/src/adapters/x402-shared.ts +++ b/typescript/packages/pay-kit/src/adapters/x402-shared.ts @@ -1,9 +1,37 @@ import { createSolanaRpc } from '@solana/kit'; -import { InvalidProofError } from '../errors.js'; +import type { PayKitConfig } from '../config.js'; +import { ConfigurationError, InvalidProofError } from '../errors.js'; +import { createMemoryReplayStore, isReservingReplayStore, type ReservingReplayStore } from '../replay-store.js'; export { isReservingReplayStore, type ReservingReplayStore } from '../replay-store.js'; +/** Env opt-in that permits a process-local in-memory replay store off localnet. */ +const ALLOW_INMEMORY_REPLAY_STORE_ENV = 'PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'; + +/** + * Resolve the reserving replay store an x402 adapter fences settlement against. + * + * A caller-supplied store wins (it must expose the atomic `reserve` capability). + * With none supplied, localnet (or the explicit off-localnet opt-in) gets a + * process-local in-memory store so a dev boot needs no wiring, mirroring how + * the MPP session adapter provisions its store. Off localnet without the opt-in + * this fails closed: no silent memory store on devnet/mainnet. + */ +export function resolveX402ReplayStore(config: PayKitConfig, scheme: string): ReservingReplayStore { + const store = config.replayStore; + if (store !== undefined) { + if (!isReservingReplayStore(store)) { + throw new ConfigurationError(`x402 ${scheme} requires a replayStore with atomic reserve capability.`); + } + return store; + } + if (config.network === 'solana_localnet' || process.env[ALLOW_INMEMORY_REPLAY_STORE_ENV] === '1') { + return createMemoryReplayStore(); + } + throw new ConfigurationError(`x402 ${scheme} requires a replayStore with atomic reserve capability.`); +} + /** The x402 payment credential header, read from either accepted name. */ export function x402PaymentHeader(request: Request): string | undefined { return request.headers.get('x-payment') ?? request.headers.get('payment-signature') ?? undefined; diff --git a/typescript/packages/pay-kit/src/adapters/x402.ts b/typescript/packages/pay-kit/src/adapters/x402.ts index b0efcf038..b6697a260 100644 --- a/typescript/packages/pay-kit/src/adapters/x402.ts +++ b/typescript/packages/pay-kit/src/adapters/x402.ts @@ -15,7 +15,7 @@ import type { ProtocolAdapter } from '../adapter.js'; import type { AcceptsEntry } from '../challenge.js'; import { requireMint, resolveCoin } from '../coin.js'; import type { PayKitConfig } from '../config.js'; -import { ConfigurationError, InvalidProofError } from '../errors.js'; +import { InvalidProofError } from '../errors.js'; import type { Gate } from '../gate.js'; import type { Payment } from '../payment.js'; import { caip2 } from '../protocol.js'; @@ -23,7 +23,7 @@ import { assertPaymentHeaderWithinCap, ChallengeBlockhashCache, errorMessage, - isReservingReplayStore, + resolveX402ReplayStore, x402PaymentHeader, } from './x402-shared.js'; @@ -74,10 +74,7 @@ export function createX402ExactAdapter(config: PayKitConfig): ProtocolAdapter { toFacilitatorSvmSigner(config.operator.signer.signer, { defaultRpcUrl: config.rpcUrl }), ), ); - if (config.replayStore === undefined || !isReservingReplayStore(config.replayStore)) { - throw new ConfigurationError('x402 exact requires a replayStore with atomic reserve capability.'); - } - const reserveStore = config.replayStore; + const reserveStore = resolveX402ReplayStore(config, 'exact'); async function claimPayload(key: string): Promise { return await reserveStore.reserve(`${CONSUMED_PREFIX}${key}`, true, MAX_TIMEOUT_SECONDS); From 3cd4761de0cb4146aad800314bafcb730d21f04e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:56:08 +0300 Subject: [PATCH 06/11] fix(x402): bound the upto reservation TTL against a payer-signed expiresAt The upto replay reservation used max(300, channel.expiresAt - now) as its TTL. expiresAt is payer-signed and unbounded above, so a far-future value minted effectively permanent route/consumed keys. Clamp the TTL to a 24h hard ceiling above the completion-window floor so a channel authorization cannot pin replay keys indefinitely. --- .../src/__tests__/x402-upto-ttl.test.ts | 78 +++++++++++++++++++ .../pay-kit/src/adapters/x402-upto.ts | 19 +++-- 2 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 typescript/packages/pay-kit/src/__tests__/x402-upto-ttl.test.ts diff --git a/typescript/packages/pay-kit/src/__tests__/x402-upto-ttl.test.ts b/typescript/packages/pay-kit/src/__tests__/x402-upto-ttl.test.ts new file mode 100644 index 000000000..ee1a92616 --- /dev/null +++ b/typescript/packages/pay-kit/src/__tests__/x402-upto-ttl.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; + +// The upto engine broadcasts the open through an in-process @x402/core +// facilitator (real crypto + RPC). Stub verify so verifyOpen reaches the replay +// reservation offline; the point under test is the TTL the reservation gets. + +vi.mock('@x402/core/facilitator', () => { + class FakeFacilitator { + register(): this { + return this; + } + verify(): Promise<{ isValid: boolean; payer: string }> { + return Promise.resolve({ isValid: true, payer: 'PAYER' }); + } + } + return { x402Facilitator: FakeFacilitator }; +}); + +vi.mock('@x402/core/http', () => ({ + decodePaymentSignatureHeader: (header: string) => JSON.parse(header), + encodePaymentRequiredHeader: () => 'ENCODED_PAYMENT_REQUIRED', + encodePaymentResponseHeader: () => 'ENCODED_PAYMENT_RESPONSE', +})); + +const { X402Upto } = await import('../adapters/x402-upto.js'); +const { configure } = await import('../config.js'); +const { usd } = await import('../price.js'); + +const NOW = () => Math.floor(Date.now() / 1000); + +function recordingStore() { + const reserve: { key: string; ttlSeconds?: number }[] = []; + return { + reserve, + store: { + delete: () => Promise.resolve(), + get: () => Promise.resolve(null), + put: () => Promise.resolve(), + reserve: (key: string, _value: unknown, ttlSeconds?: number) => { + reserve.push({ key, ttlSeconds }); + return Promise.resolve(true); + }, + }, + }; +} + +async function uptoFor(expiresAt: number) { + const recorder = recordingStore(); + const config = await configure({ + accept: ['x402'], + network: 'solana_localnet', + replayStore: recorder.store as never, + }); + const upto = new X402Upto(config); + const payload = JSON.stringify({ + accepted: { network: 'solana:localnet' }, + payload: { channelId: 'CH1', expiresAt, from: 'PAYER' }, + }); + await upto.verifyOpen(new Request('http://localhost/u', { headers: { 'x-payment': payload } }), usd('1.00')); + return recorder.reserve.map(entry => entry.ttlSeconds); +} + +describe('x402 upto reservation TTL is bounded by the payer-signed expiresAt', () => { + it('caps a far-future expiresAt at the hard ceiling (24h)', async () => { + const tenYears = NOW() + 10 * 365 * 24 * 3600; + for (const ttl of await uptoFor(tenYears)) expect(ttl).toBe(24 * 60 * 60); + }); + + it('uses the remaining window when it sits between the floor and the ceiling', async () => { + const inTenMinutes = NOW() + 600; + for (const ttl of await uptoFor(inTenMinutes)) expect(ttl).toBeCloseTo(600, -1); + }); + + it('floors an already-expired channel at the completion window (300s)', async () => { + const past = NOW() - 10; + for (const ttl of await uptoFor(past)) expect(ttl).toBe(300); + }); +}); diff --git a/typescript/packages/pay-kit/src/adapters/x402-upto.ts b/typescript/packages/pay-kit/src/adapters/x402-upto.ts index bbe6223b1..134de86a1 100644 --- a/typescript/packages/pay-kit/src/adapters/x402-upto.ts +++ b/typescript/packages/pay-kit/src/adapters/x402-upto.ts @@ -17,14 +17,14 @@ import { UptoSvmScheme as UptoSvmFacilitator } from '@x402/svm/upto/facilitator' import { requireMint, resolveCoin } from '../coin.js'; import type { PayKitConfig } from '../config.js'; -import { ConfigurationError, InvalidProofError } from '../errors.js'; +import { InvalidProofError } from '../errors.js'; import type { Price } from '../price.js'; import { caip2 } from '../protocol.js'; import { assertPaymentHeaderWithinCap, ChallengeBlockhashCache, errorMessage, - isReservingReplayStore, + resolveX402ReplayStore, x402PaymentHeader, } from './x402-shared.js'; @@ -35,6 +35,13 @@ const PAYMENT_REQUIRED_HEADER = 'payment-required'; const X402_VERSION = 2; const MAX_TIMEOUT_SECONDS = 300; const DEFAULT_WITHDRAW_DELAY_SECONDS = 900; +/** + * Hard ceiling on a replay reservation's TTL. `expiresAt` is payer-signed and + * unbounded above; without a cap a far-future value would mint effectively + * permanent route/consumed keys. 24h comfortably covers any real open-to-settle + * window while keeping stale keys self-expiring. + */ +const MAX_RESERVATION_TTL_SECONDS = 24 * 60 * 60; const BASIS_POINTS_DENOMINATOR = 10_000; const CONSUMED_PREFIX = 'x402-svm-upto:consumed:'; const ROUTE_PREFIX = 'x402-svm-upto:route:'; @@ -123,10 +130,7 @@ export class X402Upto { this.#signer = config.operator.signer; this.#recipient = config.operator.recipient; this.#rpcUrl = config.rpcUrl; - if (config.replayStore === undefined || !isReservingReplayStore(config.replayStore)) { - throw new ConfigurationError('x402 upto requires a replayStore with atomic reserve capability.'); - } - this.#replayStore = config.replayStore; + this.#replayStore = resolveX402ReplayStore(config, 'upto'); this.#stablecoins = config.stablecoins; this.#facilitator = new x402Facilitator().register( this.#network, @@ -193,7 +197,8 @@ export class X402Upto { throw new InvalidProofError(verification.invalidReason ?? 'invalid_proof', verification.invalidMessage); } const channel = parseUptoPayload(payload); - const ttlSeconds = Math.max(MAX_TIMEOUT_SECONDS, channel.expiresAt - Math.floor(Date.now() / 1000)); + const remaining = channel.expiresAt - Math.floor(Date.now() / 1000); + const ttlSeconds = Math.min(Math.max(MAX_TIMEOUT_SECONDS, remaining), MAX_RESERVATION_TTL_SECONDS); await this.#bindChannelRoute(channel.channelId, request, ttlSeconds); const channelId = channel.channelId; const replayKey = `${CONSUMED_PREFIX}${channelId}`; From 0f53e0161ceeb02c4f546d57820af3e52dcdb992 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:27:35 +0300 Subject: [PATCH 07/11] fix(x402): upgrade atomic putIfAbsent replay stores to the reserve surface A dual x402+mpp config can inject (or auto-provision via the unsafe opt-in) an mpp-style atomic putIfAbsent store. Instead of rejecting it, wrap it in the atomic replay-store view so x402's reserve-before-settle fences the same consumed-signature markers as mpp. Reserve-capable stores pass through; non-atomic stores still fail closed. --- .../pay-kit/src/adapters/x402-shared.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/typescript/packages/pay-kit/src/adapters/x402-shared.ts b/typescript/packages/pay-kit/src/adapters/x402-shared.ts index 6c1324a8d..463707042 100644 --- a/typescript/packages/pay-kit/src/adapters/x402-shared.ts +++ b/typescript/packages/pay-kit/src/adapters/x402-shared.ts @@ -2,7 +2,13 @@ import { createSolanaRpc } from '@solana/kit'; import type { PayKitConfig } from '../config.js'; import { ConfigurationError, InvalidProofError } from '../errors.js'; -import { createMemoryReplayStore, isReservingReplayStore, type ReservingReplayStore } from '../replay-store.js'; +import { + atomicReplayStoreView, + createMemoryReplayStore, + isAtomicReplayStore, + isReservingReplayStore, + type ReservingReplayStore, +} from '../replay-store.js'; export { isReservingReplayStore, type ReservingReplayStore } from '../replay-store.js'; @@ -21,10 +27,17 @@ const ALLOW_INMEMORY_REPLAY_STORE_ENV = 'PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'; export function resolveX402ReplayStore(config: PayKitConfig, scheme: string): ReservingReplayStore { const store = config.replayStore; if (store !== undefined) { - if (!isReservingReplayStore(store)) { - throw new ConfigurationError(`x402 ${scheme} requires a replayStore with atomic reserve capability.`); + if (isReservingReplayStore(store)) { + return store; } - return store; + // A store built for MPP's atomic `putIfAbsent` contract (e.g. the shared + // process-local store a dual x402+mpp config provisions from the unsafe + // opt-in) is upgraded to the reserve surface via the atomic view, so both + // protocols fence against the same underlying consumed-signature markers. + if (isAtomicReplayStore(store)) { + return atomicReplayStoreView(store); + } + throw new ConfigurationError(`x402 ${scheme} requires a replayStore with atomic reserve capability.`); } if (config.network === 'solana_localnet' || process.env[ALLOW_INMEMORY_REPLAY_STORE_ENV] === '1') { return createMemoryReplayStore(); From ecb042ab640fe2ada6e9c8df512bb8df7b0aa804 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:27:35 +0300 Subject: [PATCH 08/11] test(x402): pin accept to x402 in pure-x402 fixtures under the mpp store policy The mpp replay-store policy (declared atomic stores only) rejects the reserve-only memory store these pure-x402 fixtures inject. Pin accept: ['x402'] so the fixtures exercise the x402 reserve contract they test. --- harness/test/x402-upto-over-ceiling.test.ts | 5 ++++- .../packages/pay-kit/src/__tests__/x402-adapter.test.ts | 3 +++ .../packages/pay-kit/src/__tests__/x402-shared.test.ts | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/harness/test/x402-upto-over-ceiling.test.ts b/harness/test/x402-upto-over-ceiling.test.ts index 0d7ee72f6..d8ad5a279 100644 --- a/harness/test/x402-upto-over-ceiling.test.ts +++ b/harness/test/x402-upto-over-ceiling.test.ts @@ -38,8 +38,11 @@ const CEILING = 1_000_000n; async function makeUpto(): Promise { // A minimal, offline config (no live RPC needed: the ceiling guard precedes - // all network work). Mirrors the SDK unit test's testConfig(). + // all network work). Mirrors the SDK unit test's testConfig(). accept pins + // x402 so the MPP replay-store policy (which requires a declared atomic + // store) does not apply to this pure-upto fixture. const config = await configure({ + accept: ["x402"], mpp: { challengeBindingSecret: "x402-upto-ceiling-secret" }, network: "solana_localnet", }); diff --git a/typescript/packages/pay-kit/src/__tests__/x402-adapter.test.ts b/typescript/packages/pay-kit/src/__tests__/x402-adapter.test.ts index 8377573dc..d62a5b78e 100644 --- a/typescript/packages/pay-kit/src/__tests__/x402-adapter.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/x402-adapter.test.ts @@ -100,6 +100,7 @@ const { createMemoryReplayStore } = await import('../replay-store.js'); async function setup() { const config = await configure({ + accept: ['x402'], mpp: { challengeBindingSecret: 'x402-adapter-secret' }, network: 'solana_localnet', replayStore: createMemoryReplayStore(), @@ -113,6 +114,7 @@ function paidRequest(header = 'PAYMENT_CRED'): Request { async function gateFor(amount = usd('0.10')) { const config = await configure({ + accept: ['x402'], mpp: { challengeBindingSecret: 'x402-adapter-secret' }, network: 'solana_localnet', }); @@ -574,6 +576,7 @@ describe('createX402ExactAdapter', () => { async function setupWithStore(store: unknown) { const config = await configure({ + accept: ['x402'], mpp: { challengeBindingSecret: 'x402-adapter-secret' }, network: 'solana_localnet', replayStore: store as never, diff --git a/typescript/packages/pay-kit/src/__tests__/x402-shared.test.ts b/typescript/packages/pay-kit/src/__tests__/x402-shared.test.ts index a6f52952b..f60fb3345 100644 --- a/typescript/packages/pay-kit/src/__tests__/x402-shared.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/x402-shared.test.ts @@ -129,6 +129,7 @@ const { createMemoryReplayStore } = await import('../replay-store.js'); async function payKitConfig() { return configure({ + accept: ['x402'], mpp: { challengeBindingSecret: 'x402-cap-secret' }, network: 'solana_localnet', replayStore: createMemoryReplayStore(), From cb6fce9617fbec8df9efb6e707127f26be2f6519 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:27:36 +0300 Subject: [PATCH 09/11] test(php): align framework fixtures with the shared replay-store policy x402-only devnet constructions acknowledge single-process replay scope via PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE; mpp-only localnet framework tests opt into allow_unsafe_memory_store (the mpp adapter requires the explicit opt-in even on localnet); the Symfony listener is constructed with a named x402: argument (mpp/mppFactory keep their reflected positions). --- .../Frameworks/ReplayStoreInjectionTest.php | 8 ++- php/tests/Middleware/RequirePaymentTest.php | 69 ++++++++++++------- 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/php/tests/Frameworks/ReplayStoreInjectionTest.php b/php/tests/Frameworks/ReplayStoreInjectionTest.php index 33d04d4a8..24a78c967 100644 --- a/php/tests/Frameworks/ReplayStoreInjectionTest.php +++ b/php/tests/Frameworks/ReplayStoreInjectionTest.php @@ -230,6 +230,9 @@ public function testLaravelMppOnlyProviderBootsAndRunsWithoutX402State(): void 'accept' => ['mpp'], 'preflight' => false, 'mpp_challenge_binding_secret' => 'framework-test-secret-0123456789abcdef', + // MPP requires an explicit process-local opt-in even on localnet + // (no implicit network escape); acknowledge single-process scope. + 'mpp' => ['allow_unsafe_memory_store' => true], 'x402_replay_store' => 'missing.x402.replay_store', ]); $provider = new PayKitServiceProvider($app); @@ -273,7 +276,7 @@ public function testSymfonyUsesInjectedX402Adapter(): void $this->pricing(), $this->psrFactory(), new HttpFoundationFactory(), - $this->x402Adapter(), + x402: $this->x402Adapter(), ); $event = $this->symfonyEvent(); @@ -309,6 +312,9 @@ public function testSymfonyMppOnlyContainerBootsAndRunsWithoutX402State(): void 'accept' => ['mpp'], 'preflight' => false, 'mpp_challenge_binding_secret' => 'framework-test-secret-0123456789abcdef', + // MPP requires an explicit process-local opt-in even on localnet + // (no implicit network escape); acknowledge single-process scope. + 'mpp_allow_unsafe_memory_store' => true, 'x402_replay_store' => 'missing.x402.replay_store', ]], $container); diff --git a/php/tests/Middleware/RequirePaymentTest.php b/php/tests/Middleware/RequirePaymentTest.php index e1fd9c5db..5c0528d76 100644 --- a/php/tests/Middleware/RequirePaymentTest.php +++ b/php/tests/Middleware/RequirePaymentTest.php @@ -35,38 +35,55 @@ final class RequirePaymentTest extends TestCase public function testX402OnlyConstructionDoesNotRequireMppReplayStore(): void { - $client = new PayKit(new Config( - network: Network::SolanaDevnet, - accept: [Protocol::X402], - operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate()), - preflight: false, - )); - $middleware = new RequirePayment($client, new Gate(amount: Price::usd('0.10'))); - self::assertInstanceOf(RequirePayment::class, $middleware); + // x402 requires a durable shared replay store off-localnet; this + // single-process unit test acknowledges that scope via the opt-in so it + // can exercise the auto-wiring path. The assertion under test is that an + // x402-only client needs NO MPP replay store (the MPP adapter stays lazy). + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1'); + try { + $client = new PayKit(new Config( + network: Network::SolanaDevnet, + accept: [Protocol::X402], + operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate()), + preflight: false, + )); + $middleware = new RequirePayment($client, new Gate(amount: Price::usd('0.10'))); + self::assertInstanceOf(RequirePayment::class, $middleware); + } finally { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'); + } } public function testX402OnlyGateDoesNotConstructMppReplayStore(): void { - $client = new PayKit(new Config( - network: Network::SolanaDevnet, - accept: [Protocol::X402, Protocol::Mpp], - operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate()), - preflight: false, - mpp: new MppConfig( - challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01', - ), - )); - $middleware = new RequirePayment( - $client, - new Gate(amount: Price::usd('0.10'), accept: [Protocol::X402]), - ); + // Same single-process x402 opt-in as above; the assertion is that an + // x402-only gate never constructs the MPP replay store even when the + // client also accepts MPP. + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1'); + try { + $client = new PayKit(new Config( + network: Network::SolanaDevnet, + accept: [Protocol::X402, Protocol::Mpp], + operator: new Operator(recipient: Signer::generate()->pubkey(), signer: Signer::generate()), + preflight: false, + mpp: new MppConfig( + challengeBindingSecret: 'unit-test-secret-0123456789abcdef-01', + ), + )); + $middleware = new RequirePayment( + $client, + new Gate(amount: Price::usd('0.10'), accept: [Protocol::X402]), + ); - $response = $middleware->process( - $this->factory->createServerRequest('GET', '/paid'), - $this->nextHandler(), - ); + $response = $middleware->process( + $this->factory->createServerRequest('GET', '/paid'), + $this->nextHandler(), + ); - self::assertSame(402, $response->getStatusCode()); + self::assertSame(402, $response->getStatusCode()); + } finally { + putenv('PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE'); + } } protected function setUp(): void From 7c8f78ce2cf4f5913de5e7f6e687abc560d33aff Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:27:36 +0300 Subject: [PATCH 10/11] test(x402): assert upto TTL fixtures are non-empty before asserting per-entry An empty reservation list would let the per-entry TTL assertions pass vacuously; require at least one entry so the guard cannot false-green. --- .../pay-kit/src/__tests__/x402-upto-ttl.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/typescript/packages/pay-kit/src/__tests__/x402-upto-ttl.test.ts b/typescript/packages/pay-kit/src/__tests__/x402-upto-ttl.test.ts index ee1a92616..96f5b7c54 100644 --- a/typescript/packages/pay-kit/src/__tests__/x402-upto-ttl.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/x402-upto-ttl.test.ts @@ -63,16 +63,22 @@ async function uptoFor(expiresAt: number) { describe('x402 upto reservation TTL is bounded by the payer-signed expiresAt', () => { it('caps a far-future expiresAt at the hard ceiling (24h)', async () => { const tenYears = NOW() + 10 * 365 * 24 * 3600; - for (const ttl of await uptoFor(tenYears)) expect(ttl).toBe(24 * 60 * 60); + const ttls = await uptoFor(tenYears); + expect(ttls.length).toBeGreaterThan(0); + for (const ttl of ttls) expect(ttl).toBe(24 * 60 * 60); }); it('uses the remaining window when it sits between the floor and the ceiling', async () => { const inTenMinutes = NOW() + 600; - for (const ttl of await uptoFor(inTenMinutes)) expect(ttl).toBeCloseTo(600, -1); + const ttls = await uptoFor(inTenMinutes); + expect(ttls.length).toBeGreaterThan(0); + for (const ttl of ttls) expect(ttl).toBeCloseTo(600, -1); }); it('floors an already-expired channel at the completion window (300s)', async () => { const past = NOW() - 10; - for (const ttl of await uptoFor(past)) expect(ttl).toBe(300); + const ttls = await uptoFor(past); + expect(ttls.length).toBeGreaterThan(0); + for (const ttl of ttls) expect(ttl).toBe(300); }); }); From bfdebee67ba008fc8ef3944cfc63e085d236abec Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:48:45 +0300 Subject: [PATCH 11/11] test(boot-policy): promote the php and lua native store guards to live probes The asserted-skip roster's honesty gate fired here: this leaf's tree carries the php/lua native fail-closed store guards, so labeling them absent is no longer true. Adopt the reconciled native-guard framework (probes key on each guard's in-tree marker and assert both the missing-store and the non-durable/ non-shared rejections), with the ruby marker keyed on the SHARED-store predicate its capability leaf introduces, so that probe stays honestly pending until its full subject lands. Here: 12 passed, 8 pending-skips with owned reasons. --- harness/test/boot-policy.test.ts | 215 ++++++++++++++++++++++++++----- 1 file changed, 182 insertions(+), 33 deletions(-) diff --git a/harness/test/boot-policy.test.ts b/harness/test/boot-policy.test.ts index 8d35ad448..9f5a37524 100644 --- a/harness/test/boot-policy.test.ts +++ b/harness/test/boot-policy.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,7 +8,7 @@ import type { ImplementationDefinition } from "../src/implementations"; import { startServer, stopServer } from "../src/process"; // --------------------------------------------------------------------------- -// Cross-SDK "deployment policy" conformance: fail-CLOSED store construction. +// Cross-SDK deployment-policy conformance: fail-CLOSED store construction. // // The rest of the harness compares verify DECISIONS on fixed transactions. It // never exercises store-construction / boot-time safety policy, so nothing @@ -18,15 +18,14 @@ import { startServer, stopServer } from "../src/process"; // configured and PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE is unset — a // process-local in-memory store silently loses double-spend protection on // a multi-replica deploy (fail-OPEN). -// * TS and Python (the high-level server adapters the harness fixtures boot) -// fail OPEN today: they construct a process-local in-memory store off -// localnet and boot to `ready` anyway. +// * TypeScript and Python high-level server adapters now reject an omitted or +// process-local in-memory store off-localnet unless an explicit development +// opt-in is supplied. // -// SECURITY.md claims PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE is honored across the -// Go, TypeScript, and Python SDKs, but no test proved it. This file is that -// proof. It drives each SDK's real server/high-level-adapter *constructor* -// (reusing the harness `startServer` spawn machinery and the existing -// `*-server` fixtures) and asserts: +// Go, TypeScript, and Python share PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE. This +// file drives each SDK's real server/high-level-adapter *constructor* (reusing +// the harness `startServer` spawn machinery and the existing `*-server` +// fixtures) and asserts: // // 1. Constructed off-localnet (network=mainnet) with NO shared store and // WITHOUT PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE=1 => it MUST fail CLOSED @@ -38,6 +37,13 @@ import { startServer, stopServer } from "../src/process"; // These are blocking regression probes: Go, TypeScript, and Python must reject // unsafe off-localnet construction, while the explicit devnet escape remains // usable. Any SDK that silently falls back to process-local state goes RED. +// PHP, Ruby, and Lua do NOT use PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE, but they +// DO enforce the same off-localnet fail-closed policy through their native +// config contract (reject an omitted or non-durable/non-shared replay store off +// localnet). The source-contract probes near the end of this file pin those +// real guards rather than pretending they implement this env-var API. Rust, +// Kotlin, and Swift ship no such store guard in-tree yet, so they are +// asserted-SKIPPED with an honest reason. // // FALSE-GREEN GUARD: the fail-closed assertion does not merely check "the boot // failed" — a missing toolchain, unbuilt binary, or bad RPC would fail boot for @@ -334,21 +340,6 @@ const unimplementedProbes: Array<{ id: string; reason: string }> = [ reason: "Rust MPP server exposes no PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE boot-time fail-closed guard", }, - { - id: "php", - reason: - "PHP SolanaChargeHandler has a MemoryStore but no off-localnet fail-closed boot guard", - }, - { - id: "ruby", - reason: - "Ruby MPP runtime has a MemoryStore but no off-localnet fail-closed boot guard", - }, - { - id: "lua", - reason: - "Lua resty.pay_kit exposes no in-memory-store fail-closed boot guard", - }, { id: "kotlin", reason: "Kotlin adapter exposes no in-memory-store fail-closed boot guard", @@ -362,11 +353,11 @@ const unimplementedProbes: Array<{ id: string; reason: string }> = [ // Loud note: surface the coverage gap in the run log, not just as silent skips. // eslint-disable-next-line no-console console.warn( - "[boot-policy] fail-closed store contract is only implemented/remediated for " + - "go, typescript, python. Asserting-SKIP the rest (no PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE " + - "boot guard in their SDK source): " + - unimplementedProbes.map((p) => p.id).join(", ") + - ". Extending the contract cross-SDK is an open audit gap.", + "[boot-policy] shared PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE opt-in contract: " + + "go, typescript, python. php, ruby, lua enforce the same off-localnet " + + "fail-closed policy via their native config contract (pinned by the source " + + "probes below). Asserting-SKIP (no store guard in-tree): " + + unimplementedProbes.map((p) => p.id).join(", ") + ".", ); // Boot the SDK at network=mainnet with NO opt-in and assert it fails CLOSED @@ -437,6 +428,7 @@ describe("boot-policy conformance: boots with the opt-in", () => { } }); + describe("boot-policy conformance: SDKs without the store fail-closed contract", () => { for (const probe of unimplementedProbes) { // eslint-disable-next-line no-console @@ -458,9 +450,6 @@ describe("boot-policy conformance: SDKs without the store fail-closed contract", // excluded automatically. const SDK_SOURCE_DIR: Record = { rust: "rust", - php: "php", - ruby: "ruby", - lua: "lua", kotlin: "kotlin", swift: "swift", }; @@ -484,3 +473,163 @@ describe("boot-policy: asserted-skip roster stays honest (no half-implemented co }); } }); + +// --------------------------------------------------------------------------- +// Native (non-env-var) off-localnet fail-CLOSED store guards: php, ruby, lua. +// +// These SDKs do NOT use the shared PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE opt-in, +// so the boot probes above skip them. They deliver the same money-path safety +// through their native config contract: off-localnet, an omitted or +// non-durable/non-shared replay store is REJECTED at adapter/server +// construction (fail-closed). That is a real, working guard, so it is COVERED +// here rather than mislabeled as absent. +// +// Detection mirrors the #238 sdkImplementsGuard mechanism (git-grep the SDK's +// tracked source for its guard marker): a probe is REQUIRED only when its guard +// is present in-tree, and asserts-SKIP with an honest reason otherwise (e.g. the +// SDK is not checked out in this split). `sdkFilesReferencingOptIn` above stays +// byte-identical to #238 for the env-var roster; this parallel helper carries +// the per-SDK native marker. When present, the probe pins BOTH sides of the +// contract: the missing-store rejection and the non-durable/non-shared one. +// --------------------------------------------------------------------------- +type NativeGuardAssertion = { file: string; mechanism: string; pattern: RegExp }; +type NativeGuardProbe = { + id: string; + label: string; + sdkDir: string; + // Stable substring of the guard's rejection message; its presence in-tree is + // what promotes the probe from asserted-skip to required. + guardMarker: string; + assertions: NativeGuardAssertion[]; +}; + +// git grep -l -- , same failure handling as +// sdkFilesReferencingOptIn (kept separate so that helper stays byte-identical to +// #238). Empty result => guard/source not in this checkout => honest skip. +function sdkFilesMatchingMarker(sdkDir: string, marker: string): string[] { + try { + const out = execFileSync("git", ["grep", "-l", marker, "--", sdkDir], { + cwd: REPO_ROOT, + encoding: "utf8", + }); + return out.split("\n").filter(Boolean); + } catch (error) { + const status = (error as { status?: number }).status; + const stdout = (error as { stdout?: string }).stdout ?? ""; + // git grep exits 1 with empty output when there are no matches -> honest skip. + if (status === 1 && stdout.trim() === "") return []; + throw error; + } +} + +const nativeGuardProbes: NativeGuardProbe[] = [ + { + id: "php", + label: "PHP MPP adapter/handler off-localnet replay-store guard", + sdkDir: "php", + guardMarker: "replayStore is required outside localnet", + assertions: [ + { + file: "php/src/Protocols/Mpp/Adapter.php", + mechanism: "rejects an omitted replay store outside localnet", + pattern: + /\$replayStore === null[\s\S]*?network !== Network::SolanaLocalnet[\s\S]*?replayStore is required outside localnet/, + }, + { + file: "php/src/Protocols/Mpp/Adapter.php", + mechanism: + "rejects a non-durable/non-shared replay store outside localnet", + pattern: + /network !== Network::SolanaLocalnet[\s\S]*?providesDurableSharedReplayProtection\(\)[\s\S]*?must explicitly declare durable shared replay protection outside localnet/, + }, + { + file: "php/src/Protocols/Mpp/Server/SolanaChargeHandler.php", + mechanism: + "enforces the same off-localnet guard on direct handler construction", + pattern: + /network !== 'localnet'[\s\S]*?replayStore is required outside localnet/, + }, + ], + }, + { + id: "ruby", + label: "Ruby MPP runtime off-localnet replay-store guard", + sdkDir: "ruby", + // Keyed on the SHARED-store predicate the ruby capability leaf introduces + // (the pre-leaf runtime only pins durability, and this probe's non-durable/ + // non-shared assertion needs both sides): the probe stays honestly skipped + // until that leaf is in the tree, then activates with its full subject. + guardMarker: "durable_shared_replay_store?", + assertions: [ + { + file: "ruby/lib/pay_kit/protocols/mpp/runtime.rb", + mechanism: "rejects the implicit dev-only memory store outside localnet", + pattern: + /replay_store == DEV_ONLY_MEMORY_STORE[\s\S]*?unless localnet\?\(method\)[\s\S]*?requires a durable replay_store/, + }, + { + file: "ruby/lib/pay_kit/protocols/mpp/runtime.rb", + mechanism: "rejects a supplied non-durable replay store outside localnet", + pattern: + /unless localnet\?\(method\) \|\| durable_shared_replay_store\?\(replay_store\)[\s\S]*?requires a durable replay_store/, + }, + ], + }, + { + id: "lua", + label: "Lua MPP server off-localnet replay-store guard", + sdkDir: "lua", + guardMarker: "replay store is required outside localnet", + assertions: [ + { + file: "lua/pay_kit/protocols/mpp/init.lua", + mechanism: "rejects an omitted replay store outside localnet", + pattern: + /if not replay_store[\s\S]*?network ~= 'localnet'[\s\S]*?replay store is required outside localnet/, + }, + { + file: "lua/pay_kit/protocols/mpp/init.lua", + mechanism: "rejects a non-shared replay store outside localnet", + pattern: + /network ~= 'localnet' and not replay_store_is_shared\(replay_store\)[\s\S]*?must declare shared=true outside localnet/, + }, + { + file: "lua/pay_kit/protocols/mpp/server/init.lua", + mechanism: + "enforces the same off-localnet guard on the low-level server entry", + pattern: + /network ~= 'localnet'[\s\S]*?replay store must be shared outside localnet/, + }, + ], + }, +]; + +function readSdkSource(file: string): string { + return readFileSync(join(REPO_ROOT, file), "utf8"); +} + +describe("boot-policy: native off-localnet fail-closed store guards (php, ruby, lua)", () => { + for (const probe of nativeGuardProbes) { + const guardFiles = sdkFilesMatchingMarker(probe.sdkDir, probe.guardMarker); + if (guardFiles.length === 0) { + // eslint-disable-next-line no-console + console.warn( + `[boot-policy] SKIP ${probe.id} native guard probe: no "${probe.guardMarker}" ` + + `marker under ${probe.sdkDir}/ in this checkout (SDK source absent or guard moved).`, + ); + } + for (const assertion of probe.assertions) { + it.skipIf(guardFiles.length === 0)( + `${probe.id}: ${assertion.mechanism}`, + () => { + expect( + readSdkSource(assertion.file), + `${probe.label} regressed: expected ${assertion.file} to ${assertion.mechanism}. ` + + `${probe.id} enforces its off-localnet fail-closed guard through this native ` + + `contract, not ${OPT_IN_ENV}; fix the SDK, do not delete the probe.`, + ).toMatch(assertion.pattern); + }, + ); + } + } +});