From 3fed3de078706c272e18807f988c6bb621db012a Mon Sep 17 00:00:00 2001 From: mikeypetrillo <22065635+MikeyPetrillo@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:08:07 -0400 Subject: [PATCH 1/2] [test] Receipts: your own settled calls, in the shape a finance system posts my-usage answers "how much have I spent" for a person reading a report. An accounting system needs a different shape: one row per settled payment with what was bought, what settled, and the evidence. POST /api/receipts ($0.005) returns exactly that, plus format:"csv" for a subledger import. Every row carries three things that make it auditable by someone who does not trust us: the settlement transaction (checkable on the named chain), the sha256 of the bytes delivered, and the EAS attestation id where one was written. That is the difference between a receipt and a number we assert. IDENTITY-BOUND BY THE SIGNATURE, never a parameter. The wallet comes from the verified EIP-3009 authorization, so the route can only ever return the caller's own payables - a global feed of who paid whom is the customer list we refuse to publish anywhere else, and a finance system does not want one. Added to isIdentityBoundRoute, so it advertises EVM exact only and a Solana or Stellar buyer is never charged for a call the server cannot answer. test-capped-counts caught a defect in this commit's own code: `count` was rows.length of a LIMITed query. That is the exact shape that once published a LIMIT-20 length as "tools used", and for payables it would silently under-report with no way for the reader to tell. Now `returned` (this page) beside an UNCAPPED `total` (the window), with truncated derived from the two. CSV quotes every field, doubles embedded quotes, and prefixes a leading =,+,-,@ because spreadsheet software executes those and these rows carry third-party slugs. 32 assertions, 4 mutations killed (return everyone's rows, include internal rows, drop the settlement tx, stop being identity-bound). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQbt8oAJjLVbTKY2GqviH6 --- .github/workflows/deploy.yml | 1 + scripts/test-all.js | 2 +- scripts/test-non-metered-examples.js | 1 + scripts/test-receipts.js | 122 +++++++++++++++++++++++++++ src/payments.js | 6 +- src/pow.js | 1 + src/sales-ledger.js | 70 +++++++++++++++ src/tools/usage-kit.js | 75 +++++++++++++++- 8 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 scripts/test-receipts.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ae60842c5..8fea05b26 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/scripts/test-all.js b/scripts/test-all.js index 3e97bbc28..58c46d3de 100644 --- a/scripts/test-all.js +++ b/scripts/test-all.js @@ -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); diff --git a/scripts/test-non-metered-examples.js b/scripts/test-non-metered-examples.js index e55191d2f..ae83101ac 100644 --- a/scripts/test-non-metered-examples.js +++ b/scripts/test-non-metered-examples.js @@ -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", diff --git a/scripts/test-receipts.js b/scripts/test-receipts.js new file mode 100644 index 000000000..9deaa2356 --- /dev/null +++ b/scripts/test-receipts.js @@ -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`); diff --git a/src/payments.js b/src/payments.js index 789d4b671..c9df04bf5 100644 --- a/src/payments.js +++ b/src/payments.js @@ -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 diff --git a/src/pow.js b/src/pow.js index 7dfc1b63c..16f093f00 100644 --- a/src/pow.js +++ b/src/pow.js @@ -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", diff --git a/src/sales-ledger.js b/src/sales-ledger.js index 26e7ed6f6..c765812a6 100644 --- a/src/sales-ledger.js +++ b/src/sales-ledger.js @@ -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 >= ?`); @@ -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); diff --git a/src/tools/usage-kit.js b/src/tools/usage-kit.js index 8996d9555..46d6338eb 100644 --- a/src/tools/usage-kit.js +++ b/src/tools/usage-kit.js @@ -9,13 +9,86 @@ // SVM/Stellar payments carry no signed payer the server can verify, so they // get a self-explaining 400 instead of a report. import { payerFromRequest } from "../payer.js"; -import { payerUsage } from "../sales-ledger.js"; +import { payerUsage, payerReceipts } from "../sales-ledger.js"; function bad(message, statusCode = 400) { return Object.assign(new Error(message), { statusCode }); } + +/** Flat CSV for a subledger import. Every field is quoted and every embedded + * quote doubled, so a slug containing a comma cannot shift a column; a leading + * =,+,-,@ is prefixed with a quote because spreadsheet software executes those + * as formulas (the same rule the paid report viewer already follows). */ +export function receiptsCsv(rows) { + const cell = (v) => { + let s = v === null || v === undefined ? "" : String(v); + if (/^[=+\-@]/.test(s)) s = `'${s}`; + return `"${s.replace(/"/g, '""')}"`; + }; + const cols = ["settledAt", "item", "amountUsd", "quotedUsd", "rail", "network", "wire", "settlementTx", "responseSha256", "attestationUid"]; + return [cols.map(cell).join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n"); +} + export const USAGE_TOOLS = [ + { + route: "POST /api/receipts", + name: "Receipts (your settled calls, as accounting rows)", + slug: "receipts", + category: "payments", + price: "$0.005", + description: + "Your own settled calls in the shape a finance system posts: one row per payment with what was bought, the amount settled, the quoted ceiling where one applied, and the evidence - settlement transaction, sha256 of the bytes delivered, and the on-chain attestation id where one exists. Keyed to the wallet that pays for the call, so nobody can read another wallet's payables; no account, no export request, no support ticket. Requires an EIP-3009 payment (USDC on Base, Polygon, or Arbitrum). Use format \"csv\" for a subledger import.", + tags: ["receipts", "accounting", "reconciliation", "audit", "erp"], + aliases: ["invoice", "invoices", "journal", "ledger-export", "accounts-payable"], + discovery: { + bodyType: "json", + input: { from: "2026-09-01", limit: 100 }, + inputSchema: { + properties: { + from: { type: "string", description: "ISO date or timestamp, inclusive. Default 90 days ago." }, + to: { type: "string", description: "ISO date or timestamp, inclusive. Default now." }, + limit: { type: "number", description: "Max rows per page, 1-5000 (default 500). `total` is the uncapped count for the window and `truncated` says when the page is short of it." }, + format: { type: "string", description: '"json" (default) or "csv" - the flat form a subledger imports.' }, + }, + required: [], + }, + output: { + example: { + wallet: "0x902dcf34e53695bdea2ffb354b1a2e58bd598256", + from: "2026-09-01T00:00:00.000Z", + to: "2026-09-11T00:00:00.000Z", + returned: 2, + total: 2, + truncated: false, + currency: "USD", + rows: [ + { settledAt: "2026-09-09T10:03:05.816Z", item: "v1-chat-metered", amountUsd: 0.642466, quotedUsd: 0.74, rail: "usdc", network: "solana", wire: "x402", settlementTx: "5Nk…", responseSha256: "9f86d0…", attestationUid: null }, + { settledAt: "2026-09-08T13:15:07.000Z", item: "hash", amountUsd: 0.001, quotedUsd: null, rail: "usdc", network: "base", wire: "x402", settlementTx: "0x6563…", responseSha256: "2c26b4…", attestationUid: "0x76e736…" }, + ], + note: "Every row's settlementTx is verifiable on the named chain without asking us.", + }, + }, + }, + handler: async (input, req) => { + const wallet = payerFromRequest(req); + if (!wallet) { + throw bad( + "Receipts are keyed to the wallet that PAYS for the call. Pay via x402 with an EIP-3009 authorization (USDC on Base, Polygon, or Arbitrum) and the response covers that wallet's own payables. Solana/Stellar payments carry no signed payer the server can verify, so they cannot unlock receipts." + ); + } + const limit = input?.limit === undefined ? 500 : parseInt(input.limit, 10); + if (Number.isNaN(limit) || limit < 1 || limit > 5000) throw bad('"limit" must be an integer between 1 and 5000 (default 500)'); + const out = payerReceipts(wallet, { from: input?.from ?? null, to: input?.to ?? null, limit }); + if (out.error) throw bad(`"from"/"to" must be an ISO date or timestamp (${out.error})`); + out.note = "Every row's settlementTx is verifiable on the named chain without asking us."; + + const format = String(input?.format || "json").toLowerCase(); + if (format === "csv") return { ...out, csv: receiptsCsv(out.rows) }; + if (format !== "json") throw bad('"format" must be "json" or "csv"'); + return out; + }, + }, { route: "POST /api/my-usage", name: "My usage (wallet-keyed purchase history)", From ec99f2a302421199d6eed795d29b38e05731242c Mon Sep 17 00:00:00 2001 From: mikeypetrillo <22065635+MikeyPetrillo@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:14:36 -0400 Subject: [PATCH 2/2] Append receipts instead of prepending it; select the tool by slug The receipts entry went in as USAGE_TOOLS[0], and scripts/test-usage.js reads USAGE_TOOLS[0] positionally - so that suite was asserting my-usage's contract against the receipts handler and failed in CI on the very PR that added it. Two fixes, because either alone leaves the trap set: - receipts is APPENDED, with a note on the array saying consumers index it positionally and a new tool goes on the end; - the test selects by slug, so a future append cannot re-point it either. Caught by CI on the push run, not merged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LQbt8oAJjLVbTKY2GqviH6 --- scripts/test-usage.js | 4 +- src/tools/usage-kit.js | 96 ++++++++++++++++++++++-------------------- 2 files changed, 53 insertions(+), 47 deletions(-) diff --git a/scripts/test-usage.js b/scripts/test-usage.js index 907b230ec..0642428e1 100644 --- a/scripts/test-usage.js +++ b/scripts/test-usage.js @@ -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); diff --git a/src/tools/usage-kit.js b/src/tools/usage-kit.js index 46d6338eb..d63c5247f 100644 --- a/src/tools/usage-kit.js +++ b/src/tools/usage-kit.js @@ -30,7 +30,57 @@ export function receiptsCsv(rows) { return [cols.map(cell).join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n"); } +// NOTE ordering: my-usage stays FIRST. Consumers index this array +// positionally (scripts/test-usage.js reads USAGE_TOOLS[0]), so a new tool is +// APPENDED - prepending one silently re-points every positional reference at +// the wrong tool, which is exactly how this was caught. export const USAGE_TOOLS = [ + { + route: "POST /api/my-usage", + name: "My usage (wallet-keyed purchase history)", + slug: "my-usage", + category: "payments", + price: "$0.005", + description: + "Your own purchase history, keyed to the wallet that pays for the call - no wallet parameter, no signup: the x402 payment IS the identity, so nobody can read another wallet's profile. Returns totals, per-tool counts, per-chain breakdown, and recent receipts with settle tx hashes (independently verifiable on-chain). Requires an EIP-3009 payment (USDC on Base, Polygon, or Arbitrum); Solana/Stellar payments carry no signed payer the server can verify.", + tags: ["usage", "receipts", "billing", "audit", "wallet", "x402", "history"], + discovery: { + bodyType: "json", + input: { days: 30 }, + inputSchema: { + properties: { + days: { type: "number", description: "Aggregation window in days, 1-365 (default 30). The recent list is always the latest rows regardless." }, + limit: { type: "number", description: "Max recent receipts to return, 1-200 (default 50)" }, + }, + required: [], + }, + output: { + example: { + wallet: "0x902dcf34e53695bdea2ffb354b1a2e58bd598256", + days: 30, + persistent: true, + totals: { calls: 42, paidUsd: 1.234, firstAt: "2026-07-01T00:00:00.000Z", lastAt: "2026-07-09T00:00:00.000Z" }, + byNetwork: { base: { calls: 40, usd: 1.2 }, polygon: { calls: 2, usd: 0.034 } }, + bySlug: [{ slug: "hash", calls: 12, usd: 0.012, lastAt: "2026-07-09T00:00:00.000Z" }], + recent: [{ at: "2026-07-09T00:00:00.000Z", slug: "hash", priceUsd: 0.001, network: "base", tx: "0x…" }], + note: "Every USDC row keeps its settle tx - verifiable on-chain.", + }, + }, + }, + handler: async (input, req) => { + const wallet = payerFromRequest(req); + if (!wallet) { + throw bad( + "This report is keyed to the wallet that PAYS for it. Pay via x402 with an EIP-3009 authorization (USDC on Base, Polygon, or Arbitrum) and the response covers that wallet's history. Solana/Stellar payments carry no signed payer the server can verify, so they cannot unlock a report." + ); + } + const days = input?.days === undefined ? 30 : parseInt(input.days, 10); + if (Number.isNaN(days) || days < 1 || days > 365) throw bad('"days" must be an integer between 1 and 365 (default 30)'); + const limit = input?.limit === undefined ? 50 : parseInt(input.limit, 10); + if (Number.isNaN(limit) || limit < 1 || limit > 200) throw bad('"limit" must be an integer between 1 and 200 (default 50)'); + return payerUsage(wallet, { days, limit }); + }, + }, { route: "POST /api/receipts", name: "Receipts (your settled calls, as accounting rows)", @@ -89,50 +139,4 @@ export const USAGE_TOOLS = [ return out; }, }, - { - route: "POST /api/my-usage", - name: "My usage (wallet-keyed purchase history)", - slug: "my-usage", - category: "payments", - price: "$0.005", - description: - "Your own purchase history, keyed to the wallet that pays for the call - no wallet parameter, no signup: the x402 payment IS the identity, so nobody can read another wallet's profile. Returns totals, per-tool counts, per-chain breakdown, and recent receipts with settle tx hashes (independently verifiable on-chain). Requires an EIP-3009 payment (USDC on Base, Polygon, or Arbitrum); Solana/Stellar payments carry no signed payer the server can verify.", - tags: ["usage", "receipts", "billing", "audit", "wallet", "x402", "history"], - discovery: { - bodyType: "json", - input: { days: 30 }, - inputSchema: { - properties: { - days: { type: "number", description: "Aggregation window in days, 1-365 (default 30). The recent list is always the latest rows regardless." }, - limit: { type: "number", description: "Max recent receipts to return, 1-200 (default 50)" }, - }, - required: [], - }, - output: { - example: { - wallet: "0x902dcf34e53695bdea2ffb354b1a2e58bd598256", - days: 30, - persistent: true, - totals: { calls: 42, paidUsd: 1.234, firstAt: "2026-07-01T00:00:00.000Z", lastAt: "2026-07-09T00:00:00.000Z" }, - byNetwork: { base: { calls: 40, usd: 1.2 }, polygon: { calls: 2, usd: 0.034 } }, - bySlug: [{ slug: "hash", calls: 12, usd: 0.012, lastAt: "2026-07-09T00:00:00.000Z" }], - recent: [{ at: "2026-07-09T00:00:00.000Z", slug: "hash", priceUsd: 0.001, network: "base", tx: "0x…" }], - note: "Every USDC row keeps its settle tx - verifiable on-chain.", - }, - }, - }, - handler: async (input, req) => { - const wallet = payerFromRequest(req); - if (!wallet) { - throw bad( - "This report is keyed to the wallet that PAYS for it. Pay via x402 with an EIP-3009 authorization (USDC on Base, Polygon, or Arbitrum) and the response covers that wallet's history. Solana/Stellar payments carry no signed payer the server can verify, so they cannot unlock a report." - ); - } - const days = input?.days === undefined ? 30 : parseInt(input.days, 10); - if (Number.isNaN(days) || days < 1 || days > 365) throw bad('"days" must be an integer between 1 and 365 (default 30)'); - const limit = input?.limit === undefined ? 50 : parseInt(input.limit, 10); - if (Number.isNaN(limit) || limit < 1 || limit > 200) throw bad('"limit" must be an integer between 1 and 200 (default 50)'); - return payerUsage(wallet, { days, limit }); - }, - }, ];