Skip to content
Open
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
16 changes: 16 additions & 0 deletions services/appraisal-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@ A `POST /appraise` body larger than 8192 bytes is rejected with **HTTP 413**
`{ "error": "payload_too_large", "limitBytes": 8192 }` before the bytes are
parsed as JSON, so the request never receives a 402 payment quote.

### Quote rate limiting & production adapter boundary

Unpaid `POST /appraise` quote creation requests are rate-limited before a `402`
quote is minted. Exceeding the limit returns **HTTP 429**
`{ "error": "rate_limit_exceeded", "retryAfterSeconds": <seconds> }` with
`Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and
`X-RateLimit-Reset` headers.

- **Development default (`InMemoryQuoteRateLimiter`)**: Fixed-window in-memory
limiter (default `30` requests per `60_000` ms window, capped at `1_024`
tracked keys with expired/oldest eviction so memory stays strictly bounded).
- **Production adapter boundary (`AppraisalQuoteRateLimiter`)**: Pass a
distributed store adapter via `buildAppraisalServer(config, { quoteRateLimiter })`
implementing `consume(key, nowMs)` (for example backed by Redis or an edge
token bucket) when running multiple server instances behind a load balancer.

## Schema fixtures

The canonical request/response shapes and the per-failure-mode payloads live as
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 APPRAISALMODEL;
model: typeof APPRAISAL_MODEL;
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
116 changes: 115 additions & 1 deletion services/appraisal-api/src/body-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,20 @@ import { once } from "node:events";
import { PassThrough } from "node:stream";
import { describe, test } from "node:test";

import { Keypair, Networks } from "@stellar/stellar-sdk";
import { STELLAR_TESTNET_CAIP2, USDC_TESTNET_ADDRESS } from "@x402/stellar";

import {
APPRAISAL_BODY_TOO_LARGE,
takeAppraisalRequestBody,
} from "./body-limit.js";
import {
APPRAISAL_RATE_LIMITED,
InMemoryQuoteRateLimiter,
type AppraisalQuoteRateLimiter,
} from "./rate-limit.js";
import { buildAppraisalServer } from "./server.js";
import { validRequest } from "./fixtures/index.js";

function listen(server: http.Server): Promise<number> {
server.listen(0, "127.0.0.1");
Expand All @@ -24,7 +34,7 @@ function listen(server: http.Server): Promise<number> {

function fakeRequest(headers: http.IncomingHttpHeaders, body: Buffer): IncomingMessage {
const stream = new PassThrough();
const req = Object.assign(stream, { headers }) as IncomingMessage;
const req = Object.assign(stream, { headers }) as unknown as IncomingMessage;
queueMicrotask(() => {
stream.end(body);
});
Expand Down Expand Up @@ -75,6 +85,110 @@ describe("appraisal request body cap", () => {
});
assert.equal(allowed.status, 200);
} finally {
server.closeAllConnections?.();
server.close();
}
});
});

describe("appraisal quote rate limiting", () => {
test("InMemoryQuoteRateLimiter enforces window quota and resets deterministically", () => {
let now = 1_000_000;
const limiter = new InMemoryQuoteRateLimiter({
maxRequests: 2,
windowMs: 10_000,
maxKeys: 4,
nowMs: () => now,
});

const first = limiter.consume("127.0.0.1");
assert.equal(first.allowed, true);
assert.equal(first.remaining, 1);

const second = limiter.consume("127.0.0.1");
assert.equal(second.allowed, true);
assert.equal(second.remaining, 0);

now += 3_000;
const third = limiter.consume("127.0.0.1");
assert.equal(third.allowed, false);
assert.equal(third.remaining, 0);
assert.equal(third.retryAfterSeconds, 7);

now += 7_000;
const afterReset = limiter.consume("127.0.0.1");
assert.equal(afterReset.allowed, true);
assert.equal(afterReset.remaining, 1);
});

test("InMemoryQuoteRateLimiter bounds memory to maxKeys under key churn", () => {
let now = 2_000_000;
const limiter = new InMemoryQuoteRateLimiter({
maxRequests: 5,
windowMs: 60_000,
maxKeys: 3,
nowMs: () => now,
});

for (let i = 0; i < 10; i++) {
limiter.consume(`client-${i}`);
assert.ok(limiter.size <= 3, `expected size <= 3, got ${limiter.size}`);
}
});

test("POST /appraise enforces quote rate limits and supports custom production adapter", async () => {
const facilitator = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7));
const payee = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 8));
let calls = 0;
const customAdapter: AppraisalQuoteRateLimiter = {
async consume() {
calls += 1;
return {
allowed: calls <= 1,
limit: 1,
remaining: 0,
retryAfterSeconds: calls <= 1 ? 0 : 15,
resetAtMs: 1_700_000_015_000,
};
},
};

const server = await buildAppraisalServer(
{
facilitatorSecret: facilitator.secret(),
payTo: payee.publicKey(),
asset: USDC_TESTNET_ADDRESS,
price: 0.1,
network: STELLAR_TESTNET_CAIP2,
networkPassphrase: Networks.TESTNET,
rpcUrl: "https://soroban-testnet.stellar.org",
port: 0,
},
{ quoteRateLimiter: customAdapter },
);
const port = await listen(server);
try {
const first = await fetch(`http://127.0.0.1:${port}/appraise`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(validRequest.value),
});
assert.equal(first.status, 402);
assert.equal(first.headers.get("x-ratelimit-limit"), "1");

const second = await fetch(`http://127.0.0.1:${port}/appraise`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(validRequest.value),
});
assert.equal(second.status, 429);
assert.equal(second.headers.get("retry-after"), "15");
assert.deepEqual(await second.json(), {
error: APPRAISAL_RATE_LIMITED,
retryAfterSeconds: 15,
});
} finally {
server.closeAllConnections?.();
server.close();
}
});
Expand Down
18 changes: 17 additions & 1 deletion services/appraisal-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,23 @@ export {
type AppraisalAttributes,
} from "./appraisal.js";

export { buildAppraisalServer } from "./server.js";
export {
buildAppraisalServer,
type AppraisalServerOptions,
} from "./server.js";
export {
APPRAISAL_RATE_LIMITED,
DEFAULT_QUOTE_RATE_LIMIT_MAX_KEYS,
DEFAULT_QUOTE_RATE_LIMIT_MAX_REQUESTS,
DEFAULT_QUOTE_RATE_LIMIT_WINDOW_MS,
InMemoryQuoteRateLimiter,
appraisalRateLimitedBody,
rateLimitHeaders,
resolveQuoteRateLimitKey,
type AppraisalQuoteRateLimitDecision,
type AppraisalQuoteRateLimiter,
type InMemoryQuoteRateLimiterOptions,
} from "./rate-limit.js";
export {
AppraisalConfigError,
configFromEnv,
Expand Down
Loading