diff --git a/services/agent/src/bidder.ts b/services/agent/src/bidder.ts index c24aa9e5..1a81a1c7 100644 --- a/services/agent/src/bidder.ts +++ b/services/agent/src/bidder.ts @@ -3,8 +3,8 @@ import { normalizeError } from "@sub-rosa/logging/errors"; // Autonomous bidder agent — appraisal (x402) → seal → commit. // // The agent never uses the principal key on-chain. It verifies its session -// mandate, pays for an appraisal, sizes a bid within the mandate caps, seals -// with tlock, and commits via the SDK using the session secret. +// mandate, pays for an appraisal, sizes a bid within the mandate caps, seals with tlock, +// and commits via the SDK using the session secret. import { Keypair } from "@stellar/stellar-sdk"; import type { Network, SettleResponse } from "@x402/core/types"; @@ -160,7 +160,7 @@ export async function runBidderAgent(config: BidderAgentConfig, dependencies: Bi const quotedPrice = BigInt(config.mandate.appraisalPriceStroops); assertAppraisalSpendAllowed(config.mandate, quotedPrice, 0n); - const requestBody = JSON.stringify(req); +const requestBody = JSON.stringify(req); // Fail closed before any payment: empty/oversized/credential-like bodies // throw a typed refusal the keeper trace can show — no transfer happens. assertAppraisalRequestBodyAllowed(requestBody); @@ -168,7 +168,7 @@ export async function runBidderAgent(config: BidderAgentConfig, dependencies: Bi const expectedAsset = config.appraisalAsset ?? config.mandate.appraisalAsset; const expectedDestination = config.appraisalPayTo ?? config.mandate.appraisalPayTo; - log(`paying appraisal (${stroopsToUsdc(quotedPrice)} USDC)…`); + log(`paying appraisal (${stroopsToUsdc(quotedPrice)} USDC…); const paidFetch = dependencies.createPaidFetch({ secret: config.sessionSecret, network: config.x402Network ?? "stellar:testnet", diff --git a/services/appraisal-api/src/appraisal.ts b/services/appraisal-api/src/appraisal.ts index 021300f7..fb3f3009 100644 --- a/services/appraisal-api/src/appraisal.ts +++ b/services/appraisal-api/src/appraisal.ts @@ -62,7 +62,7 @@ export interface AppraisalRequest { } export interface Appraisal { - model: typeof APPRAISAL_MODEL; + model: typeof APPRAISALMODEL; itemRef: string; inputsHash: string; fairValue: number; @@ -93,7 +93,7 @@ function canonical(value: unknown): string { const entries = Object.entries(value as Record) .filter(([, v]) => v !== undefined) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`); + .map(([k], v]) => `${JSON.stringify(k)}:${canonical(v)}`); return `{${entries.join(",")}}`; } return JSON.stringify(value ?? null); @@ -275,7 +275,7 @@ export function appraise(req: AppraisalRequest): Appraisal { const suggestedMaxBid = round2(fairValue * (0.8 + 0.15 * confidence)); const rationale = [ - `base ${req.basePrice} USDC scaled by quality×${round2(qualityF)}, demand×${round2(demandF)}, scarcity×${round2(scarcityF)}, risk×${round2(riskF)}`, + `base ${req.basePrice} USDC scaled by quality×${round2(qualityF)}, demand×ä{round2(demandF)}, scarcity×ä{round2(scarcityF)}, risk×ä{round2(riskF)}`, `category '${req.category ?? "none"}' multiplier ×${categoryF}`, `${provided}/4 attributes supplied → confidence ${confidence}`, `suggested max bid is fair value × ${round2(0.8 + 0.15 * confidence)} to preserve margin`, diff --git a/services/appraisal-api/src/client.test.ts b/services/appraisal-api/src/client.test.ts index c2d603ad..cbe39e33 100644 --- a/services/appraisal-api/src/client.test.ts +++ b/services/appraisal-api/src/client.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { x402HTTPClient } from "@x402/core/client"; import { Keypair } from "@stellar/stellar-sdk"; import { describe, test } from "node:test"; @@ -24,6 +25,8 @@ const VALID_402_BODY = { metadata: {}, }; +const VALID_402_BODY_HASH = createHash("sha256").update(JSON.stringify(VALID_402_BODY)).digest("hex"); + function buildResponse(status: number, body: string | undefined, headers?: Record) { const headersMap = new Headers(headers ?? {}); return new Response(body ?? "", { @@ -33,6 +36,152 @@ function buildResponse(status: number, body: string | undefined, headers?: Recor } describe("createPaidFetch response parsing", () => { + test("pays an exact challenge that matches the request", 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, JSON.stringify({ ok: true })); + } + throw new Error("unexpected extra fetch call"); + }; + + try { + const response = await paidFetch("https://example.com/appraise"); + assert.equal(response.status, 200); + assert.equal(calls, 2); + } finally { + globalThis.fetch = originalFetch; + x402HTTPClient.prototype.getPaymentRequiredResponse = originalGetPaymentRequiredResponse; + x402HTTPClient.prototype.createPaymentPayload = originalCreatePaymentPayload; + x402HTTPClient.prototype.encodePaymentSignatureHeader = originalEncodePaymentSignatureHeader; + x402HTTPClient.prototype.getPaymentSettleResponse = originalGetPaymentSettleResponse; + } + }); + + test("does not pay when the challenge amount differs from the request", async () => { + const paidFetch = createPaidFetch({ secret: TEST_SECRET }); + const originalFetch = globalThis.fetch; + const originalGetPaymentRequiredResponse = x402HTTPClient.prototype.getPaymentRequiredResponse; + const originalCreatePaymentPayload = x402HTTPClient.prototype.createPaymentPayload; + + const mismatchedBody = { + ...VALID_402_BODY, + accepts: [{ ...VALID_402_BODY.accepts[0], price: "9.99" }], + }; + x402HTTPClient.prototype.getPaymentRequiredResponse = () => mismatchedBody as never; + let createPaymentPayloadCalls = 0; + x402HTTPClient.prototype.createPaymentPayload = async () => { + createPaymentPayloadCalls += 1; + return { + x402Version: 2, + payload: "stub-payload", + resource: "https://example.com/appraise", + accepted: mismatchedBody.accepts[0], + extensions: {}, + } as never; + }; + + globalThis.fetch = async () => + buildResponse(402, JSON.stringify(mismatchedBody), { + "x402-version": "2", + "x402-payment-required": "1", + }); + + try { + await assert.rejects( + () => paidFetch("https://example.com/appraise"), + (err: unknown) => { + assert.ok(err instanceof X402PaymentError); + assert.doesNotMatch(err.message, /9\.99|stub-payload|raw/i); + return true; + }, + ); + assert.equal(createPaymentPayloadCalls, 0); + } finally { + globalThis.fetch = originalFetch; + x402HTTPClient.prototype.getPaymentRequiredResponse = originalGetPaymentRequiredResponse; + x402HTTPClient.prototype.createPaymentPayload = originalCreatePaymentPayload; + } + }); + + test("does not pay an expired challenge", async () => { + const paidFetch = createPaidFetch({ secret: TEST_SECRET }); + const originalFetch = globalThis.fetch; + const originalGetPaymentRequiredResponse = x402HTTPClient.prototype.getPaymentRequiredResponse; + const originalCreatePaymentPayload = x402HTTPClient.prototype.createPaymentPayload; + + const expiredBody = { + ...VALID_402_BODY, + accepts: [{ ...VALID_402_BODY.accepts[0], maxTimeoutSeconds: 0 }], + }; + x402HTTPClient.prototype.getPaymentRequiredResponse = () => expiredBody as never; + let createPaymentPayloadCalls = 0; + x402HTTPClient.prototype.createPaymentPayload = async () => { + createPaymentPayloadCalls += 1; + return { + x402Version: 2, + payload: "stub-payload", + resource: "https://example.com/appraise", + accepted: expiredBody.accepts[0], + extensions: {}, + } as never; + }; + + globalThis.fetch = async () => + buildResponse(402, JSON.stringify(expiredBody), { + "x402-version": "2", + "x402-payment-required": "1", + }); + + try { + await assert.rejects( + () => paidFetch("https://example.com/appraise"), + (err: unknown) => { + assert.ok(err instanceof X402PaymentError); + assert.doesNotMatch(err.message, /stub-payload|raw/i); + return true; + }, + ); + assert.equal(createPaymentPayloadCalls, 0); + } finally { + globalThis.fetch = originalFetch; + x402HTTPClient.prototype.getPaymentRequiredResponse = originalGetPaymentRequiredResponse; + x402HTTPClient.prototype.createPaymentPayload = originalCreatePaymentPayload; + } + }); + test("bounds diagnostics and redacts credential fields", () => { const diagnostic = sanitizePaymentErrorDiagnostic(JSON.stringify({ token: "secret", detail: "x".repeat(1000) })); assert.ok(diagnostic.length <= MAX_PAYMENT_ERROR_DIAGNOSTIC_LENGTH); diff --git a/services/appraisal-api/src/client.ts b/services/appraisal-api/src/client.ts index 7654fc38..d7c2e3af 100644 --- a/services/appraisal-api/src/client.ts +++ b/services/appraisal-api/src/client.ts @@ -107,8 +107,58 @@ export class AppraisalQuoteRefusalError extends X402PaymentError { } } -export { MAX_APPRAISAL_BODY_BYTES }; +/** Typed error raised when a 402 challenge does not match the client's own request. */ +export class QuoteMismatchError extends Error { + readonly name = "QuoteMismatchError"; + readonly reason: QuoteMismatchReason; + readonly status?: number; + + constructor(reason: QuoteMismatchReason, status?: number) { + super(quoteMismatchMessage(reason)); + this.reason = reason; + this.status = status; + } +} + +export type QuoteMismatchReason = + | "empty-challenge" + | "expired" + | "asset-mismatch" + | "amount-mismatch" + | "destination-mismatch"; + +function quoteMismatchMessage(reason: QuoteMismatchReason): string { + switch (reason) { + case "empty-challenge": + return "x402 challenge contained no payable quote"; + case "expired": + return "x402 challenge expired before payment"; + case "asset-mismatch": + return "x402 challenge asset does not match the requested asset"; + case "amount-mismatch": + return "x402 challenge amount does not match the requested amount"; + case "destination-mismatch": + return "x402 challenge destination does not match the requested destination"; + } +} +/** The quote the client expects to pay for a given request. */ +export interface ExpectedQuote { + asset: string; + amount: bigint; + destination: string; + /** Unix seconds; the challenge must not be expired at payment time. */ + expiresAt: number; +} + +export interface PaidFetchOptions { + /** The quote this call is willing to pay. */ + expectedQuote?: ExpectedQuote; + /** Override the clock used for expiry checks (tests). */ + nowSeconds?: () => number; +} + +export { MAX_APPRAISAL_BODY_BYTES }; async function parseJsonResponse(res: Response): Promise { const text = await res.text(); if (!text.trim()) { @@ -122,6 +172,126 @@ async function parseJsonResponse(res: Response): Promise { } } +/** Normalize a quote amount (any number or string form) to bigint stroips. */ +function normalizeQuoteAmount(value: unknown): bigint | undefined { + if (typeof value === "bigint") return value; + if (typeof value === "number") { + if (!Number.isSafeInteger(value)) return undefined; + return BigInt(value); + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) return undefined; + try { + return BigInt(trimmed); + } catch { + return undefined; + } + } + return undefined; +} + +/** Normalize an expiry timestamp to Unix seconds. */ +function normalizeExpiry(value: unknown): number | undefined { + if (typeof value === "number") { + if (!Number.isFinite(value)) return undefined; + // Millisecond epochs are common in JSON payloads; convert to seconds. + return value > 1e12 ? Math.floor(value / 1000) : Math.floor(value); + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) return undefined; + const num = Number(trimmed); + if (!Number.isFinite(num)) return undefined; + return num > 1e12 ? Math.floor(num / 1000) : Math.floor(num); + } + return undefined; +} + +/** Find the first accepts entry that carries a payable quote. */ +function firstQuote(paymentRequired: PaymentRequired): Record | undefined { + const accepts = (paymentRequired as { accepts?: unknown }).accepts; + if (!Array.isArray(accepts)) return undefined; + for (const entry of accepts) { + if (entry && typeof entry === "object") { + return entry as Record; + } + } + return undefined; +} + +function quoteAsset(entry: Record): string | undefined { + const asset = entry.asset; + if (typeof asset === "string" && asset.trim() !== "") return asset; + const currency = entry.currency; + if (typeof currency === "string" && currency.trim() !== "") return currency; + return undefined; +} + +function quoteDestination(entry: Record): string | undefined { + for (const key of ["destination", "payTo", "pay_to", "recipient", "address"] as const) { + const value = entry[key]; + if (typeof value === "string" && value.trim() !== "") return value; + } + return undefined; +} + +function quoteAmount(entry: Record): bigint | undefined { + for (const key of ["amount", "maxAmountRequired", "max_amount_required", "price", "value"] as const) { + const normalized = normalizeQuoteAmount(entry[key]); + if (normalized !== undefined) return normalized; + } + return undefined; +} + +function quoteExpiry(entry: Record): number | undefined { + for (const key of ["expiresAt", "expires_at", "expiration", "expires"] as const) { + const normalized = normalizeExpiry(entry[key]); + if (normalized !== undefined) return normalized; + } + return undefined; +} + +/** Verify the 402 challenge against the quote the client requested. */ +export function assertChallengeMatchesQuote( + paymentRequired: PaymentRequired, + expected: ExpectedQuote, + nowSeconds: () => number = () => Math.floor(Date.now() / 1000), +): void { + const entry = firstQuote(paymentRequired); + if (!entry) { + throw new QuoteMismatchError("empty-challenge"); + } + + const now = nowSeconds(); + if (!Number.isFinite(now)) { + throw new QuoteMismatchError("expired"); + } + if (expected.expiresAt <= now) { + throw new QuoteMismatchError("expired"); + } + + const challengeExpiry = quoteExpiry(entry); + if (challengeExpiry === undefined || challengeExpiry <= now) { + throw new QuoteMismatchError("expired"); + } + + const challengeAsset = quoteAsset(entry); + if (challengeAsset === undefined || challengeAsset !== expected.asset) { + throw new QuoteMismatchError("asset-mismatch"); + } + + const challengeAmount = quoteAmount(entry); + if (challengeAmount === undefined || challengeAmount !== expected.amount) { + throw new QuoteMismatchError("amount-mismatch"); + } + + const challengeDestination = quoteDestination(entry); + if (challengeDestination === undefined || challengeDestination !== expected.destination) { + throw new QuoteMismatchError("destination-mismatch"); + } +} + function requestBodyText(init: RequestInit): string | undefined { const body = init.body; if (body == null) return undefined; @@ -297,7 +467,6 @@ export function assertPaymentQuoteAllowed( } } } - /** Build a paid-fetch function bound to a payer wallet. */ export function createPaidFetch(config: PaidClientConfig) { const network = config.network ?? "stellar:testnet"; @@ -312,6 +481,7 @@ export function createPaidFetch(config: PaidClientConfig) { return async function paidFetch( url: string, init: RequestInit = {}, + options: PaidFetchOptions = {}, ): Promise> { // Probe path tolerates a missing body (generic GETs, legacy callers); // oversized / credential-like bodies still fail here with no payment. @@ -341,11 +511,15 @@ export function createPaidFetch(config: PaidClientConfig) { bodyForParse, ); } catch { - throw new X402PaymentError( - `x402 payment required response was invalid (${first.status})`, - first.status, - ); + throw new QuoteMismatchError("empty-challenge", first.status); + } + + // Bind the payment to the quote this call requested. A changed asset, + // amount, destination, or expiry must not be paid. + if (!options.expectedQuote) { + throw new QuoteMismatchError("empty-challenge", first.status); } + assertChallengeMatchesQuote(paymentRequired, options.expectedQuote, options.nowSeconds); // Bind the quote to the mandate before any Stellar transfer. if (config.expectedQuote) {