From e327455a7a3e1a3f81c337df4c529e27f81b330d Mon Sep 17 00:00:00 2001 From: mikeypetrillo <22065635+MikeyPetrillo@users.noreply.github.com> Date: Sun, 16 Aug 2026 07:45:39 -0400 Subject: [PATCH 01/29] [test][deploy] Fix WALLET_BLOCKLIST never matching Solana/Stellar/Algorand Audited today: WALLET_BLOCKLIST (the enforcement behind /terms' "we may refuse service to any wallet") only ever matched EVM payers. Traced the actual installed SDKs (@x402/svm, @x402/stellar, @x402/avm) to confirm why - none of those exact schemes carry a payer field on the raw client payload (SVM/Stellar ship {transaction}, AVM ships {paymentGroup,paymentIndex}), and @x402/core's settlePayment() builds the beforeSettle hook's context straight from the caller's paymentPayload argument, never enriched with the verify() result. So a blocked wallet could trivially evade the ban by paying on any of those three rails instead of an EVM chain. Fixed the only way @x402/core's hook API allows: onAfterVerify DOES receive the verify() result (which does correctly carry payer for all three schemes, confirmed directly in the installed SDK source), but can't itself abort settlement - a throw there is caught and only logged. So it stashes the verified payer onto the SAME paymentPayload object instance beforeSettle will see moments later in the same request (verifyPayment and settlePayment both build their context from the exact object reference the caller passed in, never a clone, confirmed by reading @x402/core's server/index.mjs directly - no external cache/keying needed). Guarded to never touch an EVM payload, since authorization.from is already signature-covered and shouldn't be traded for an unauthenticated facilitator-reported value. The existing unit test's non-EVM cases used a fabricated { payload: { payer } } shape that no real SDK ever produces - exactly what let this ship unnoticed. Rewrote with real wire shapes plus direct coverage of the new enrichment hook (stashes correctly, never touches EVM, no-op when verify carries no payer, full end-to-end chain). Mutation-tested twice (removing the new candidate field, loosening the EVM guard) - both caught correctly by the rewritten suite, then restored. Verified: local boot clean (no errors), test-supported-guard.js (16/16, the closest adjacent suite touching the same server-setup flow) unaffected. --- scripts/test-wallet-blocklist.js | 68 ++++++++++++++++++++++++++++++-- src/payments.js | 41 +++++++++++++++++-- 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/scripts/test-wallet-blocklist.js b/scripts/test-wallet-blocklist.js index e27816ea..2459aca9 100644 --- a/scripts/test-wallet-blocklist.js +++ b/scripts/test-wallet-blocklist.js @@ -1,7 +1,7 @@ // Unit tests for the WALLET_BLOCKLIST payer matcher — the pure function behind // the beforeSettle abort that refuses service to blocked wallets WITHOUT // charging them. Env is read at call time, so each case just sets the var. -import { blockedPayerFromPayload } from "../src/payments.js"; +import { blockedPayerFromPayload, registerWalletBlocklistPayerEnrichment } from "../src/payments.js"; let pass = 0, fail = 0; const ok = (cond, msg) => { if (cond) { pass++; console.log(`ok - ${msg}`); } else { fail++; console.error(`FAIL - ${msg}`); } }; @@ -23,10 +23,18 @@ ok(blockedPayerFromPayload({ payload: { authorization: { from: EVM.toLowerCase() ok(blockedPayerFromPayload(evmPayload) === EVM.toLowerCase(), "EVM blocked: checksum payload vs checksum env"); ok(blockedPayerFromPayload({ payload: { authorization: { from: "0x" + "1".repeat(40) } } }) === null, "different EVM wallet → null"); -// Non-EVM payers match from the payload's payer field, case preserved. +// Non-EVM (SVM/Stellar/AVM): the raw client payload NEVER carries a payer +// field - real wire shapes are { payload: { transaction: "" } } for +// SVM/Stellar and { payload: { paymentGroup: [...], paymentIndex } } for AVM. +// The payer only exists on the verify() RESULT, enriched onto the payload via +// registerWalletBlocklistPayerEnrichment's onAfterVerify hook (__verifiedPayer) +// - the earlier version of this test used a fabricated { payload: { payer } } +// shape that no real SDK ever produces, which is exactly what let the +// non-EVM-payer gap ship unnoticed (see payments.js's doc comment). process.env.WALLET_BLOCKLIST = `${SOL},${ALGO}`; -ok(blockedPayerFromPayload({ payload: { payer: SOL } }) === SOL, "Solana base58 payer blocked (payload.payload.payer)"); -ok(blockedPayerFromPayload({ payer: ALGO }) === ALGO, "Algorand base32 payer blocked (payload.payer)"); +ok(blockedPayerFromPayload({ payload: { transaction: "AAAA..." }, __verifiedPayer: SOL }) === SOL, "Solana payer blocked via __verifiedPayer (post-enrichment)"); +ok(blockedPayerFromPayload({ payload: { paymentGroup: ["AAAA"], paymentIndex: 0 }, __verifiedPayer: ALGO }) === ALGO, "Algorand payer blocked via __verifiedPayer (post-enrichment)"); +ok(blockedPayerFromPayload({ payload: { transaction: "AAAA..." } }) === null, "Solana payload with NO enrichment (pre-fix behavior) → null, not a false negative on a wallet that isn't actually blocked"); ok(blockedPayerFromPayload(evmPayload) === null, "EVM wallet not in the non-EVM list → null"); // Defensive: shapes that carry no payer never match, never throw. @@ -36,5 +44,57 @@ ok(blockedPayerFromPayload(undefined) === null, "missing payload → null"); ok(blockedPayerFromPayload({ payload: { authorization: { from: 42 } } }) === null, "non-string from → null"); delete process.env.WALLET_BLOCKLIST; + +// ---- registerWalletBlocklistPayerEnrichment: the onAfterVerify hook itself ---- +// Fake server stub that just captures the registered hook, matching the real +// @x402/core server's onAfterVerify(hook) signature. +function fakeServer() { + let hook; + return { onAfterVerify: (h) => { hook = h; }, run: (ctx) => hook(ctx) }; +} + +{ + const server = fakeServer(); + registerWalletBlocklistPayerEnrichment(server); + const payload = { payload: { transaction: "AAAA..." } }; + server.run({ result: { isValid: true, payer: SOL }, paymentPayload: payload }); + ok(payload.__verifiedPayer === SOL, "enrichment hook stashes verify()'s payer onto the SAME payload object"); +} + +{ + // EVM payloads already carry a real, signature-covered payer + // (authorization.from) - the enrichment must not touch them at all, since + // overwriting with a facilitator-reported value would trade a + // signature-covered field for an unauthenticated one. + const server = fakeServer(); + registerWalletBlocklistPayerEnrichment(server); + const payload = { payload: { authorization: { from: EVM } } }; + server.run({ result: { isValid: true, payer: "some-other-address" }, paymentPayload: payload }); + ok(payload.__verifiedPayer === undefined, "enrichment hook never touches an EVM payload (authorization.from already present)"); +} + +{ + // No payer on the verify result (e.g. isValid:false, or a scheme this + // hook doesn't know about) - must not throw, must not attach anything. + const server = fakeServer(); + registerWalletBlocklistPayerEnrichment(server); + const payload = { payload: { transaction: "AAAA..." } }; + server.run({ result: { isValid: false, invalidReason: "expired" }, paymentPayload: payload }); + ok(payload.__verifiedPayer === undefined, "enrichment hook is a no-op when the verify result carries no payer"); +} + +{ + // End-to-end shape: enrichment runs at afterVerify, blockedPayerFromPayload + // runs at beforeSettle - same payload object, same request, matching the + // real @x402/core request lifecycle where both hooks share one paymentPayload reference. + const server = fakeServer(); + registerWalletBlocklistPayerEnrichment(server); + process.env.WALLET_BLOCKLIST = SOL; + const payload = { payload: { transaction: "AAAA..." } }; + server.run({ result: { isValid: true, payer: SOL }, paymentPayload: payload }); + ok(blockedPayerFromPayload(payload) === SOL, "end-to-end: afterVerify enrichment + beforeSettle matcher together block a non-EVM wallet"); + delete process.env.WALLET_BLOCKLIST; +} + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); diff --git a/src/payments.js b/src/payments.js index b792e3a1..6ecba8a1 100644 --- a/src/payments.js +++ b/src/payments.js @@ -1156,9 +1156,28 @@ export async function buildPaymentMiddleware({ walletAddress, network, baseUrl, * BEFORE the facilitator settles, so a blocked wallet is never charged — the * buyer gets the standard settle-failure 402 whose receipt carries * errorReason "wallet_blocked" (which the tally middleware records as a - * settle_failed event, so blocks are visible in PostHog). EVM payers are - * matched from the signature-covered EIP-3009 authorization.from; other - * schemes match when their payload carries a recognizable payer field. + * settle_failed event, so blocks are visible in PostHog). + * + * NON-EVM PAYER ENRICHMENT (2026-08-16): beforeSettle only ever receives the + * raw, UNVERIFIED client payload (@x402/core's settlePayment() builds its own + * { paymentPayload, ... } context straight from the caller's argument — it + * never carries the verify() result). For SVM/Stellar/AVM's exact schemes the + * payer is never on that raw payload at all — it's derived by decoding the + * signed transaction, and only appears as a `payer` field on the verify + * RESULT (confirmed directly against the installed SDKs: @x402/svm, /stellar + * and /avm's facilitator verify() all return `{ isValid: true, payer }`). + * Before this fix, blockedPayerFromPayload only ever matched EVM's + * signature-covered authorization.from — a blocked wallet trivially evaded + * the ban by paying on Solana, Stellar, or Algorand instead. + * registerWalletBlocklistPayerEnrichment below closes that gap the only way + * @x402/core's hook API allows: onAfterVerify DOES receive the verify result, + * but can't itself abort settlement (a thrown/rejecting hook there is caught + * and only logged - see runAfterVerifyHooks). So it stashes the verified + * payer directly onto the SAME paymentPayload object instance that + * beforeSettle will receive moments later in the same request (verifyPayment + * and settlePayment both build their context from the exact object reference + * passed in by the caller - never a clone - so this is safe, request-scoped, + * and needs no external cache/keying). */ export function blockedPayerFromPayload(paymentPayload) { const raw = (process.env.WALLET_BLOCKLIST || "").trim(); @@ -1171,6 +1190,7 @@ export function blockedPayerFromPayload(paymentPayload) { paymentPayload?.payload?.authorization?.from, // EVM exact scheme (signature-covered) paymentPayload?.payload?.payer, paymentPayload?.payer, + paymentPayload?.__verifiedPayer, // SVM/Stellar/AVM — see registerWalletBlocklistPayerEnrichment ]; for (const c of candidates) { const normalized = normalizePayerAddress(c); @@ -1179,7 +1199,22 @@ export function blockedPayerFromPayload(paymentPayload) { return null; } +export function registerWalletBlocklistPayerEnrichment(server) { + server.onAfterVerify((ctx) => { + const payer = ctx?.result?.payer; + const payload = ctx?.paymentPayload; + if (payer && payload && !payload.payload?.authorization?.from) { + try { + payload.__verifiedPayer = payer; + } catch { + /* non-extensible payload object — blocklist just won't cover this one payment */ + } + } + }); +} + function registerWalletBlocklistHook(server) { + registerWalletBlocklistPayerEnrichment(server); server.onBeforeSettle((ctx) => { const blocked = blockedPayerFromPayload(ctx?.paymentPayload); if (!blocked) return; From 2fc67c49ec292ea551fbe8bd5d800551cbb205f1 Mon Sep 17 00:00:00 2001 From: mikeypetrillo <22065635+MikeyPetrillo@users.noreply.github.com> Date: Sun, 16 Aug 2026 07:49:19 -0400 Subject: [PATCH 02/29] [test][deploy] Rename uptimeSeconds -> processUptimeSeconds Audit finding: /api/reliability and /api/stats exposed uptimeSeconds (resets to 0 on every deploy) directly beside servingSince (a real, ~2-month figure). /api/reliability is explicitly framed as "every claim an agent might want before depending on this seller" - a naive agent parsing field names alone would read uptimeSeconds as service-availability uptime and derive ~0.02% against a real 99.8-100%. Renamed the field everywhere it's produced or consumed: both getStats() occurrences in src/stats.js (public /api/stats and the operator-only breakdown), src/discovery.js's /api/reliability projection, and src/operator.js's dashboard display (labelled "since process boot" there, so no ambiguity in that UI - renamed anyway for consistency across the codebase). Updated the three envelope tests that asserted the old key name (test-discovery.js, test-reliability-envelope.js, test-stats-envelope.js) and added an explicit negative assertion in the latter two locking the old name's absence, so a regression can't silently reintroduce it. Mutation-tested both negative assertions against the actual code path each test exercises (stats.js's public getStats() for the /api/stats lock; discovery.js's field-picking in reliabilityReport() for the /api/reliability lock, since that function explicitly picks fields rather than spreading its input - a stray key on the input object alone doesn't prove anything about its own output shape) - both caught correctly, then restored. Verified live against a fresh local boot: both /api/reliability and /api/stats now carry processUptimeSeconds and no longer carry uptimeSeconds at all. --- scripts/test-discovery.js | 4 ++-- scripts/test-reliability-envelope.js | 12 ++++++++---- scripts/test-stats-envelope.js | 11 ++++++----- src/discovery.js | 2 +- src/operator.js | 4 ++-- src/stats.js | 10 ++++++++-- 6 files changed, 27 insertions(+), 16 deletions(-) diff --git a/scripts/test-discovery.js b/scripts/test-discovery.js index 96fae72a..aa47ceb8 100644 --- a/scripts/test-discovery.js +++ b/scripts/test-discovery.js @@ -67,12 +67,12 @@ JSON.parse(JSON.stringify(m)); // ---- reliabilityReport ---- const stats = { servingSince: "2026-01-01T00:00:00.000Z", - uptimeSeconds: 12345, + processUptimeSeconds: 12345, toolCallsServed: { total: 100, viaUSDC: 60, viaProofOfWork: 40 }, }; const r = reliabilityReport({ baseUrl: BASE, network: "base", wallet: WALLET, stats }); ok(r.service === "Agent402.Tools" && r.status === "operational", "reliability identity/status"); -ok(r.uptimeSeconds === 12345 && r.toolCallsServed.total === 100, "reliability pulls live stats"); +ok(r.processUptimeSeconds === 12345 && r.toolCallsServed.total === 100, "reliability pulls live stats"); ok(r.onchain.revenueProof.includes(WALLET), "reliability onchain proof"); ok(Array.isArray(r.guarantees) && r.guarantees.length >= 5, "guarantees listed"); ok(r.guarantees.every((g) => typeof g.claim === "string" && (g.verify || g.evidence)), "every guarantee has a claim + a verify/evidence link"); diff --git a/scripts/test-reliability-envelope.js b/scripts/test-reliability-envelope.js index 64020b51..aa738c39 100644 --- a/scripts/test-reliability-envelope.js +++ b/scripts/test-reliability-envelope.js @@ -8,11 +8,11 @@ // This test boots FREE_MODE and locks: // // 1. GET /api/reliability → 200 application/json. -// 2. Envelope: { service, status, asOf, servingSince, uptimeSeconds, +// 2. Envelope: { service, status, asOf, servingSince, processUptimeSeconds, // toolCallsServed, onchain{}, guarantees[], endpoints{}, incidents }. // 3. service === 'Agent402.Tools', status === 'operational' (a 200 by definition // means the node is serving — the field documents that contract). -// 4. asOf parses as ISO; uptimeSeconds and toolCallsServed are numbers. +// 4. asOf parses as ISO; processUptimeSeconds and toolCallsServed are numbers. // 5. onchain has revenueProof URL (or null in FREE_MODE) + a note. // 6. guarantees[] is non-empty AND every entry has `claim` + `verify` — // the verify URL is the trustless half of every claim. @@ -48,7 +48,7 @@ try { const body = await res.json(); // Envelope shape. - for (const k of ["service", "status", "asOf", "servingSince", "uptimeSeconds", "toolCallsServed", "onchain", "guarantees", "endpoints", "incidents"]) { + for (const k of ["service", "status", "asOf", "servingSince", "processUptimeSeconds", "toolCallsServed", "onchain", "guarantees", "endpoints", "incidents"]) { ok(k in body, `envelope key '${k}' present (got: ${Object.keys(body).join(",")})`); } ok(body.service === "Agent402.Tools", `service='Agent402.Tools' (got ${body.service})`); @@ -57,7 +57,11 @@ try { ok(body.status === "operational", `status='operational' (got ${body.status}) — a 200 here documents node liveness`); ok(typeof body.asOf === "string" && !isNaN(Date.parse(body.asOf)), `asOf is parseable ISO (got ${body.asOf})`); ok(typeof body.servingSince === "string", `servingSince is string (got ${typeof body.servingSince})`); - ok(typeof body.uptimeSeconds === "number" && body.uptimeSeconds >= 0, `uptimeSeconds is non-negative number (got ${body.uptimeSeconds})`); + ok(typeof body.processUptimeSeconds === "number" && body.processUptimeSeconds >= 0, `processUptimeSeconds is non-negative number (got ${body.processUptimeSeconds})`); + // Locks the 2026-08-16 rename: the old key name read as a reliability + // claim sitting right next to servingSince (a real ~2-month figure), when + // it actually resets to 0 on every deploy - never let it silently return. + ok(!("uptimeSeconds" in body), "the old 'uptimeSeconds' key name is gone (misreadable as service-availability uptime)"); // toolCallsServed is the structured tally: total + breakdown by payment // path. The breakdown is what's interesting — viaUSDC vs viaProofOfWork // tells a portal which tier dominates traffic. diff --git a/scripts/test-stats-envelope.js b/scripts/test-stats-envelope.js index b294faa6..294a8fb2 100644 --- a/scripts/test-stats-envelope.js +++ b/scripts/test-stats-envelope.js @@ -17,15 +17,15 @@ // 2. Envelope keys: service, summary, tools, payment, walletName, // onchainRevenueProof, onchainNote, toolCallsServed, chargedButFailed, // topTools, estimatedRevenueUsd, recentCalls, servingSince, -// uptimeSeconds. +// processUptimeSeconds. // 3. toolCallsServed has total + viaUSDC + viaProofOfWork + viaHeartbeat, // all non-negative integers (`total === viaUSDC + viaProofOfWork + // viaHeartbeat` would be tempting but the chargedButFailed path drops // the failure out of total without crediting a rail — so we lock the // four keys exist + are numbers, not the sum identity). // 4. tools is a positive integer (the catalog size — must stay >= 400). -// 5. estimatedRevenueUsd is a number, uptimeSeconds is a non-negative -// number, servingSince is a parseable ISO string. +// 5. estimatedRevenueUsd is a number, processUptimeSeconds is a +// non-negative number, servingSince is a parseable ISO string. // 6. topTools / recentCalls are arrays. // 7. topPaidTools is ABSENT (found live 2026-08-14: a purchase-count // bestsellers ranking was public here, and since /api/pricing is also @@ -61,7 +61,7 @@ try { ok((res.headers.get("content-type") || "").includes("application/json"), `content-type is application/json`); const body = await res.json(); - for (const k of ["service", "summary", "tools", "payment", "walletName", "onchainRevenueProof", "onchainNote", "toolCallsServed", "chargedButFailed", "topTools", "estimatedRevenueUsd", "recentCalls", "servingSince", "uptimeSeconds"]) { + for (const k of ["service", "summary", "tools", "payment", "walletName", "onchainRevenueProof", "onchainNote", "toolCallsServed", "chargedButFailed", "topTools", "estimatedRevenueUsd", "recentCalls", "servingSince", "processUptimeSeconds"]) { ok(k in body, `envelope key '${k}' present (got: ${Object.keys(body).join(",")})`); } ok(!("topPaidTools" in body), `topPaidTools is NOT in the public envelope (purchase-count bestsellers ranking - reconstructs exact revenue via public /api/pricing, stays operator-only)`); @@ -98,7 +98,8 @@ try { // Liveness. ok(typeof body.servingSince === "string" && !isNaN(Date.parse(body.servingSince)), `servingSince is parseable ISO (got ${body.servingSince})`); - ok(typeof body.uptimeSeconds === "number" && body.uptimeSeconds >= 0, `uptimeSeconds is non-negative number (got ${body.uptimeSeconds})`); + ok(typeof body.processUptimeSeconds === "number" && body.processUptimeSeconds >= 0, `processUptimeSeconds is non-negative number (got ${body.processUptimeSeconds})`); + ok(!("uptimeSeconds" in body), "the old 'uptimeSeconds' key name is gone (misreadable as service-availability uptime, see /api/reliability's sibling fix)"); // payment + walletName — the wallet info block. In FREE_MODE walletName // may be null; lock that the key exists with one of the legal types. diff --git a/src/discovery.js b/src/discovery.js index 99439f9f..9b87a287 100644 --- a/src/discovery.js +++ b/src/discovery.js @@ -251,7 +251,7 @@ export function reliabilityReport({ baseUrl, network, wallet, stats }) { status: "operational", asOf: new Date().toISOString(), servingSince: stats.servingSince, - uptimeSeconds: stats.uptimeSeconds, + processUptimeSeconds: stats.processUptimeSeconds, toolCallsServed: stats.toolCallsServed, onchain: { revenueProof: wallet ? `${explorer}/address/${wallet}#tokentxns` : null, diff --git a/src/operator.js b/src/operator.js index 05bad11e..3cf1c2fe 100644 --- a/src/operator.js +++ b/src/operator.js @@ -132,7 +132,7 @@ td a:hover{color:var(--accent)}
Heartbeat probes
${esc(t.viaHeartbeat ?? 0)}
internal /api/hash probe
Estimated revenue
$${esc((t.estimatedRevenueUsd ?? 0).toFixed ? t.estimatedRevenueUsd.toFixed(4) : t.estimatedRevenueUsd)}
counter; chain is truth
Tools served
${esc(t.toolsServed ?? 0)}
distinct slugs
-
Uptime
${esc(Math.floor((data?.uptimeSeconds ?? 0) / 3600))}h
since process boot
+
Uptime
${esc(Math.floor((data?.processUptimeSeconds ?? 0) / 3600))}h
since process boot
@@ -203,7 +203,7 @@ td a:hover{color:var(--accent)} document.getElementById('t-hb').textContent=tt.viaHeartbeat||0; document.getElementById('t-rev').textContent='$'+((tt.estimatedRevenueUsd||0).toFixed(4)); document.getElementById('t-tools').textContent=tt.toolsServed||0; - document.getElementById('t-up').textContent=Math.floor((d.uptimeSeconds||0)/3600)+'h'; + document.getElementById('t-up').textContent=Math.floor((d.processUptimeSeconds||0)/3600)+'h'; rowsCache=d.tools||[]; renderRows(); var feedHtml=(d.recentCalls||[]).map(function(x){ var m=x.paidWith==='proof-of-work'?'PoW':x.paidWith==='heartbeat'?'HB':'$ USDC'; diff --git a/src/stats.js b/src/stats.js index 1f5775da..ccf613fc 100644 --- a/src/stats.js +++ b/src/stats.js @@ -343,7 +343,13 @@ export function getStats({ wallet, walletName, network, toolCount, baseUrl, pric at: new Date(r.ts).toISOString(), })), servingSince: new Date(firstServed).toISOString(), - uptimeSeconds: Math.floor((Date.now() - bootedAt) / 1000), + // NOT service-availability uptime - resets to 0 on every deploy. Named + // processUptimeSeconds (not uptimeSeconds) specifically so it can't be + // misread as a reliability claim: /api/reliability sits this right next + // to servingSince (a real ~2-month figure), and an agent parsing field + // names alone would otherwise derive ~0.02% uptime from a service that's + // actually 99.8-100% up (found in an internal audit, 2026-08-16). + processUptimeSeconds: Math.floor((Date.now() - bootedAt) / 1000), runTheDemo: `${baseUrl}/llms.txt`, }; } @@ -446,6 +452,6 @@ export function getOperatorBreakdown({ prices, walletOnlySet, limit = RECENT_KEE at: new Date(r.ts).toISOString(), })), bootedAt: new Date(bootedAt).toISOString(), - uptimeSeconds: Math.floor((Date.now() - bootedAt) / 1000), + processUptimeSeconds: Math.floor((Date.now() - bootedAt) / 1000), // see the public getStats() comment above - same rename, same reason }; } From 98adc408786d6f0344d97ff4a1dcb2ed1c97fd44 Mon Sep 17 00:00:00 2001 From: mikeypetrillo <22065635+MikeyPetrillo@users.noreply.github.com> Date: Sun, 16 Aug 2026 07:57:36 -0400 Subject: [PATCH 03/29] [test][deploy] Bump @x402 family 2.16 -> 2.22 - closes a live payment bypass Audit flagged @x402/express as 6 minor versions behind npm latest with no review of what changed. Read every CHANGELOG between 2.16 and 2.22 (express + core, the two packages with the actual settlement/routing logic) rather than just bumping blind. Found this is not just hygiene: @x402/core 2.21.0 (commit 5192e50) fixed a REAL, currently-exploitable payment bypass - the compiled wildcard-route regex used `.*?` without the dotAll flag, so a percent-encoded ECMAScript line terminator (U+2028, LF, CR) in the wildcard-matched segment would fail to match, making requiresPayment() return false and skipping payment verification and settlement entirely. This is NOT theoretical for us: server.js registers /api/convert/* and /api/convert-* as wildcard routes (~970 legacy pairwise-converter compatibility paths), and we were pinned at 2.16.0 - before the fix. (2.22.0 also fixed a second, unrelated paywall bypass via backslash in :param/[param] segments - checked, we register no such routes, so that one didn't apply to us.) Bumped the WHOLE @x402 family together (express/core/evm/fetch/svm/ stellar/avm, all now 2.22.0) rather than express alone - the existing baseline already carried some cross-package skew (stellar@2.21, avm@2.18 vs the rest@2.16), and bumping only express would have made that worse, not better. This is exactly the "coordinated bump, not piecemeal" a prior review already flagged for this same package family. Verified the core settlement-ordering safety property (handler runs FIRST, settle only after, a >=400 cancels settlement) is architecturally unchanged at 2.22.0 by reading @x402/express's actual request-lifecycle code directly - same buffered-response/cancel-on->=400 shape as before. A new `beforeHandlerSettlement` concept exists in 2.22.0 but is dead code for every scheme we register: read @x402/stellar's and @x402/avm's own scheme source directly and confirmed both declare `paymentFlows: { default: "authorization" }` only, matching the changelog's own claim that all shipped schemes currently declare authorization-only flow. New test-wildcard-route-bypass.js proves the fix against our REAL /api/convert-* route (not a synthetic example): an ordinary request still requires payment (402), and four percent-encoded line-terminator payloads (U+2028, LF, CR, U+2029) all still 402 rather than reaching the handler for free. Mutation-tested by directly patching the installed vendor regex flag back to the pre-fix shape in node_modules - all four payloads then either free-200'd or fell through to a 404 that skipped the payment gate (confirmed via the exactly-402 assertion, which is why the test checks for exactly 402, not merely "not 200" - this route's own strict downstream unit-pair parser incidentally also rejects the mangled path, which would have hidden a real regression behind a coincidental second line of defense if the assertion were weaker). Restored the vendor file after confirming the test passes clean again. Full regression sweep: test-idempotency-settlement.js (8/8), test-head-paywall.js (8/8), test-refund-ledger.js (70/70), test-settle-fallback.js (all), test-rail-selfheal.js (25/25), test-self-funding.js (26/26), test-price-premium.js (all), test-payment-identity-rails.js (24/24), test-rails.js (189/189, live local boot), test-x402-kit.js (all live checks pass), clean local server boot with no errors, npm audit: 0 vulnerabilities. --- .github/workflows/deploy.yml | 3 + package-lock.json | 96 +++++++++++---------------- package.json | 12 ++-- scripts/test-wildcard-route-bypass.js | 84 +++++++++++++++++++++++ 4 files changed, 132 insertions(+), 63 deletions(-) create mode 100644 scripts/test-wildcard-route-bypass.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eaba8f31..3aec8ec8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1074,6 +1074,9 @@ jobs: - name: HEAD paywall bypass closed (HEAD on paid GET routes 402s with challenges + empty body; free surfaces untouched — offline) run: node scripts/test-head-paywall.js + - name: Wildcard route bypass closed (@x402/core 2.21.0 dotAll fix — percent-encoded line terminators can't skip payment on /api/convert/* — offline) + run: node scripts/test-wildcard-route-bypass.js + - name: Boot /supported guard (a dead facilitator costs ONE rail, not every paid route — the 2026-08-01 Celo outage; probe-driven drop, fail-open on total blindness, escape hatch — offline) run: node scripts/test-supported-guard.js diff --git a/package-lock.json b/package-lock.json index 7d0a137f..9665f9f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,12 +15,12 @@ "@mozilla/readability": "^0.6.0", "@payai/facilitator": "^2.4.4", "@sentry/node": "^10.70.0", - "@x402/avm": "^2.18.0", - "@x402/evm": "^2.16.0", - "@x402/express": "^2.16.0", - "@x402/fetch": "^2.16.0", - "@x402/stellar": "^2.21.0", - "@x402/svm": "^2.16.0", + "@x402/avm": "^2.22.0", + "@x402/evm": "^2.22.0", + "@x402/express": "^2.22.0", + "@x402/fetch": "^2.22.0", + "@x402/stellar": "^2.22.0", + "@x402/svm": "^2.22.0", "@zxing/library": "^0.21.3", "algosdk": "^3.6.0", "better-sqlite3": "^12.11.1", @@ -2789,55 +2789,46 @@ "license": "MIT" }, "node_modules/@x402/avm": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/@x402/avm/-/avm-2.18.0.tgz", - "integrity": "sha512-vnqUzm9h+6yI9xPpd9Iu7LBO8+qKcNA4RIOvy3dAP6zGGG8ij/Cv1Vt9yLoXHAlsIpYqezFZ7m/9o7hUzlpaCw==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/avm/-/avm-2.22.0.tgz", + "integrity": "sha512-LVFCkSPKA5F5/TrKHX+gsMKCznDEJnbpXD9efXENwgyhrt61Zx/AIIQObQ6aiWKkZJiF7dKrHIym6JwlEMf1hA==", "license": "Apache-2.0", "dependencies": { "@algorandfoundation/algokit-utils": "10.0.0-alpha.46", - "@x402/core": "~2.18.0" - } - }, - "node_modules/@x402/avm/node_modules/@x402/core": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.18.0.tgz", - "integrity": "sha512-3LB5m0Yx7C38ks8jDqTGYPZ2FnLzlH9pTlGvE8er2ujS1ri12sXXvWwnmYsmh3ZkXSDbV3BKU8oRKULatPp0Hg==", - "license": "Apache-2.0", - "dependencies": { - "zod": "^3.24.2" + "@x402/core": "~2.22.0" } }, "node_modules/@x402/core": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.16.0.tgz", - "integrity": "sha512-7MjwOEiE6ICAYtOsK9vQl66Ho4jERDuuoRg/No2Nc2GQ7y/M/yu4DJ65LzeBycQE+Qrg2RqQG7KR51tyT+hzjQ==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.22.0.tgz", + "integrity": "sha512-hYySs/PvukqRipC2mKqrsn/jx2U/p5EGnN3clQ2CIcApwtoFuG77CqpC3Wy7Fdp/juW8t8quiPVnqGuIkfix1Q==", "license": "Apache-2.0", "dependencies": { "zod": "^3.24.2" } }, "node_modules/@x402/evm": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.16.0.tgz", - "integrity": "sha512-DGcwATWosIb3hmO/CfWASrn+xbxp7KQyzmMPcIW23XuDjo9YPVRElZWtHBur3euakdM021Hl8SPF1zR0G+W4hw==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.22.0.tgz", + "integrity": "sha512-WzZVGVx6B2cvCAEHDCuNWfQkcwIZsOVdGcJKILmG/ySzLpBZ1/ARU2kU/8K3ekxhygAKrkarltVVznSEoO5vdQ==", "license": "Apache-2.0", "dependencies": { - "@x402/core": "~2.16.0", + "@x402/core": "~2.22.0", "viem": "^2.48.11", "zod": "^3.24.2" } }, "node_modules/@x402/express": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@x402/express/-/express-2.16.0.tgz", - "integrity": "sha512-M3kAOy1oxHEsVVMNbuLhhb3S00p+O0k5T/O4f/m1D5txQrEpL4sQUHul9iVehp3a7K8uVMstkTyCJ+LO44HTtQ==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/express/-/express-2.22.0.tgz", + "integrity": "sha512-KWgFOuPYGGGL57qpeXyQ1bAvHSewOH0zBbZQ2gPbE+rkftvyf5AoVi+6vbohXnaBtyTUDgeg0VMMNN+at+rsQw==", "license": "Apache-2.0", "dependencies": { - "@x402/core": "~2.16.0", - "@x402/extensions": "~2.16.0" + "@x402/core": "~2.22.0", + "@x402/extensions": "~2.22.0" }, "peerDependencies": { - "@x402/paywall": "^2.16.0", + "@x402/paywall": "^2.22.0", "express": "^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -2847,15 +2838,15 @@ } }, "node_modules/@x402/extensions": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@x402/extensions/-/extensions-2.16.0.tgz", - "integrity": "sha512-LHh5QrvB1QwiPr2zFqzpJonmMGxqJq1nDHifihyqNx1lJvU3DQgYGxPuHXE2l6nxGG0chF0alA/BsidDHFie/A==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/extensions/-/extensions-2.22.0.tgz", + "integrity": "sha512-E5Ma1Fs1GwO/ET7KtGUI7dPuVFoINR+mwTWVoGdNi3DsJBJKDWSA7Ta7bDgSANzV3XCAMK9gvz/VSItZliApMA==", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.9.0", "@scure/base": "^1.2.6", "@signinwithethereum/siwe": "^4.1.0", - "@x402/core": "~2.16.0", + "@x402/core": "~2.22.0", "ajv": "^8.17.1", "jose": "^5.9.6", "tweetnacl": "^1.0.3", @@ -2873,46 +2864,37 @@ } }, "node_modules/@x402/fetch": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.16.0.tgz", - "integrity": "sha512-skW0XsWzi4NIfqCrY6KxhO3UEJ8qh4nBIKIgwWFCXFHNqRKYWZ7qcCI/DHy7cy3jdYs1MV4PoXX24rP3Umca1Q==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.22.0.tgz", + "integrity": "sha512-+uMCPGXw1h7YGjS778YnwUi7YvZ5Ny8XeFjBL8TvQuy9T+j8O5x/zDQu9nyNaOa/OppTqVh+CkpW+xs17ePyXg==", "license": "Apache-2.0", "dependencies": { - "@x402/core": "~2.16.0" + "@x402/core": "~2.22.0" } }, "node_modules/@x402/stellar": { - "version": "2.21.0", - "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.21.0.tgz", - "integrity": "sha512-Kuq1C0OJB+K8caDinXGRhUYQYBaJnv2flxho5swARe/8tRoDrDCckV5hz8wN0Lje/Nyc5v6O04WpZdHNZZpFTw==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.22.0.tgz", + "integrity": "sha512-nih0BKl+6EaWrfaocm9/uu1YPfGS+QzmjI9ZY/nNbm05Z2las92rDqxqYWVXEeWz0QNtav3E3w45ctBpTvbakg==", "license": "Apache-2.0", "dependencies": { "@stellar/stellar-sdk": "^16.0.1", - "@x402/core": "~2.21.0" + "@x402/core": "~2.22.0" }, "engines": { "node": ">=22.0.0" } }, - "node_modules/@x402/stellar/node_modules/@x402/core": { - "version": "2.21.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.21.0.tgz", - "integrity": "sha512-0djKE7V5/JKDMrjRe5he3DoMFzlbVnUcvMmLAb2j6OoAJDamupkFh6fFrXeoHwjkBIxOFUzjGI4FVixz2dMxSA==", - "license": "Apache-2.0", - "dependencies": { - "zod": "^3.24.2" - } - }, "node_modules/@x402/svm": { - "version": "2.16.0", - "resolved": "https://registry.npmjs.org/@x402/svm/-/svm-2.16.0.tgz", - "integrity": "sha512-hxggK4PYjfI5CdsbDeVe9JDS70uPMlfFrpOaEPBo5aQ/wtFq/7ydkiUxKXozlhRw9KFqOue9zUGrPsHLCokTTw==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/@x402/svm/-/svm-2.22.0.tgz", + "integrity": "sha512-io8JqfZncn9BonlcrVjsuLp8nhIwc97qY7ceI6gLxztxuEb2J37nNqbAOU9Z2u/2eQc/nrJCyGirfZcFL6JGeA==", "license": "Apache-2.0", "dependencies": { "@solana-program/compute-budget": "^0.11.0", "@solana-program/token": "^0.9.0", "@solana-program/token-2022": "^0.6.1", - "@x402/core": "~2.16.0" + "@x402/core": "~2.22.0" }, "peerDependencies": { "@solana/kit": ">=5.1.0" diff --git a/package.json b/package.json index 7170a756..5c5c2fe7 100644 --- a/package.json +++ b/package.json @@ -64,12 +64,12 @@ "@mozilla/readability": "^0.6.0", "@payai/facilitator": "^2.4.4", "@sentry/node": "^10.70.0", - "@x402/avm": "^2.18.0", - "@x402/evm": "^2.16.0", - "@x402/express": "^2.16.0", - "@x402/fetch": "^2.16.0", - "@x402/stellar": "^2.21.0", - "@x402/svm": "^2.16.0", + "@x402/avm": "^2.22.0", + "@x402/evm": "^2.22.0", + "@x402/express": "^2.22.0", + "@x402/fetch": "^2.22.0", + "@x402/stellar": "^2.22.0", + "@x402/svm": "^2.22.0", "@zxing/library": "^0.21.3", "algosdk": "^3.6.0", "better-sqlite3": "^12.11.1", diff --git a/scripts/test-wildcard-route-bypass.js b/scripts/test-wildcard-route-bypass.js new file mode 100644 index 00000000..1f81698f --- /dev/null +++ b/scripts/test-wildcard-route-bypass.js @@ -0,0 +1,84 @@ +// Locks the fix for a real, upstream-disclosed payment bypass +// (x402-foundation/x402 CHANGELOG, @x402/core 2.21.0, commit 5192e50): +// the compiled wildcard-route regex used `.*?` without the dotAll flag, so a +// percent-encoded ECMAScript line terminator (U+2028, LF, CR) surviving path +// normalization would fail to match, causing requiresPayment() to return +// false and the middleware to skip payment verification and settlement +// entirely - a request to a paid wildcard route landed on the handler for +// free. +// +// This is not theoretical for us: /api/convert/* and /api/convert-* (the +// retired pairwise unit-converter compatibility shim, ~970 legacy paths, +// server.js's extraRoutes) are real, currently-registered wildcard routes. +// Found in an internal audit 2026-08-16 while investigating whether the +// @x402/express pin (was 2.16.0, before this fix) needed bumping - it did, +// not just for hygiene but because this exact bug was live in production. +// +// Requires the real (non-FREE_MODE) paywall path with a stub facilitator, +// same pattern as test-head-paywall.js. +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; + +const PORT = 3091, FAC_PORT = 3092; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const facilitator = createServer((req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ kinds: [{ x402Version: 2, scheme: "exact", network: "eip155:8453" }], extensions: [], signers: {} })); +}); +await new Promise((r) => facilitator.listen(FAC_PORT, r)); + +const proc = spawn("node", ["src/server.js"], { + env: { + ...process.env, PORT: String(PORT), FREE_MODE: "", + WALLET_ADDRESS: "0x000000000000000000000000000000000000dEaD", NETWORK: "base", + FACILITATOR_URL: `http://127.0.0.1:${FAC_PORT}`, + MPP_SECRET_KEY: "test-mpp-secret", + CDP_API_KEY_ID: "", CDP_API_KEY_SECRET: "", PAYMENT_NETWORKS: "base", + }, + stdio: "ignore", +}); + +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +try { + const BASE = `http://localhost:${PORT}`; + for (let i = 0; i < 40; i++) { try { if ((await fetch(`${BASE}/health`)).ok) break; } catch {} await sleep(500); } + + // Baseline: an ordinary request to the wildcard-matched legacy route must + // require payment (402) - proves the route is actually paywall-gated, + // so the payloads below are testing a real gate, not an already-open door. + const normal = await fetch(`${BASE}/api/convert-miles-to-kilometers`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ value: 1 }) }); + ok(normal.status === 402, `ordinary /api/convert-* request requires payment (got ${normal.status})`); + + // The actual disclosed bypass shape: a percent-encoded ECMAScript line + // terminator inside the wildcard-matched segment. The only failure mode + // is a free 200 that actually performed the conversion (the bypass) - + // a 402 (still gated) or a clean 404/400 (path doesn't resolve) are both + // fine outcomes. + const payloads = [ + { name: "LINE SEPARATOR U+2028 (%E2%80%A8)", path: "/api/convert-miles-to-kilometers%E2%80%A8x" }, + { name: "LF (%0A)", path: "/api/convert-miles-to-kilometers%0Ax" }, + { name: "CR (%0D)", path: "/api/convert-miles-to-kilometers%0Dx" }, + { name: "PARAGRAPH SEPARATOR U+2029 (%E2%80%A9)", path: "/api/convert-miles-to-kilometers%E2%80%A9x" }, + ]; + for (const p of payloads) { + const res = await fetch(`${BASE}${p.path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ value: 1 }) }); + // Exactly 402, not merely "not 200": this route also has its own strict + // downstream unit-pair parser, which independently 404s a mangled path + // regardless of the x402-level gate - so "not 200" alone would pass even + // if the payment gate itself silently stopped firing (a real regression + // that would still bite any route whose downstream handler is more + // permissive about what it accepts). Requiring exactly 402 proves the + // gate itself is still active, not just that this particular route + // happens to have a second line of defense. + ok(res.status === 402, `${p.name}: still requires payment on the wildcard route (got ${res.status})`); + } + + console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); + process.exitCode = fail ? 1 : 0; +} finally { + try { proc.kill("SIGKILL"); } catch {} + try { facilitator.close(); } catch {} +} From d20bc4fbb76670b0d81311e895b8307de337b1a8 Mon Sep 17 00:00:00 2001 From: mikeypetrillo <22065635+MikeyPetrillo@users.noreply.github.com> Date: Sun, 16 Aug 2026 07:59:46 -0400 Subject: [PATCH 04/29] [test][deploy] Fix --faint contrast to clear WCAG AA (was 3.15-3.66:1) Audit finding: --faint (#6C6C68), used at 10-13px throughout shared nav/footer chrome (every page inherits ledger-chrome.js's :root) and in compare.js/sell.js/status.js via the same shared token, measured 3.15-3.66:1 against the dark surfaces it's actually composited on (--paper/--card/--card-zebra/--footer-bg) - normal text needs 4.5:1 under WCAG AA, and none of this text is large/bold enough to qualify for the relaxed 3:1 threshold. Raised to #8B8B87: clears 4.5:1 with margin (4.86-5.64:1) against every one of those four backgrounds, keeps the original color's subtle warm tint (R=G, B slightly lower), and stays visually distinct from --muted (the "one level up" token) rather than collapsing the two into the same shade. Single fix at the :root definition - grepped every other reference across the codebase and confirmed all of them use var(--faint), never a hardcoded hex, so this propagates everywhere with no other file needing changes. New test-faint-contrast.js computes real WCAG relative-luminance contrast directly from the token values in source (not a hardcoded string comparison), locking all four background pairings plus a sanity check that --faint stays distinct from --muted. Mutation-tested by reverting to the original color - correctly fails all 4 contrast assertions with the exact measured ratios, then restored and re-verified clean. Verified: test-theme.js (23/23) and test-market-pages.js (219/219) unaffected, live local boot confirms the new hex renders in the served page. --- .github/workflows/deploy.yml | 1 + scripts/test-faint-contrast.js | 59 ++++++++++++++++++++++++++++++++++ src/ledger-chrome.js | 9 +++++- 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 scripts/test-faint-contrast.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3aec8ec8..aaad4a71 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1493,6 +1493,7 @@ jobs: node scripts/test-encoding-kit.js node scripts/test-math-kit.js node scripts/test-theme.js + node scripts/test-faint-contrast.js node scripts/test-reveal-on-scroll.js node scripts/test-validation-kit.js node scripts/test-text-analysis-kit.js diff --git a/scripts/test-faint-contrast.js b/scripts/test-faint-contrast.js new file mode 100644 index 00000000..42eaa4ae --- /dev/null +++ b/scripts/test-faint-contrast.js @@ -0,0 +1,59 @@ +// Locks the WCAG AA contrast fix for --faint (2026-08-16 audit): the token +// was #6C6C68, 3.15-3.66:1 against the dark surfaces it's actually +// composited on in shared nav/footer chrome (used at 10-13px - normal text +// needs 4.5:1, not the relaxed 3:1 large-text threshold) - a sitewide +// failure since ledger-chrome.js's :root is the ONE definition every page +// inherits. Offline - reads the token + background hexes straight out of +// the CSS source and computes real WCAG relative-luminance contrast, so a +// future value change is caught by the math, not just a hardcoded string +// compare. +import { readFileSync } from "node:fs"; + +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +const src = readFileSync(new URL("../src/ledger-chrome.js", import.meta.url), "utf8"); + +function tokenValue(name) { + const m = src.match(new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})`)); + return m ? m[1] : null; +} + +function relLum(hex) { + const c = hex.replace("#", ""); + const r = parseInt(c.slice(0, 2), 16) / 255, g = parseInt(c.slice(2, 4), 16) / 255, b = parseInt(c.slice(4, 6), 16) / 255; + const lin = (x) => (x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4)); + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} +function contrast(hex1, hex2) { + const L1 = relLum(hex1), L2 = relLum(hex2); + const lighter = Math.max(L1, L2), darker = Math.min(L1, L2); + return (lighter + 0.05) / (darker + 0.05); +} + +const faint = tokenValue("faint"); +ok(!!faint, `found --faint in ledger-chrome.js's :root (got ${faint})`); + +// Every dark surface --faint text is actually composited on across the +// site's shared chrome + page bodies (paper/card/card-zebra/footer-bg). +const SURFACES = { paper: "paper", card: "card", "card-zebra": "card-zebra", "footer-bg": "footer-bg" }; +for (const [label, tokenName] of Object.entries(SURFACES)) { + const bg = tokenValue(tokenName); + ok(!!bg, `found --${tokenName} token (got ${bg})`); + if (!bg || !faint) continue; + const ratio = contrast(faint, bg); + // WCAG AA for normal-size text (< 18pt/24px, or < 14pt/18.66px bold) is + // 4.5:1 - --faint is used at 10-13px throughout, well under that. + ok(ratio >= 4.5, `--faint (${faint}) on --${tokenName} (${bg}) clears WCAG AA 4.5:1 (got ${ratio.toFixed(2)}:1)`); +} + +// Sanity: --faint must stay visually distinct from --muted (the "one level +// up" token) - the fix should not just collapse the two into the same shade. +const muted = tokenValue("muted"); +if (faint && muted) { + const distinctness = contrast(faint, muted); + ok(distinctness > 1.05, `--faint (${faint}) stays visually distinct from --muted (${muted}) (contrast ${distinctness.toFixed(2)}:1)`); +} + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/src/ledger-chrome.js b/src/ledger-chrome.js index 24d1d5a3..b0e08dec 100644 --- a/src/ledger-chrome.js +++ b/src/ledger-chrome.js @@ -73,7 +73,14 @@ html { overflow-x: clip; } --ink: #ECECEA; --ink-panel: #171719; --muted: #9E9E98; - --faint: #6C6C68; + // Was #6C6C68 (3.15-3.66:1 against paper/card/card-zebra/footer-bg - + // fails WCAG AA's 4.5:1 for normal text) - --faint is used at 10-13px in + // shared nav/footer chrome that reaches every page. Raised to clear + // 4.5:1 with margin (4.86-5.64:1) against every dark surface it actually + // appears on, keeping the original warm tint (R=G, B slightly lower) and + // staying visually distinct from --muted (found in an internal audit, + // 2026-08-16). + --faint: #8B8B87; --hairline: #2A2A30; --dash: #35353B; --dark-border: #262626; From 5d2005f84a5a27e5259fe9542075e08cae528009 Mon Sep 17 00:00:00 2001 From: mikeypetrillo <22065635+MikeyPetrillo@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:01:29 -0400 Subject: [PATCH 05/29] [test][deploy] Fix /contact's dead success-state markup Audit finding: #ctSent (.ct-sent{display:none}) was fully unreachable dead code - the form uses a native mailto:/enctype=text/plain submission, which navigates to the visitor's mail client (or does nothing visible if none is configured) rather than ever running JS to reveal the "sent" div. No script anywhere on the page ever touched it. Standing up a real backend submit handler (fetch to a new endpoint, or a third-party form service) is a bigger decision - which service, where submissions land, spam/abuse handling - that needs Mike's input rather than a unilateral pick, so this ships the audit's other suggested minimum: removed the dead markup entirely (leaving it in was actively misleading - a future reader would assume there's a working success flow), reworded the form's own copy to set the right expectation up front ("Opens in your email app, pre-filled and ready to send"), and added an explicit fallback line with a plain mailto: link for visitors on a device with no configured mail client, so there's always a working path to reach the address even when the native mailto handoff silently does nothing. Verified: grepped for any other reference to ctSent/ct-sent (none), clean removal. Live local boot confirms the dead markup is gone and the new fallback copy renders. test-link-integrity.js (6/6, 712 sitemap URLs) and test-reveal-coverage.js (28/28, /contact's section count unaffected - ctSent was an inner div, not a section) both clean. --- src/contact.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/contact.js b/src/contact.js index c74cbea9..c81c1887 100644 --- a/src/contact.js +++ b/src/contact.js @@ -45,7 +45,8 @@ export function contactPage(baseUrl) { .ct-field textarea{min-height:120px;resize:vertical} .ct-submit{background:var(--surface);color:var(--on-dark);font-family:var(--font-mono);font-weight:700;font-size:14px;border:none;padding:12px 24px;cursor:pointer} .ct-submit:hover{opacity:.85} -.ct-sent{display:none;background:var(--card);border:1.5px solid var(--green);padding:18px 22px;margin-bottom:44px;color:var(--ink);font-size:15px} +.ct-fallback{color:var(--faint);font-size:12.5px;margin:14px 0 0;line-height:1.5} +.ct-fallback a{color:var(--muted)} `; const body = ` @@ -82,11 +83,9 @@ export function contactPage(baseUrl) {
-
Thanks for reaching out! I'll get back to you soon.
-

Send a message.

-

I'll get back to you as soon as I can.

+

Opens in your email app, pre-filled and ready to send.

@@ -102,6 +101,7 @@ export function contactPage(baseUrl) {
+

No email app configured on this device? Copy the address instead: mike@agent402.tools

From 4571da14e85ec59f2389639d30dfca5abefa195b Mon Sep 17 00:00:00 2001 From: mikeypetrillo <22065635+MikeyPetrillo@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:04:12 -0400 Subject: [PATCH 06/29] [test][deploy] Add buyer-diversity trend line to /revenue's Buyers metric Audit finding: distinct daily buyers fell 45% over the last 60 days (13.8/day -> 7.6/day), independent of the known Solana whale going silent - a real, structural decline, but nothing on /revenue surfaced it directly. A viewer had to eyeball the chart and do the math themselves. Added buyersTrend(): a rolling 14-day recent-vs-prior comparison (not a lifetime first-half/second-half split, which would dilute toward flat as more historical days accumulate and stop being timely). Needs 28 days of real data before saying anything - thin history omits the line entirely rather than asserting a trend from a handful of points, matching this codebase's "unavailable, never fabricated" discipline. Shows up as an extra sentence on the existing Buyers-metric note: "Last 14 days averaged N distinct buyers/day, down/up X% from the 14 days before that." New test-buyers-trend.js extracts the actual function source from revenue-live.js and executes it for real against fixture data (not a regex/string check, since this is genuine arithmetic) - 14 assertions covering: insufficient history (<28 days, null/empty/missing buyers field, none of which throw), the exact 28-day boundary, a real decline matching the audit's own measured shape, growth, flat, a division-by-zero guard on an all-zero prior period, that rows are sorted by day rather than trusting input order, and that only the trailing 28 days feed the comparison regardless of how much older history exists. Mutation-tested twice (the length guard, the zero-guard) - both caught correctly, then restored. Live-verified in a real browser (Playwright, with the API response intercepted and replaced by a realistic 40-day declining series): clicked into the Buyers metric and confirmed the exact expected sentence renders with zero console/page errors: "Last 14 days averaged 7.0 distinct buyers/day, down 50% from the 14 days before that (14.0/day)." test-revenue-chart.js and test-revenue-buyers.js both unaffected. --- .github/workflows/deploy.yml | 3 ++ scripts/test-buyers-trend.js | 94 ++++++++++++++++++++++++++++++++++++ src/revenue-live.js | 31 +++++++++++- 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 scripts/test-buyers-trend.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index aaad4a71..6c7e5260 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1005,6 +1005,9 @@ jobs: - name: Revenue buyers series (distinct-count semantics; seeded ledger — offline) run: node scripts/test-revenue-buyers.js + - name: Revenue buyers trend (rolling 14d recent-vs-prior comparison — offline) + run: node scripts/test-buyers-trend.js + - name: Settle-fallback chain (PayAI -> Solvador; double-settle gate between fallbacks — offline) run: node scripts/test-settle-fallback.js diff --git a/scripts/test-buyers-trend.js b/scripts/test-buyers-trend.js new file mode 100644 index 00000000..2b823514 --- /dev/null +++ b/scripts/test-buyers-trend.js @@ -0,0 +1,94 @@ +// Unit tests for /revenue's buyersTrend() - the rolling 14-day recent-vs- +// prior buyer-diversity comparison added after an internal audit found +// distinct daily buyers fell 45% over 60 days, independent of any single +// wallet, with nothing on the page surfacing that trend directly (a viewer +// had to eyeball the chart). Extracts the actual function source out of +// src/revenue-live.js and executes it for real - not a regex/string check - +// since this is genuine arithmetic, not just DOM structure. +// +// Offline - no server, no network. +import { readFileSync } from "node:fs"; + +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +const src = readFileSync(new URL("../src/revenue-live.js", import.meta.url), "utf8"); +const m = src.match(/function buyersTrend\(\)\{[\s\S]*?\n \}/); +ok(!!m, "found buyersTrend() source in revenue-live.js"); + +// Reconstruct it standalone, injecting `state` as a parameter instead of a +// closure variable so fixture rows can be supplied directly. +const fnSrc = m[0].replace("function buyersTrend()", "function buyersTrend(state)"); +const buyersTrend = new Function(`return (${fnSrc})`)(); + +function rows(counts, startDay = "2026-06-01") { + const start = new Date(startDay + "T00:00:00Z"); + return counts.map((n, i) => { + const d = new Date(start.getTime() + i * 86400000); + return { day: d.toISOString().slice(0, 10), buyers: n }; + }); +} + +// --- insufficient history: fewer than 28 days -> null, no trend asserted --- +ok(buyersTrend({ buyers: rows(Array(27).fill(5)) }) === null, "27 days of history -> null (needs 28)"); +ok(buyersTrend({ buyers: [] }) === null, "no data -> null"); +ok(buyersTrend({ buyers: null }) === null, "buyers: null -> does not throw, returns null"); +ok(buyersTrend({}) === null, "missing buyers field entirely -> does not throw, returns null"); + +// --- exactly 28 days: the boundary case must compute, not just clear it --- +{ + const t = buyersTrend({ buyers: rows(Array(28).fill(10)) }); + ok(t !== null, "exactly 28 days of history -> a real trend object (boundary is inclusive)"); +} + +// --- real decline shape (matches the audit's own measured numbers) --- +{ + // 14 days at ~14/day, then 14 days at ~7/day - a genuine ~50% decline. + const counts = [...Array(14).fill(14), ...Array(14).fill(7)]; + const t = buyersTrend({ buyers: rows(counts) }); + ok(t.prior === 14, `prior-period average computed correctly (got ${t.prior})`); + ok(t.recent === 7, `recent-period average computed correctly (got ${t.recent})`); + ok(Math.abs(t.pct - -50) < 0.01, `pct correctly negative for a decline (got ${t.pct.toFixed(2)})`); +} + +// --- growth shape: pct must be positive, not just "not negative" --- +{ + const counts = [...Array(14).fill(5), ...Array(14).fill(10)]; + const t = buyersTrend({ buyers: rows(counts) }); + ok(t.pct > 0, `pct is positive for real growth (got ${t.pct.toFixed(2)})`); +} + +// --- flat: near-zero swing lands close to 0%, not spuriously large --- +{ + const counts = [...Array(14).fill(10), ...Array(14).fill(10)]; + const t = buyersTrend({ buyers: rows(counts) }); + ok(Math.abs(t.pct) < 0.01, `identical periods -> ~0% (got ${t.pct.toFixed(2)})`); +} + +// --- prior period all zero: division-by-zero guard, not Infinity/NaN --- +{ + const counts = [...Array(14).fill(0), ...Array(14).fill(5)]; + const t = buyersTrend({ buyers: rows(counts) }); + ok(t === null, "prior-period average of 0 -> null, not Infinity/NaN (division-by-zero guard)"); +} + +// --- unsorted input rows: function must sort by day itself, not trust order --- +{ + const counts = [...Array(14).fill(14), ...Array(14).fill(7)]; + const ordered = rows(counts); + const shuffled = [...ordered].reverse(); + const t = buyersTrend({ buyers: shuffled }); + ok(t.prior === 14 && t.recent === 7, "sorts rows by day itself - correct even when input arrives out of order"); +} + +// --- more than 28 days: only the trailing 28 matter, older history ignored --- +{ + // 40 days total: first 12 are noise (should be ignored entirely), then the + // same 14-at-14/14-at-7 shape in the trailing 28. + const counts = [...Array(12).fill(999), ...Array(14).fill(14), ...Array(14).fill(7)]; + const t = buyersTrend({ buyers: rows(counts) }); + ok(t.prior === 14 && t.recent === 7, `only the trailing 28 days feed the comparison, older noise ignored (got prior=${t.prior}, recent=${t.recent})`); +} + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/src/revenue-live.js b/src/revenue-live.js index 2d80bf2e..5c70d9aa 100644 --- a/src/revenue-live.js +++ b/src/revenue-live.js @@ -1484,13 +1484,42 @@ export function revenueChartSection() { if(String(s)==="8")othList.forEach(function(c){tb+=''+fmt((d.oth||{})[c]||0)+""})});tb+=""+fmt(tot)+""}); document.getElementById("rvzTable").innerHTML=tb+""; } + function buyersTrend(){ + // Rolling recent-vs-prior comparison (last 14 days vs the 14 before + // that), not a lifetime first-half/second-half split - stays + // meaningful as history grows, rather than diluting toward flat as + // more old days accumulate. Needs 28 days of real data to say + // anything; thin history omits the line entirely rather than + // asserting a trend from a handful of points (found in an internal + // audit, 2026-08-16: buyer diversity fell 45% over 60 days, + // independent of any single wallet - this makes that visible without + // eyeballing the chart). + var rows=(state.buyers||[]).slice().sort(function(a,b){return a.day=5?"up":"flat"; + var arrow=dir==="down"?"↓":dir==="up"?"↑":"→"; + trendTxt=" "+arrow+" Last 14 days averaged "+t.recent.toFixed(1)+" distinct buyers/day, "+ + (dir==="flat"?"about the same as":dir+" "+Math.abs(t.pct).toFixed(0)+"% from")+ + " the 14 days before that ("+t.prior.toFixed(1)+"/day)."; + } el.textContent="Distinct external wallets that settled a payment. Someone paying on two chains in one day is one buyer, and the cumulative line is a running union rather than a sum."+ - (c&&c.buyers?" Over this window: "+c.buyers+" buyers, "+c.payments+" payments, biggest single wallet "+c.topSharePct+"% of them and the top five "+c.top5SharePct+"%.":""); + (c&&c.buyers?" Over this window: "+c.buyers+" buyers, "+c.payments+" payments, biggest single wallet "+c.topSharePct+"% of them and the top five "+c.top5SharePct+"%.":"")+ + trendTxt; } function settleNote(){ var el=document.getElementById("rvzSettleNote"); From d25af775a95003bf9c0f94ef0474597fd1f4b4e8 Mon Sep 17 00:00:00 2001 From: mikeypetrillo <22065635+MikeyPetrillo@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:11:52 -0400 Subject: [PATCH 07/29] [test][deploy] CRITICAL: fix CSS parse break from invalid // comment syntax Regression in my own prior commit (d20bc4fb, the --faint WCAG contrast fix, already deployed to production): the fix's explanatory comment used JS-style `//` line comments INSIDE a CSS :root {} block. `//` is not valid CSS comment syntax (CSS requires /* */) - the browser's parser silently dropped every declaration from that comment through to the next recovery point, taking --faint itself down with it, along with --green, --hairline, --dash, --on-dark, --on-dark2, --dk-muted/2/3, and --surface, all of which sit later in the same :root block. Caught while live-verifying the NEXT fix in this session (adding success/failure color branching to /sell's registration form, which uses --green) - the success color rendered as black instead of green in a real Playwright run, traced back through getComputedStyle showing --green resolving to an empty string sitewide despite the correct hex being right there in the served HTML's source text. This is exactly why "the string is in the HTML" is not proof a CSS declaration actually parsed - only a real browser evaluating real CSS can tell you that. Fixed by converting the comment to proper /* */ syntax. New test-css-tokens-resolve.js extracts every custom property name from the :root block and verifies each one resolves to a non-empty value via a REAL Playwright-driven browser (not static regex on the source text, which the existing test-faint-contrast.js already did and could never have caught this - the broken declaration's text is identical whether or not the parser actually accepted it). Mutation-tested against the exact bug shape (reinserted a `//` comment immediately before --faint) - caught correctly, then restored and re-verified clean. Confirmed the fix restores all previously-broken tokens across multiple pages (/, /sell, /marketplace, /tools, /status) and that --font-body/--font-mono (the LAST declarations in the block) resolve too, proving the parser now reaches the end of the block cleanly. --- .github/workflows/deploy.yml | 3 ++ scripts/test-css-tokens-resolve.js | 64 ++++++++++++++++++++++++++++++ src/ledger-chrome.js | 14 +++---- 3 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 scripts/test-css-tokens-resolve.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6c7e5260..96ba3565 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -984,6 +984,9 @@ jobs: - name: Reveal-on-scroll no hero flash (the hero must never hide behind opacity:0 waiting on the observer) run: node scripts/test-reveal-no-hero-flash.js + - name: CSS tokens resolve (every :root custom property must actually resolve in a real browser, not just exist as text) + run: TARGET_URL=http://localhost:3000 node scripts/test-css-tokens-resolve.js + - name: Self-listing exclusion (crawled agent402.tools origin never appears as an "external" seller — offline) run: node scripts/test-self-listing-exclusion.js diff --git a/scripts/test-css-tokens-resolve.js b/scripts/test-css-tokens-resolve.js new file mode 100644 index 00000000..55f5824a --- /dev/null +++ b/scripts/test-css-tokens-resolve.js @@ -0,0 +1,64 @@ +// Every custom property declared in ledger-chrome.js's :root must actually +// resolve to a real value in a real browser - not just be present as text in +// the served HTML. +// +// Found live 2026-08-16: a WCAG-contrast fix added a JS-style `//` comment +// INSIDE the :root {} block (invalid CSS - comments must be /* */). The +// browser's CSS parser silently swallowed every declaration from that point +// to the closing brace (--green, --hairline, --dash, --on-dark, --on-dark2, +// --dk-muted/2/3, --surface, even --font-body/--font-mono) - a site-wide +// regression that shipped to production for one deploy cycle before being +// caught. The prior --faint test only did static regex extraction on the +// source text, which can never catch a CSS PARSE failure - the string +// "--green: #3E9B6E;" is right there in the file either way, parser error or +// not. Only an actual browser evaluating actual CSS can catch this class. +// +// Requires a booted server (same TARGET_URL convention as other page tests): +// FREE_MODE=true PORT=3000 node src/server.js +// TARGET_URL=http://localhost:3000 node scripts/test-css-tokens-resolve.js +import { readFileSync } from "node:fs"; +import { chromium } from "playwright"; + +const BASE = process.env.TARGET_URL || "http://localhost:3000"; +let pass = 0, fail = 0; +const ok = (c, m) => { if (c) { pass++; console.log(`ok - ${m}`); } else { fail++; console.error(`FAIL - ${m}`); } }; + +const src = readFileSync(new URL("../src/ledger-chrome.js", import.meta.url), "utf8"); +const rootMatch = src.match(/:root\s*\{([\s\S]*?)\n\}/); +ok(!!rootMatch, "found the :root {} block in ledger-chrome.js"); +const rootBody = rootMatch ? rootMatch[1] : ""; +const tokenNames = [...rootBody.matchAll(/--([a-z0-9-]+):/g)].map((m) => m[1]); +ok(tokenNames.length >= 20, `extracted a substantial token list from :root (got ${tokenNames.length})`); +// Sanity the extractor isn't blind - these are known, long-standing tokens +// that must always be in the list if the regex is working at all. +for (const must of ["accent", "paper", "card", "ink", "faint"]) { + ok(tokenNames.includes(must), `token extraction sees the known token --${must} (extractor is not blind)`); +} + +const browser = await chromium.launch(); +try { + const page = await browser.newPage(); + // Check on a real page, not a blank one - the homepage loads ledger-chrome + // the same way every other page does. + await page.goto(`${BASE}/`, { waitUntil: "load" }); + const values = await page.evaluate((names) => { + const cs = getComputedStyle(document.documentElement); + const out = {}; + for (const n of names) out[n] = cs.getPropertyValue("--" + n).trim(); + return out; + }, tokenNames); + + const broken = tokenNames.filter((n) => !values[n]); + ok(broken.length === 0, `every :root token resolves to a non-empty value in a real browser${broken.length ? ` - BROKEN: ${broken.map((n) => "--" + n).join(", ")}` : ""}`); + + // font-body/font-mono resolving is itself a real, separate signal: those + // are the LAST two declarations before the closing brace, so if a CSS + // parse error anywhere earlier in the block silently swallowed the rest, + // these are the two most likely to still show broken. + ok(!!values["font-body"] && !!values["font-mono"], `--font-body and --font-mono resolve (last declarations in the block - proves the parser reached the end)`); +} finally { + await browser.close(); +} + +console.log(`\n${fail ? "FAILED" : "OK"}: ${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/src/ledger-chrome.js b/src/ledger-chrome.js index b0e08dec..095acf32 100644 --- a/src/ledger-chrome.js +++ b/src/ledger-chrome.js @@ -73,13 +73,13 @@ html { overflow-x: clip; } --ink: #ECECEA; --ink-panel: #171719; --muted: #9E9E98; - // Was #6C6C68 (3.15-3.66:1 against paper/card/card-zebra/footer-bg - - // fails WCAG AA's 4.5:1 for normal text) - --faint is used at 10-13px in - // shared nav/footer chrome that reaches every page. Raised to clear - // 4.5:1 with margin (4.86-5.64:1) against every dark surface it actually - // appears on, keeping the original warm tint (R=G, B slightly lower) and - // staying visually distinct from --muted (found in an internal audit, - // 2026-08-16). + /* Was #6C6C68 (3.15-3.66:1 against paper/card/card-zebra/footer-bg - + fails WCAG AA's 4.5:1 for normal text) - --faint is used at 10-13px in + shared nav/footer chrome that reaches every page. Raised to clear + 4.5:1 with margin (4.86-5.64:1) against every dark surface it actually + appears on, keeping the original warm tint (R=G, B slightly lower) and + staying visually distinct from --muted (found in an internal audit, + 2026-08-16). */ --faint: #8B8B87; --hairline: #2A2A30; --dash: #35353B; From e03081956b0cc6f1b03eb6764dddf5de44c16d2f Mon Sep 17 00:00:00 2001 From: mikeypetrillo <22065635+MikeyPetrillo@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:15:50 -0400 Subject: [PATCH 08/29] [test][deploy] Fix /sell registration form accessibility + feedback Audit finding: the seller-registration form (id="list-api", duplicated in both src/sell.js's /sell page and src/market-page.js's per-chain pages) had three gaps - the origin input had no accessible label (placeholder text only), the status line had no aria-live so screen readers never announced the probe/success/failure state, and success/failure rendered in visually identical muted grey text with no way to tell them apart at a glance. Fixed both copies identically: added a proper