Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sdk-facilitator-unknown-outcome.md
Original file line number Diff line number Diff line change
@@ -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`.
28 changes: 26 additions & 2 deletions packages/sdk/src/hono/facilitator.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 <T>(operation: string, fn: () => Promise<T>): Promise<T> => {
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()),
};
}
8 changes: 4 additions & 4 deletions packages/sdk/src/hono/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -184,9 +184,9 @@ function internalErrorResponse<E extends Env>(c: Context<E>, error: unknown): Re
export function radiusPayments<E extends Env = Env>(options: RadiusPaymentsOptions<E>): MiddlewareHandler<E> {
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<E>) =>
Expand Down
55 changes: 55 additions & 0 deletions packages/sdk/test/hono.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>) {
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('<html>504 Gateway Time-out</html>', { status: 504, headers: { 'content-type': 'text/html' } });
const verified = () => Response.json({ isValid: true, payer: '0xabc' });

async function pay(app: Hono<any>) {
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);
});
});
Loading