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
2 changes: 1 addition & 1 deletion services/appraisal-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/run.ts",
"test": "node --import tsx --test src/appraisal.test.ts src/appraisal-fixtures.test.ts src/config.test.ts",
"test": "node --import tsx --test src/appraisal.test.ts src/appraisal-fixtures.test.ts src/config.test.ts src/client.test.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
Expand Down
142 changes: 142 additions & 0 deletions services/appraisal-api/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import { x402HTTPClient } from "@x402/core/client";
import { Keypair } from "@stellar/stellar-sdk";
import { describe, test } from "node:test";

import { createPaidFetch, X402PaymentError, AppraisalResponseParseError } from "./client.js";

const TEST_SECRET = Keypair.random().secret();
const VALID_402_BODY = {
x402Version: 2,
resource: "https://example.com/appraise",
accepts: [
{
scheme: "exact",
network: "stellar:testnet",
payTo: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
price: "0.10",
asset: "USDC",
maxTimeoutSeconds: 60,
extra: {},
},
],
error: "Payment required",
metadata: {},
};

function buildResponse(status: number, body: string | undefined, headers?: Record<string, string>) {
const headersMap = new Headers(headers ?? {});
return new Response(body ?? "", {
status,
headers: headersMap,
});
}

describe("createPaidFetch response parsing", () => {
test("throws a typed parse error for an unpaid empty response body", async () => {
const paidFetch = createPaidFetch({ secret: TEST_SECRET });
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => buildResponse(200, "");

try {
await assert.rejects(
() => paidFetch("https://example.com/appraise"),
(err: unknown) => {
assert.ok(err instanceof AppraisalResponseParseError);
assert.equal(err.status, 200);
assert.equal(err.name, "AppraisalResponseParseError");
assert.match(err.message, /invalid JSON body/i);
return true;
},
);
} finally {
globalThis.fetch = originalFetch;
}
});

test("throws a typed parse error for a paid non-JSON response body without exposing raw content", async () => {
const paidFetch = createPaidFetch({ secret: TEST_SECRET });
const originalFetch = globalThis.fetch;
const originalGetPaymentRequiredResponse = x402HTTPClient.prototype.getPaymentRequiredResponse;
const originalCreatePaymentPayload = x402HTTPClient.prototype.createPaymentPayload;
const originalEncodePaymentSignatureHeader =
x402HTTPClient.prototype.encodePaymentSignatureHeader;
const originalGetPaymentSettleResponse = x402HTTPClient.prototype.getPaymentSettleResponse;

x402HTTPClient.prototype.getPaymentRequiredResponse = () => VALID_402_BODY as never;
x402HTTPClient.prototype.createPaymentPayload = async () => ({
x402Version: 2,
payload: "stub-payload",
resource: "https://example.com/appraise",
accepted: VALID_402_BODY.accepts[0],
extensions: {},
}) as never;
x402HTTPClient.prototype.encodePaymentSignatureHeader = () => ({
"X-PAYMENT": "stub-signature",
});
x402HTTPClient.prototype.getPaymentSettleResponse = () => ({
transaction: "stub-tx",
network: "stellar:testnet",
payer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
}) as never;

let calls = 0;
globalThis.fetch = async (_url, _init) => {
calls += 1;
if (calls === 1) {
return buildResponse(402, JSON.stringify(VALID_402_BODY), {
"x402-version": "2",
"x402-payment-required": "1",
});
}
if (calls === 2) {
return buildResponse(200, "not-json");
}
throw new Error("unexpected extra fetch call");
};

try {
await assert.rejects(
() => paidFetch("https://example.com/appraise"),
(err: unknown) => {
assert.ok(err instanceof AppraisalResponseParseError);
assert.equal(err.status, 200);
assert.equal(err.name, "AppraisalResponseParseError");
assert.doesNotMatch(err.message, /not-json|raw|payload/i);
return true;
},
);
} finally {
globalThis.fetch = originalFetch;
x402HTTPClient.prototype.getPaymentRequiredResponse = originalGetPaymentRequiredResponse;
x402HTTPClient.prototype.createPaymentPayload = originalCreatePaymentPayload;
x402HTTPClient.prototype.encodePaymentSignatureHeader = originalEncodePaymentSignatureHeader;
x402HTTPClient.prototype.getPaymentSettleResponse = originalGetPaymentSettleResponse;
}
});

test("preserves x402 payment errors for non-JSON 402 responses", async () => {
const paidFetch = createPaidFetch({ secret: TEST_SECRET });
const originalFetch = globalThis.fetch;
const originalGetPaymentRequiredResponse = x402HTTPClient.prototype.getPaymentRequiredResponse;
x402HTTPClient.prototype.getPaymentRequiredResponse = () => {
throw new Error("invalid x402 payment required response");
};

globalThis.fetch = async () => buildResponse(402, "not-json");

try {
await assert.rejects(
() => paidFetch("https://example.com/appraise"),
(err: unknown) => {
assert.ok(err instanceof X402PaymentError);
assert.match(err.message, /payment|402/i);
return true;
},
);
} finally {
globalThis.fetch = originalFetch;
x402HTTPClient.prototype.getPaymentRequiredResponse = originalGetPaymentRequiredResponse;
}
});
});
72 changes: 60 additions & 12 deletions services/appraisal-api/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// pay the appraisal API per call.

import { x402Client, x402HTTPClient } from "@x402/core/client";
import type { Network, SettleResponse } from "@x402/core/types";
import type { Network, PaymentRequired, SettleResponse } from "@x402/core/types";
import { createEd25519Signer } from "@x402/stellar";
import { ExactStellarScheme as ClientStellarScheme } from "@x402/stellar/exact/client";

Expand All @@ -28,7 +28,38 @@ export interface PaidResult<T = unknown> {
settlement?: SettleResponse;
}

export class X402PaymentError extends Error {}
export class AppraisalResponseParseError extends Error {
readonly name = "AppraisalResponseParseError";
readonly status: number;

constructor(status: number, options?: ErrorOptions) {
super(`appraisal api returned ${status} with invalid JSON body`, options);
this.status = status;
}
}

export class X402PaymentError extends Error {
readonly name = "X402PaymentError";
readonly status?: number;

constructor(message: string, status?: number) {
super(message);
this.status = status;
}
}

async function parseJsonResponse<T>(res: Response): Promise<T> {
const text = await res.text();
if (!text.trim()) {
throw new AppraisalResponseParseError(res.status);
}

try {
return JSON.parse(text) as T;
} catch (cause) {
throw new AppraisalResponseParseError(res.status, { cause });
}
}

/** Build a paid-fetch function bound to a payer wallet. */
export function createPaidFetch(config: PaidClientConfig) {
Expand All @@ -47,28 +78,45 @@ export function createPaidFetch(config: PaidClientConfig) {
): Promise<PaidResult<T>> {
const first = await fetch(url, init);
if (first.status !== 402) {
return { status: first.status, body: (await first.json()) as T };
return { status: first.status, body: await parseJsonResponse<T>(first) };
}

// 402 → build the signed payment and retry.
const bodyForParse = await first.clone().json().catch(() => undefined);
const paymentRequired = http.getPaymentRequiredResponse(
(name) => first.headers.get(name),
bodyForParse,
);
let bodyForParse: unknown;
try {
bodyForParse = await parseJsonResponse<unknown>(first.clone());
} catch (error) {
if (error instanceof AppraisalResponseParseError) {
bodyForParse = undefined;
} else {
throw error;
}
}

let paymentRequired: PaymentRequired;
try {
paymentRequired = http.getPaymentRequiredResponse(
(name) => first.headers.get(name),
bodyForParse,
);
} catch {
throw new X402PaymentError(
`x402 payment required response was invalid (${first.status})`,
first.status,
);
}

const payload = await http.createPaymentPayload(paymentRequired);
const payHeaders = http.encodePaymentSignatureHeader(payload);

const paid = await fetch(url, {
...init,
headers: { ...(init.headers ?? {}), ...payHeaders },
});
const body = (await paid.json()) as T;
const body = await parseJsonResponse<T>(paid);

if (paid.status !== 200) {
throw new X402PaymentError(
`paid request failed (${paid.status}): ${JSON.stringify(body)}`,
);
throw new X402PaymentError(`paid request failed (${paid.status})`, paid.status);
}
const settlement = http.getPaymentSettleResponse((name) => paid.headers.get(name));
return { status: paid.status, body, settlement };
Expand Down
1 change: 1 addition & 0 deletions services/appraisal-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
} from "./config.js";
export {
createPaidFetch,
AppraisalResponseParseError,
X402PaymentError,
type PaidClientConfig,
type PaidResult,
Expand Down