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
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1378,6 +1378,7 @@ jobs:
t node scripts/test-dataset-snapshot.js
t node scripts/test-dataset-health.js
t node scripts/test-price-parse.js
t node scripts/test-receipts.js

- name: x402 Bazaar discovery shape (POST→bodyType, GET→queryParams, example types match schema)
run: node scripts/check-bazaar.mjs
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-all.js
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ const isMemory = (p) => p.startsWith("/api/memory");
// Wallet-keyed tools (payment = identity): in free mode there is no payment,
// so their documented "pay to unlock" 4xx is the CORRECT answer, not a bug —
// same leniency class as the memory tools.
const isWalletIdentity = (p) => isMemory(p) || p === "/api/my-usage";
const isWalletIdentity = (p) => isMemory(p) || p === "/api/my-usage" || p === "/api/receipts";

const spec = await (await fetch(`${TARGET}/openapi.json`)).json();
const paths = Object.entries(spec.paths);
Expand Down
1 change: 1 addition & 0 deletions scripts/test-non-metered-examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export const METERED_SLUGS = new Set([
"memory-write", "memory-read", "memory-incr", "memory-cas", "memory-grant", "memory-revoke",
"memory-grants", "memory-log", "memory-remember", "memory-recall", "memory-forget",
"my-usage",
"receipts",
// FRED keyed (503 without FRED_API_KEY / FRED_API_KEY_V2)
"fred-series", "fred-search", "fred-series-info", "fred-release-calendar",
"sahm-rule", "cpi-yoy", "unemployment-rate", "fed-funds",
Expand Down
122 changes: 122 additions & 0 deletions scripts/test-receipts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env node
// Receipts: the caller's OWN settled payments, in the shape a finance system
// posts, and nobody else's.
//
// Two properties are the whole product and both are pinned here:
//
// 1. IDENTITY-BOUND BY THE SIGNATURE, NEVER A PARAMETER. The wallet comes
// from the verified EIP-3009 authorization. A `wallet` field in the body
// must change nothing - if it ever did, this route would be a way to read
// any buyer's payables, which is the customer list we refuse to publish
// anywhere else.
//
// 2. EVIDENCE SURVIVES. settlementTx, responseSha256 and attestationUid are
// what make a row auditable by someone who does not trust us. A row that
// drops them is just a number we assert.
//
// Offline: the ledger is driven directly with a temp database.
import { strict as assert } from "node:assert";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "a402-receipts-"));
process.env.SALES_LEDGER_DB = join(dir, "sales.db");

const { recordSale, payerReceipts } = await import("../src/sales-ledger.js");
const { USAGE_TOOLS } = await import("../src/tools/usage-kit.js");
const { isIdentityBoundRoute } = await import("../src/payments.js");

let n = 0;
const ok = (c, m) => { assert.ok(c, m); n++; };
const eq = (a, b, m) => { assert.deepEqual(a, b, m); n++; };

const MINE = "0xaaaa000000000000000000000000000000000001";
const THEIRS = "0xbbbb000000000000000000000000000000000002";
const def = USAGE_TOOLS.find((t) => t.slug === "receipts");
ok(def, "the receipts tool exists");

// --- the route must be identity-bound, or a non-EVM buyer pays then is refused
ok(isIdentityBoundRoute(def), "receipts is identity-bound: it advertises EVM exact only, so a Solana or Stellar buyer is never charged for a call the server cannot answer");

// --- seed two payers plus an internal row -----------------------------------
recordSale({ slug: "hash", priceUsd: 0.001, rail: "usdc", network: "base", payer: MINE, tx: "0xtx1", wire: "x402", responseSha256: "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" });
recordSale({ slug: "v1-chat-metered", priceUsd: 0.642466, quoteUsd: 0.74, rail: "usdc", network: "base", payer: MINE, tx: "0xtx2", wire: "x402" });
recordSale({ slug: "seller-dossier", priceUsd: 0.05, rail: "usdc", network: "base", payer: THEIRS, tx: "0xtx3", wire: "x402" });
recordSale({ slug: "uuid", priceUsd: 0.001, rail: "usdc", network: "base", payer: MINE, tx: "0xtx4", synthetic: true });

// --- 1. one payer sees only their own ---------------------------------------
{
const r = payerReceipts(MINE, {});
const items = r.rows.map((x) => x.item).sort();
eq(items, ["hash", "v1-chat-metered"], "only this payer's EXTERNAL rows are returned");
ok(!JSON.stringify(r).includes(THEIRS), "another payer's address appears nowhere");
ok(!r.rows.some((x) => x.item === "uuid"), "an internal/synthetic row is not a purchase and is excluded");
eq(r.wallet, MINE, "the wallet is echoed so a row set is self-describing");
eq(r.currency, "USD", "the currency is stated rather than assumed");

const theirs = payerReceipts(THEIRS, {});
eq(theirs.rows.map((x) => x.item), ["seller-dossier"], "the other payer sees only theirs");
}

// --- 2. evidence survives ----------------------------------------------------
{
const r = payerReceipts(MINE, {});
const hash = r.rows.find((x) => x.item === "hash");
eq(hash.settlementTx, "0xtx1", "the settlement transaction rides, so a row is checkable on-chain");
eq(hash.responseSha256, "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", "the hash of the delivered bytes rides");
eq(payerReceipts(MINE, {}).rows.find((x) => x.item === "v1-chat-metered").responseSha256, null,
"a row with no recorded digest says null - recordSale rejects anything that is not a full 64-hex sha256, so a partial value can never masquerade as evidence");
ok("attestationUid" in hash, "the attestation field is always present, null when none was written");

const metered = r.rows.find((x) => x.item === "v1-chat-metered");
eq(metered.amountUsd, 0.642466, "the amount is what SETTLED");
eq(metered.quotedUsd, 0.74, "the quoted ceiling rides beside it - the gap is what a buyer reconciles");
eq(hash.quotedUsd, null, "a flat-priced row has no quote, and says null rather than repeating the price");
}

// --- 3. a body parameter can never redirect the read -------------------------
{
// The handler derives the wallet from the request only. Passing someone
// else's address as input must be inert - this is the assertion that keeps
// the route from becoming a customer-list reader.
const req = { headers: {}, __testPayer: MINE };
const out = await def.handler({ wallet: THEIRS, payer: THEIRS, from: null }, {
headers: { "payment-signature": "" },
...req,
}).catch((e) => e);
ok(out instanceof Error, "with no verifiable payer the handler REFUSES rather than falling back to a parameter");
ok(/wallet that PAYS/i.test(out.message), "and the refusal explains that payment is the identity");
}

// --- 4. windows and truncation are stated, not implied -----------------------
{
const r = payerReceipts(MINE, { limit: 1 });
eq(r.rows.length, 1, "limit is honoured");
eq(r.truncated, true, "a cut page SAYS it was cut - a consumer must never mistake a page for the period");
eq(r.returned, 1, "`returned` is this page");
eq(r.total, 2, "`total` is the WINDOW, uncapped - a LIMITed length published as a count is how a capped query once became a business figure, and here it would silently under-report payables");
eq(payerReceipts(MINE, {}).truncated, false, "a complete page says so");
ok(payerReceipts(MINE, { from: "not-a-date" }).error, "an unparseable window is an error, never a silent default");
const empty = payerReceipts(MINE, { from: "2020-01-01", to: "2020-01-02" });
eq(empty.total, 0, "a window with no activity is an honest zero");
eq(empty.returned, 0, "and nothing returned");
eq(empty.rows, [], "and an empty list, not a missing field");
}

// --- 5. CSV is importable and cannot execute --------------------------------
{
const rows = payerReceipts(MINE, {}).rows;
const { receiptsCsv } = await import("../src/tools/usage-kit.js").then((m) => ({ receiptsCsv: m.receiptsCsv }));
ok(typeof receiptsCsv === "function", "the CSV formatter is exported so it can be tested without a paid request");
const csv = receiptsCsv([...rows, { settledAt: "x", item: "=cmd|'/c calc'!A1", amountUsd: 1, quotedUsd: null, rail: 'a"b', network: "n,m", wire: null, settlementTx: null, responseSha256: null, attestationUid: null }]);
const lines = csv.split("\n");
eq(lines[0], '"settledAt","item","amountUsd","quotedUsd","rail","network","wire","settlementTx","responseSha256","attestationUid"', "a header row names every column");
eq(lines.length, rows.length + 2, "one line per row plus the header");
ok(csv.includes(`"'=cmd`), "a leading = is quote-prefixed: spreadsheet software EXECUTES those, and these rows carry third-party slugs");
ok(csv.includes('"a""b"'), "an embedded quote is doubled");
ok(csv.includes('"n,m"'), "a comma inside a field cannot shift a column");
}

rmSync(dir, { recursive: true, force: true });
console.log(`test-receipts: ${n} assertions OK`);
4 changes: 3 additions & 1 deletion scripts/test-usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ const uEmpty = payerUsage("0x3333333333333333333333333333333333333333", { days:
ok(uEmpty.totals.calls === 0 && uEmpty.bySlug.length === 0 && uEmpty.recent.length === 0, "unknown wallet gets an empty (not erroring) report");

// The tool: identity comes ONLY from the verified X-PAYMENT authorization.
const tool = USAGE_TOOLS[0];
// By SLUG, never by position: a tool appended to the kit must not be able to
// re-point this at a different handler.
const tool = USAGE_TOOLS.find((t) => t.slug === "my-usage");
const header = Buffer.from(JSON.stringify({ payload: { authorization: { from: BUYER } } })).toString("base64");
const reqWithPayment = { header: (n) => (n.toLowerCase() === "x-payment" ? header : undefined) };
const viaTool = await tool.handler({ days: 30 }, reqWithPayment);
Expand Down
6 changes: 5 additions & 1 deletion src/payments.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,12 @@ export function enabledNetworks(network) {
// `attest` joined 2026-09-03: an attestation is written for the BUYER of a sale,
// so the handler must know who is paying, and only a signed EVM authorization
// tells it before settlement.
// `receipts` joined 2026-09-11: it returns the CALLER'S OWN settled payments,
// derived from the signed authorization and never from a parameter, so it can
// only be answered for a payer the server can verify. Without this a Solana or
// Stellar buyer would settle and then be refused - charged for nothing.
export const isIdentityBoundRoute = (def) =>
def?.category === "memory" || def?.slug === "my-usage" || def?.slug === "attest";
def?.category === "memory" || def?.slug === "my-usage" || def?.slug === "attest" || def?.slug === "receipts";

// Build the `accepts` list for one catalog item. EVM rails always apply. For an
// identity-bound route that is ALL it advertises, so a buyer can never settle on
Expand Down
1 change: 1 addition & 0 deletions src/pow.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ export const WALLET_ONLY_SLUGS = new Set([
// Usage report: payment IS the identity (payerFromRequest) — a PoW call has
// no wallet, so there is nothing it could ever report on.
"my-usage",
"receipts",
// Image generation kit: every call burns real upstream inference credit
// (OpenAI GPT Image API). Same rationale as LLM proxy.
"image-gen", "image-gen-hd", "image-gen-premium",
Expand Down
70 changes: 70 additions & 0 deletions src/sales-ledger.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,15 @@ const qExtSlugWindow = db.prepare(`

// Payer-scoped view (the /api/my-usage tool). Money rails only — PoW rows
// carry no payer, so they can never appear in a wallet-keyed report anyway.
const qPayerReceiptsTotal = db.prepare(
"SELECT COUNT(*) AS n FROM sales WHERE payer = ? AND internal = 0 AND ts >= ? AND ts <= ?"
);
const qPayerReceipts = db.prepare(`
SELECT ts, slug, price_usd, quote_usd, rail, network, wire, tx, response_sha256, attest_uid
FROM sales
WHERE payer = ? AND internal = 0 AND ts >= ? AND ts <= ?
ORDER BY ts DESC
LIMIT ?`);
const qPayerTotals = db.prepare(`
SELECT COUNT(*) AS n, SUM(price_usd) AS usd, MIN(ts) AS first_ts, MAX(ts) AS last_ts
FROM sales WHERE payer = ? AND rail IN ${PAYING_RAILS_SQL} AND ts >= ?`);
Expand Down Expand Up @@ -358,6 +367,67 @@ export function externalSalesForSlugs(slugs, sinceMs, untilMs) {
} catch { return []; }
}

/**
* One payer's settled calls as ACCOUNTING ROWS, newest first.
*
* payerUsage answers "how much have I spent" for a person reading a report.
* This answers "what do I post to the general ledger", which is a different
* shape: one row per settled payment, each carrying what was bought, the amount
* actually settled, the counterparty, and the independent evidence - the
* settlement transaction, the sha256 of the bytes delivered, and the EAS
* attestation UID where one was written. Those three are what makes a row
* auditable by someone who does not trust us, which is the whole point of
* handing it to a finance system.
*
* Identity-bound by the caller, never by a parameter: the route derives the
* payer from the signed authorization, so this can only ever return the
* caller's OWN rows. A global feed of who paid whom is the customer list we
* refuse to publish anywhere else, and an ERP does not want one anyway - it
* wants its own payables.
*
* Internal rows (our canaries, volume runs) are excluded: they are not
* anybody's purchases.
*/
export function payerReceipts(payer, { from = null, to = null, limit = 500 } = {}) {
const lo = from ? Date.parse(from) : Date.now() - 90 * 86_400_000;
const hi = to ? Date.parse(to) : Date.now();
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return { error: "unparseable from/to" };
const cap = Math.min(Math.max(limit, 1), 5000);
const rows = qPayerReceipts.all(payer, lo, hi, cap);
// UNCAPPED, deliberately. `returned` is this page; `total` is the window. A
// count-named field holding the length of a LIMITed result is how a capped
// query once got published as a business figure, and for an accounting
// consumer it is worse than useless: it would under-report payables and the
// reader would have no way to know.
const total = qPayerReceiptsTotal.get(payer, lo, hi)?.n || 0;
return {
wallet: payer,
from: new Date(lo).toISOString(),
to: new Date(hi).toISOString(),
returned: rows.length,
total,
// Stated so a consumer never mistakes a page for the period.
truncated: rows.length < total,
currency: "USD",
rows: rows.map((r) => ({
settledAt: new Date(r.ts).toISOString(),
item: r.slug,
// What was actually charged. On a metered call quotedUsd is the ceiling
// that was authorized and amountUsd is what settled under it - both are
// kept because the difference is the thing a buyer reconciles.
amountUsd: +Number(r.price_usd || 0).toFixed(6),
quotedUsd: r.quote_usd == null ? null : +Number(r.quote_usd).toFixed(6),
rail: r.rail,
network: r.network || null,
wire: r.wire || null,
// Evidence, all independently checkable without asking us.
settlementTx: r.tx || null,
responseSha256: r.response_sha256 || null,
attestationUid: r.attest_uid || null,
})),
};
}

export function payerUsage(payer, { days = 30, limit = 50 } = {}) {
const since = Date.now() - days * 86_400_000;
const t = qPayerTotals.get(payer, since);
Expand Down
Loading