diff --git a/.changeset/sdk-facilitator-unknown-outcome.md b/.changeset/sdk-facilitator-unknown-outcome.md new file mode 100644 index 0000000..9cac9f4 --- /dev/null +++ b/.changeset/sdk-facilitator-unknown-outcome.md @@ -0,0 +1,5 @@ +--- +'radius-sdk': patch +--- + +`radiusPayments` answers `502` (`facilitator_error`) when a facilitator call fails without the facilitator's own answer, such as a dropped connection or an error page from a gateway. These were reported as `402`, which a buyer reads as "rejected, nothing moved", although a settle may have reached the chain. The facilitator's own rejections are still `402`. diff --git a/packages/sdk/src/hono/facilitator.ts b/packages/sdk/src/hono/facilitator.ts index e2b3f22..94a48c4 100644 --- a/packages/sdk/src/hono/facilitator.ts +++ b/packages/sdk/src/hono/facilitator.ts @@ -1,5 +1,5 @@ -import { HTTPFacilitatorClient, type FacilitatorClient } from '@x402/core/server'; -import type { PaymentPayload, PaymentRequirements, SettleResponse, SupportedResponse, VerifyResponse } from '@x402/core/types'; +import { FacilitatorResponseError, HTTPFacilitatorClient, type FacilitatorClient } from '@x402/core/server'; +import { SettleError, VerifyError, type PaymentPayload, type PaymentRequirements, type SettleResponse, type SupportedResponse, type VerifyResponse } from '@x402/core/types'; import type { RadiusNetwork } from '../networks.js'; export interface FacilitatorOptions { @@ -72,3 +72,27 @@ export class RadiusFacilitatorClient implements FacilitatorClient { return this.live ? this.http.getSupported() : Promise.resolve(staticSupported(this.network)); } } + +/** + * A facilitator call that fails without the facilitator's own answer (a network error, or an + * error status whose body is not an x402 response) leaves the outcome unknown: a settle may + * have reached the chain. @x402/core reports those as a `402`, which a buyer reads as "rejected, + * nothing moved", so this rethrows them as `FacilitatorResponseError`, which `radiusPayments` + * answers with `502`. The facilitator's own answers (`VerifyError`, `SettleError`) pass through. + */ +export function withUnknownOutcomes(facilitator: FacilitatorClient): FacilitatorClient { + const call = async (operation: string, fn: () => Promise): Promise => { + try { + return await fn(); + } catch (error) { + if (error instanceof FacilitatorResponseError || error instanceof VerifyError || error instanceof SettleError) throw error; + const message = error instanceof Error ? error.message : String(error); + throw Object.assign(new FacilitatorResponseError(`Facilitator ${operation} failed: ${message}`), { cause: error }); + } + }; + return { + verify: (payload, requirements) => call('verify', () => facilitator.verify(payload, requirements)), + settle: (payload, requirements) => call('settle', () => facilitator.settle(payload, requirements)), + getSupported: () => call('supported', () => facilitator.getSupported()), + }; +} diff --git a/packages/sdk/src/hono/index.ts b/packages/sdk/src/hono/index.ts index a17a693..39e03d4 100644 --- a/packages/sdk/src/hono/index.ts +++ b/packages/sdk/src/hono/index.ts @@ -18,7 +18,7 @@ import { resolveNetwork, type Address, type NetworkInput, type NetworkOverrides, import { resolvePrice, type Price } from '../amounts.js'; import { explorerTxUrl } from '../networks.js'; import type { PaymentReceipt } from '../receipt.js'; -import { RadiusFacilitatorClient, type FacilitatorOptions } from './facilitator.js'; +import { RadiusFacilitatorClient, withUnknownOutcomes, type FacilitatorOptions } from './facilitator.js'; import { RadiusExactScheme, type GasSponsoringMode, type SettleMode } from './scheme.js'; export { RadiusFacilitatorClient, staticSupported, type FacilitatorOptions } from './facilitator.js'; @@ -184,9 +184,9 @@ function internalErrorResponse(c: Context, error: unknown): Re export function radiusPayments(options: RadiusPaymentsOptions): MiddlewareHandler { const network = resolveNetwork(options.network, options); const settle: SettleMode = options.settle ?? 'before'; - const facilitator = isFacilitatorClient(options.facilitator) - ? options.facilitator - : new RadiusFacilitatorClient(network, options.facilitator); + const facilitator = withUnknownOutcomes( + isFacilitatorClient(options.facilitator) ? options.facilitator : new RadiusFacilitatorClient(network, options.facilitator), + ); const resourceServer = new x402ResourceServer(facilitator).register(network.network, new RadiusExactScheme(network, settle, options.gasSponsoring ?? 'auto')); const resolvePayTo = (spec: PayTo) => diff --git a/packages/sdk/test/hono.test.ts b/packages/sdk/test/hono.test.ts index d4f107f..39a14de 100644 --- a/packages/sdk/test/hono.test.ts +++ b/packages/sdk/test/hono.test.ts @@ -212,3 +212,58 @@ describe('radiusPayments paid flow (facilitator mocked)', () => { expect(calls).toHaveLength(0); }); }); + +describe('radiusPayments when the facilitator gives no answer', () => { + // A settle that fails without the facilitator's own answer may still have reached the chain, + // so the buyer must see 502 (outcome unknown), not 402 (rejected, nothing moved). + function mockFacilitator(respond: (path: 'verify' | 'settle') => Response | Promise) { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input instanceof Request ? input.url : input); + if (url.endsWith('/verify')) return respond('verify'); + if (url.endsWith('/settle')) return respond('settle'); + throw new Error(`unexpected fetch ${url}`); + }); + } + const gatewayTimeout = () => new Response('504 Gateway Time-out', { status: 504, headers: { 'content-type': 'text/html' } }); + const verified = () => Response.json({ isValid: true, payer: '0xabc' }); + + async function pay(app: Hono) { + const sig = await payloadFor(app); + return app.request('http://seller.test/api/lookup', { headers: { 'PAYMENT-SIGNATURE': sig } }); + } + + it('answers 502 when settle returns an error page', async () => { + mockFacilitator(gatewayTimeout); + const res = await pay(makeApp()); + expect(res.status).toBe(502); + expect(await res.json()).toMatchObject({ error: 'facilitator_error' }); + }); + + it('answers 502 when the settle connection fails', async () => { + mockFacilitator(() => { + throw new TypeError('fetch failed'); + }); + const res = await pay(makeApp()); + expect(res.status).toBe(502); + expect(await res.text()).not.toContain('secret'); + }); + + it('answers 502 when settle fails after the handler (settle: "after")', async () => { + mockFacilitator((path) => (path === 'verify' ? verified() : gatewayTimeout())); + const res = await pay(makeApp({ settle: 'after' })); + expect(res.status).toBe(502); + expect(await res.text()).not.toContain('secret'); + }); + + it('answers 502 when verify returns an error page (settle: "after")', async () => { + mockFacilitator(gatewayTimeout); + const res = await pay(makeApp({ settle: 'after' })); + expect(res.status).toBe(502); + }); + + it("keeps 402 for the facilitator's own settle rejection", async () => { + mockFacilitator(() => Response.json({ success: false, errorReason: 'insufficient_funds', transaction: '', network: 'eip155:72344' }, { status: 400 })); + const res = await pay(makeApp()); + expect(res.status).toBe(402); + }); +});