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
8 changes: 4 additions & 4 deletions services/agent/src/bidder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -160,15 +160,15 @@ 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);

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",
Expand Down
6 changes: 3 additions & 3 deletions services/appraisal-api/src/appraisal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export interface AppraisalRequest {
}

export interface Appraisal {
model: typeof APPRAISAL_MODEL;
model: typeof APPRAISALMODEL;
itemRef: string;
inputsHash: string;
fairValue: number;
Expand Down Expand Up @@ -93,7 +93,7 @@ function canonical(value: unknown): string {
const entries = Object.entries(value as Record<string, unknown>)
.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);
Expand Down Expand Up @@ -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`,
Expand Down
149 changes: 149 additions & 0 deletions services/appraisal-api/src/client.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string, string>) {
const headersMap = new Headers(headers ?? {});
return new Response(body ?? "", {
Expand All @@ -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);
Expand Down
Loading