The durable security reference for base-tx-explain. Sessions end; this file is
what the next one reads. It records the threat model, the trust boundaries, what
has been checked and found safe, and what is known-open — so nobody re-treads
old ground or reopens a closed question.
Keep it current: when a finding is fixed, move it from Open to Fixed with the commit; when you clear a hypothesis, add it to Checked and safe.
Last reviewed: 2026-08-21, live at be8a858 (pass settlement integrity).
We hold no user accounts. The payout (RECEIVE) wallet is receive-only — an
EIP-3009 authorization pins to = X402_PAY_TO, so a replayer can only push the
payer's own $0.02 toward us, and no private key for it exists in this codebase or
environment.
This changed on 2026-08-21: autonomous spend is now authorized on the SPEND
wallet (0x2E31f337…). Until then this section said we held no secret with
spend authority, and every judgement below inherited that. We do now. Breach
damage is no longer bounded by "there is nothing to take" — it is bounded by the
SPEND wallet's balance, which is a number someone chooses and must keep choosing
deliberately. See §9.
The asymmetric risk is reputational and second-order: our JSON is fed to other agents' LLMs, which act on it. A wrong or manipulated answer can cost a user money through an agent that trusted us — and that failure is invisible to us, because it happens in someone else's system. This is the thing to defend hardest. It is why a confidently-wrong or injectable decode is a security issue, not merely a bug.
Priority order for security effort:
- Output integrity — the decode must not confidently assert something false, and must not carry attacker-authored instructions into a consuming model.
- Revenue/paywall — the free-tier and payment gates.
- Availability — one 256 MB Fly machine serving real users.
- Confidentiality — was "least important; we hold little worth stealing." That is now only true of user data. A credential with spend authority is worth stealing, so confidentiality of the SPEND signer specifically ranks with the items above it, while everything else here still holds.
Untrusted (attacker-controllable) — treat as data, never as truth or instructions:
- The transaction itself: any contract can emit any log and be called with any
4-byte selector. Event
topic0and function selectors are claims, not proof. - Token/contract self-reported metadata:
symbol(),name(),decimals(),totalSupply(). A token can name itself anything and lie about decimals. - Third-party API responses that reach output: Sourcify ABIs (event names),
api.4byte.sourcify.dev(function names — community-submittable), Blockscout txlist, the two GitHub scam blocklists. - Any client-supplied HTTP header except
Fly-Client-IP(see below), and the request body.
Trusted:
- Our own code and the static
src/labels.tsaddress table (hand-verified). - The Fly edge: it stamps
Fly-Client-IPand overwrites any client-supplied value, so it is a safe identity key.trust proxyis1(one hop), soreq.ipis not spoofable viaX-Forwarded-For. - The Chainlink ETH/USD feed and Base RPC for consensus data (balances, logs, receipts) — but not for anything a contract self-reports.
The output contract: provenance.untrusted_fields on every result names the
fields whose strings are attacker-controlled (summary, assets_moved[].token,
counterparties[].label). Consuming agents are told, in-band, to treat those as
data. Keep that list honest as fields change.
POST /mcp— the tool. Free-tier + rate-limit gate, x402 payment path, then the decoder fan-out (Base RPC, Sourcify, 4byte, Chainlink, Blockscout, blocklists).GET /healthz,/stats,/openapi.json,/,/llms.txt— info surface.- The decoder →
summary/assets_moved/risk_flags→ a downstream LLM. - Startup:
initPayments()reaches the x402 facilitator before serving. - Deploy pipeline: three sessions committing to one repo daily.
-
Forged protocol events laundering a drain into a benign action (classifier emitter-gating).
decodeKnownLogmatches ontopic0alone, butlog.address(the emitter) is not forgeable — a contract can only emit logs from itself.classifynow trusts a protocol-semantic event (Aave lending, OP bridge, Seaport sale, EAS attestation, Basenames registration) only when the emitter carries the matching label, so a counterfeit event from a random contract no longer produces "supplied to Aave" / "bridged into Base" / "claimed". A drain fronted by aclaim()selector is also caught: the claim rule is now subordinate to net flows (a real claim receives value; a sender that only parted with value is described as the transfer it is). Real, labeled protocols are unaffected. Residuals in Open #1. Recall cost measured, not assumed: a 120-transaction sweep of live Base traffic after the change was 93.3% clean with 0 crashes, and every partial was an app-specific unrecognized-event contract or a batch transfer hitting the 60-asset display cap — none came from the emitter gating. The safety-over-recall trade cost no measurable coverage. -
$9 pass issued without payment / phantom revenue (
649c763,38bc986,be8a858).buy_passminted inside the x402 handler, which runs BEFORE settle, and @x402/mcp returns the handler's result even when settle answers200 {success:false}— a caller whose payment never landed received a live $9 bearer token, repeatably (an authorization can pass verify and fail settle; the EIP-3009 nonce is only consumed at settle). MCP passes now mint inert and activate only when settlement resolves; REST already withheld the token (@x402/express discards the buffered body on failed settle) so it mints active — minting pending there would have stranded real payers. Revenue is booked on both rails only once a sale is confirmed/delivered. Two corrections found by adversarial verification, both worse than the original bug: (1) the nonce was read fromextra._meta, but the wrapper passes its own{toolName, arguments, meta}context — always null, so every paid pass would have been dead on arrival (100% failure on real sales). Now typed as the library'sMCPToolContext, so that mistake is a build error. (2) revoking onsuccess===falseis WRONG:settlement_pendingcarries a real tx hash after broadcast andtransfer_event_mismatchmeans the transfer mined. A pass is now taken back only on a provably pre-broadcast rejection (no tx hash- a recognised validation reason); everything else activates and alerts. Design rule this established: on a payment path, ambiguity must fail toward the customer. "Withhold, maybe activate" looks safer than "grant, maybe revoke" but flips the ambiguous cases — where real payers sit — from fail-open to fail-closed.
-
IPv6 /64 not normalised — the free tier and rate limit were optional for any IPv6 caller. Both counters keyed on the full client address. IPv6's smallest routed allocation is a /64, which any VPS or home line controls in full, so a caller binding a new source address per request presented up to 2^64 identities — each a fresh 10-call tier and a fresh 60/minute window — without forging anything.
clientKey(src/clientKey.ts) now collapses IPv6 to its /64 while leaving IPv4 exact, which makes the two families behave alike rather than making IPv6 stricter: an IPv4 household behind NAT has always shared one bucket. Normalised once inclientIpOf, so the free tier, the throttle and the ledger cannot drift into disagreeing about who a client is. Proven end to end, not argued: with the tier set to 2, four calls from four different addresses inside one /64 went free, free, free-degraded, free-degraded — the counter depleting across the rotation — while a fifth call from a different /64 got its own tier. One-time migration effect, checked rather than assumed:free-tier.jsonkeys are a salted hash of the client identifier, so counts written before the change key on the full address and stopped matching at the boundary. Any IPv6 client mid-window got one fresh tier. That is the over-granting direction, which isfreeTier.ts's stated safe failure, and the orphaned entries are not a leak:initFreeTiercallsprune()on every boot and drops anything past the 30-day window, so they clear themselves. Recorded so a later reader does not mistake the one-off for abuse. Ledger semantics changed with it:clientis hashed from the same key, so unique/external client counts now treat a /64 as one client. Deliberate — a number answering "has a stranger used this" must not be inflatable by one stranger — and the accountant has been told rather than left to notice. -
Metadata negative-cache poisoning (per-result cache TTL).
getTokenMeta,getContractName, andgetTokenSupplyreturnednullon any read failure and cached it FOREVER, so one transient RPC blip rendered a token's amounts at the 18-decimal fallback (10^n wrong for stablecoins) for the whole process life.TtlCache.getOrLoadnow accepts a per-result TTL; anullis cached for onlyNEGATIVE_TTL(10 min) and self-heals, while a real result is still kept long. The same mechanism has since closed theverification.ts/firstTime.ts/drainers.tsnegatives (below). -
Token-symbol impersonation (canonical cross-check). A contract that self-reports a valid ticker (e.g. "USDC") from an address that is not that token's canonical one is shown as its address, not the ticker, and carries a distinct
impersonated_tokenrisk flag (separate fromnonstandard_token_symbol, which is a merely non-standard symbol — the first is active deception, the second could be an honest quirk). Bounded mitigation, not a closed class: the catch is only as wide as the token label table (src/labels.ts). As of the label expansion it covers 19 tickers (USDC, USDT, EURC, USDS, sUSDS, GHO, DAI, the ETH/BTC LSTs, etc.), up from 8 — a fake "USDT" now resolves to impersonation, but a ticker still absent from the table sails through until its canonical address is added. Every address was published by the issuer and read back on-chain before being trusted (scripts/verify-labels.tsrejected 14 of 57 candidates); that bar is what makes widening the table safe, since a wrong entry would actively vouch for an impostor. Expanding the table widens this mitigation and improves decode quality at once — the table is the product's closest thing to a compounding asset. Unknown symbols are a deliberate allow (most tokens are legitimate and unlabeled). -
Token-metadata prompt injection (
cbe62d6). Hostile token symbols/names and third-party event/function names reachedsummary. Now:sanitizeSymbolhardened (NFKC, strips control/format/line-separator/mark chars incl. U+2028/U+2029, drops emoji, allowlist + cap) and applied to the 4byte and Sourcify name paths;displaySymbolvalidates the raw symbol as a short ASCII ticker and shows the contract address otherwise (+ a factualnonstandard_token_symbolflag); aprovenanceblock marks untrusted fields. Real fixture: token0x5c371cc9121a71c974091e0eb07d05d02a6915a9("BUY FLASH USDT"with emoji). -
approve()returnedrisk_flags: [](3a13cde). Verification and first-time checks keyed onto(the token), skipping the spender. Now resolved per action (spender/operator, additively withto), spender named. -
unlimited_approvalevadable at 2^128 − 1 (cbe62d6). Now compares the allowance against the tokentotalSupply(cached), 2^128 as fallback. -
XFF free-tier spoof / batched
tools/call(51b7f3d, pre-audit).trust proxy: 1+Fly-Client-IP; multi-call batches rejected. -
Free-tier reset by every deploy (
86d7f3f). The free-call counter lived only in memory while the ledger persisted, so every restart handed every client a fresh 10 calls — at the multi-session deploy frequency no real user reached the paywall, making revenue structurally impossible. Counts now persist to the/datavolume (salted-hash keys, 30-day window anchored to first call, atomic write, degrades to in-memory on file error — over-granting, never wrongly charging). Verified live:/data/free-tier.jsoninitializes on boot. (Minor residual: the key salt is a constant prefix, so file keys are IP-reversible the same way the loggedipTagis — same class as the ipTag item in Open #6, not new.) -
One signed authorization could buy an unbounded number of decodes (
src/authOnce.ts). An EIP-3009 nonce is not consumed until SETTLE, and our path is verify → decode → settle. N concurrent requests carrying ONE authorization therefore all pass verify legitimately — the facilitator's simulation is a question about present chain state, and the honest answer to all N is yes — so each ran a full decode while exactly one settled. The revenue loss (N × $0.02) was never the point: each decode spends real upstream RPC calls, so one payment bought an unbounded multiple of our most expensive resource. Cost amplification, not a revenue bug, and only we could close it, because nothing is wrong on-chain until settle. Now single-flighted on(payer, nonce): every caller of one authorization gets the SAME decode, computed once. The first call also BINDS the authorization to its transaction hash, and a later call on that authorization asking about a different hash is refused rather than answered — without that, a burst of one authorization plus N different hashes would have collapsed to one decode and returned it to callers who asked about other transactions, which is a silently wrong authoritative answer and worse than the cost bug being fixed. Caught in review by platform's question about key composition, before deploy. It DEDUPES rather than rejects on purpose — a client whose response was lost retries with the same authorization, and rejecting that after the first settled would take the money and withhold the decode, which is the one error this service must never make. Errors are not retained (a failed decode does not settle, so its authorization is still unspent). Fails open: an unreadable payload disables dedup rather than withholding work. Residual, by design: per-process, so multiple instances divide the amplification by instance count instead of eliminating it — the unbounded case is gone either way. NOT demonstrated against production, per the rules of engagement; code-level argument plustest/auth-once.test.ts. -
A reverted transaction reported a phantom ETH movement (
b1baeac), and an ERC-1155 batch truncated at 60 while claiming completeness (ceaabec). Both madeassets_movedassert something false — the first contradictedstatus, thetransaction_revertedflag and the summary's own "no assets moved" inside one response. -
Security-signal negative-cache poisoning (
2d446d2,4ca22dc,ea44a8e).verification.tscached a transient'unknown'for a DAY keyed on address only, so one Sourcify blip suppressedunverified_contractfor that contract for 24h across every client;firstTime.tsdid the same with anullverdict. Live-exercised on 2026-08-20 by a 16-minute Blockscout outage that poisoned keys long after recovery. Both now useNEGATIVE_TTL.drainers.tsseparately retried a failed blacklist load only after the full 12-hour refresh interval, leaving a fail-open check dark; it now retries in 60s, and concurrent cold-start callers all await the in-flight load instead of answering from an empty set. -
A facilitator outage was a total outage (
5d3c0bf).app.listen()was gated behindinitPayments(), so a PayAI blip at boot crash-looped the entire service — including the free tier and/healthz, which have nothing to do with payments. The port now binds first and payments initialize in the background with exponential backoff, registering the paid routes when they come up. -
fly deployshipped the WORKING DIRECTORY, not HEAD (e451b5e, platform). With 3–4 sessions in one clone, a tree that COMPILES while carrying someone else's uncommitted changes shipped them silently — no commit, no review, and a deployed artifact corresponding to no inspectable commit. That voids the guarantee the whole review process rests on. Now gated byscripts/predeploy.sh. -
UNVERIFIED Stripe signing secret — now VERIFIED, and the scope of what that proves is deliberately narrow. Open for ~36 hours on testimony alone: the malformed entry whose NAME was a secret had been deleted, but deleting a COPY says nothing about the secret itself, and no delivery had ever exercised it (
never_exercised,verified_count: 0). Closed 2026-08-23 by a real signed delivery:verified_count: 1,last_verified_at 07:05:17Z,bad_signature_count: 0. The value in Fly is cryptographically the one Stripe signs with. Verified against/statsdirectly rather than accepted from a relayed report — the hop where a qualifier goes missing is exactly the hop this finding was about. Platform encoded the limit into the instrument rather than leaving it in prose: the status issecret_verified, NOThealthy, so "the signature path works" cannot be compressed into "the card rail works" by anyone downstream. The money half has never executed at all — zero livecheckout.session.completedhandled by this server, ever — and is Open #4.
- Residual spoofed-evidence: swap events. The main forged-event vectors are
closed (see Fixed: emitter-gating) and token-symbol impersonation is now a
bounded mitigation (also Fixed). What remains, lower-severity:
univ3_swapetc. are still trusted on the event alone — the emitter is the pool, which is not labeled, so emitter-gating does not apply. A bare forged Swap event mislabels asswap("executed a token swap"); with the token-identity fix, forged token legs show as addresses. A swap is not a custody-safety claim, so this ranks below the closed cases. Candidate fix: require corroborating fungible movements before trusting a swap event. - A paid request in flight during a deploy loses the payer's money. The loss
is now VISIBLE but still NOT RECOVERABLE.
Scope moved twice on 2026-08-21; both moves are stated rather than silently
edited, so a later reader can see what changed.
As first found: a $9 pass whose payment settled during a restart was
destroyed silently.
activatePass(nonce)resolves throughpendingByNonce, an in-memory Map, andPassEntrycarried no nonce, sopasses.jsoncould not express the mapping;initPassesthen dropped every non-active entry at boot. Measured after a restart:usePassreturnedinvalid(the token was GONE, not merely inactive),listUnconfirmed()was empty, nothing logged it. Fixed the same night (visibility half only): the nonce is persisted, stranded entries are retained and marked instead of deleted,usePassnow answersnot_activated,listUnconfirmed()returns the entry WITH its nonce, and boot shouts the count and the action. Verified by booting the real module twice — the promisedocs/try-it.mdmakes to a 3am debugger holds. Prevention half CLOSED and VERIFIED IN PRODUCTION 2026-08-22. Graceful shutdown shipped (9a95afc,2b0cc74,537ea7d): both signals handled,kill_timeoutraised to 30s with the drain grace derived from it, idle keep-alives released so ordinary deploys stay fast, andpredeploy.shgates both the mirror agreement and a 10s floor. The running artifact was confirmed byfly ssh, not by the release log. Its first real exercise is in the production log with the old behaviour visible above it for contrast:2026-08-21T22:36:11Z—Sending signal SIGINT→Main child exited with signal(killed outright, no handler; this is the bug)2026-08-22T04:10:31Z—Sending signal SIGINT→[shutdown] SIGINT: refusing new connections, draining in-flight requests→[shutdown] every in-flight request finished; exiting cleanly(no exited-with-signal line), and the whole drain inside one second, so deploys did not slow down. What is still open, and it is the part that costs money: a stranded pass still cannot be activated. Shutdown covers an ORDERLY stop only — an unexpected death (OOM on the 256 MB box, host failure) still strands whatever was in flight, and nothing here helps with that. The settlement hook died with the process, so nothing re-applies it. The payer's funds may have moved on chain with no pass issued, and recovery is currently a human comparing the payout wallet againstlistUnconfirmed()by hand. UntilauthorizationState(payer, nonce)reconciliation exists there is no automatic path back. The window is between mint (in the handler, post-verify) andactivatePass(inonAfterSettlement). CORRECTION, verified 2026-08-21 after platform checked the shutdown path: this is not a narrow race, it is a certainty for anything in flight. There is NO graceful shutdown anywhere in the service — noSIGINTorSIGTERMhandler exists (the onlyserver.close()is per-request MCP transport cleanup insideres.on('close')), andfly.tomlsets neitherkill_signalnorkill_timeout. Fly sends SIGINT; Node with no listener exits immediately. So a deploy does not merely RISK interrupting an in-flight purchase — it kills every in-flight request instantly, with no drain. Every deploy tonight, and there were many, would have destroyed any request sitting between mint and settle. And it is wider than the pass rail. The same mechanism hits the $0.02 per-call path, where settle also runs after the handler: a process death between broadcast and response means the payer's money moved and they received no decode. Smaller per event, but that is the rail our first interested customer has said they want, so it is the one most likely to be exercised first. If the process dies after the facilitator broadcast but before the hook, the transfer still completes on chain: the payout wallet receives $9 that our ledger never recorded and no pass exists for. That is worse than money-taken-no-service, because it is also money-taken-no-RECORD — the reconciler sees a wallet receipt it cannot attribute, and the customer holds a token string that answersinvalid. Fix, in order of what matters: (a) stop dropping silently — retain pending entries at boot, log loudly, and surface them inlistUnconfirmed()so a lost pass is discoverable at all; (b) persist the nonce onPassEntryso a restored pending pass can still be identified; (c) resolve it againstauthorizationState(payer, nonce)— the same on-chain ground truth already named below as the correct close for ambiguous activation, so these converge rather than compete. Visibility is the part worth doing before the first real $9 sale; the rest can follow. Operational note until fixed: do not deploy while a purchase could be in flight — but treat that as scaffolding, not a control. Verify-then-deploy is itself a race (a purchase can begin between the check and the machine stopping), and right now our only protection against losing a paid request is two agents remembering to look. Tonight established what happens to rules that live in memory. Fix order, REVISED 2026-08-21 once the likely product changed — the original ordering was reasoned entirely from the $9 pass rail, and two independent parties (Circadian with arithmetic, kindrat86 with an integration judgement) have now said per-call suits them rather than the pass:- Graceful shutdown FIRST. Reconciliation RECOVERS a loss; shutdown PREVENTS it. Recovery is worth a human's time at $9 and is worth nobody's at $0.02 — no one reconciles two cents by hand, so on the per-call rail an unrecoverable loss is simply a loss. And the cost there is not the money: an agent integrating this that intermittently pays and receives nothing reads it as an unreliable rail and leaves. Fifteen lines, and it protects the path a real customer has asked for.
authorizationState(payer, nonce)reconciliation second, still essential — it is the only thing that makes an already-stranded $9 pass recoverable, and shutdown does nothing for a machine that dies unexpectedly rather than being asked to stop.- Retire verify-then-deploy third, once the first two have engineered away its purpose rather than leaving a habit behind. (Original ordering put reconciliation first. Recorded rather than overwritten: it was correct for a pass-led product and stopped being correct when the evidence moved.)
- Ambiguous pass activation (residual of the $9-pass fix in §4). A pass activated
because settlement was ambiguous rather than confirmed could, in principle,
turn out to be unpaid — bounded at $9 of calls. Deliberately NOT capped: this
path produced two serious bugs in one evening precisely because it accumulated
conditional rules, and
listUnconfirmed()makes every such pass queryable. The correct close is a reconciler againstauthorizationState(payer, nonce)(the same call verify uses) plus the payout wallet, not a cap that papers over it. - NO $9 PASS HAS EVER BEEN DELIVERED, ON ANY RAIL. Found by platform
2026-08-23 while checking whether we can deliver what we are selling.
Lifetime settlements: 1, and it is the $0.02 Circadian probe. The two
active_passeswere minted in testing (payer: null, no settlement behind them). So the offer being actively sold has never completed end to end for anybody. The three purchase paths are separate code and none is interchangeable evidence for another: MCPbuy_pass(index.ts:441) mints PENDING and depends on the settlement hook — that is the stranded-pass path, Open #2; RESTPOST /pass(index.ts:1628) mints ACTIVE and is structurally safer, verified three layers deep (the@x402/expressmiddleware discards the buffered body on!settleResult.successso the token never reaches a non-payer;rest.ts:216revokes on any non-2xx; and it hooksres.on('close')so a dropped connection revokes too); Stripe card is a third path whose money half has NEVER EXECUTED — not "untested" in the sense of not yet got round to, but zero livecheckout.session.completedhandled by this server, ever, on any purchase. This repo has already been bitten by exactly that distinction. Theextra._metavsctx.metabug sat in the pass-purchase path, passedtsc, passed review, and would have failed 100% of real sales — because no real sale had ever run it. A reviewed path that has never executed is not a tested path: its first execution IS the test, and that first execution is currently scheduled to be a stranger's $9. Send a $9 buyer to RESTPOST /pass. The close is a real purchase through the path being sold — and afterwards the honest claim is "REST pass delivery is proven", never "the pass rail works". - Test files are never typechecked, so a signature change silently leaves
stale callers.
tsconfig.jsonincludes onlysrc/**/*.ts;npm run typechecktherefore passes while a test calls a function with the wrong shape. Found when adding a required field tobuildAssetsMovedproduced no error intest/assets.test.ts— the missing field becameundefined, read as falsy, and the test kept passing while asserting the wrong behaviour. Given that both of the serious bugs on 2026-08-20 shipped with a green suite, a test that is quietly testing the wrong thing is worth closing. Fix: atsconfig.test.jsonextending the base withrootDirat the project root andincludecoveringsrcandtest, wired to atypecheck:testscript. Not done here: it touches shared build config and would surface errors across test files owned by several sessions, so it wants its own change rather than riding along with a bug fix. - Info/ops:
/healthzpublishes lifetime revenue + funnel unauthenticated; payer EIP-3009 signature written to logs on facilitator error;ipTagis a reversible 32-bit hash; unbounded usage-ledger Sets on a 256 MB box.
Fly-Client-IPspoofing — Fly overwrites it;ON_FLYgates onFLY_APP_NAME, which Fly always sets.- Refund abuse for free successful decodes —
refundFreeCallfires only when the call returnedisError; you cannot get a real decode and a refund. - Facilitator outage during verify — verify precedes the handler, so it fails closed (no free decode). The settle-after window leaked until the authorization single-flight closed it (§4).
- Free decodes from an unfunded wallet — does not exist.
verifyin@x402/evmis NOT signature-only: it runssimulateEip3009TransferResultagainst present chain state, so an authorization from a zero-balance wallet (or one whose nonce is already consumed) fails verify and never reaches the handler. This is load-bearing for how the paid path is reasoned about: the settle-after window leaks only what a payer who CAN pay is able to replay, not unlimited free service. Note the asymmetry — settle defaults tosimulateInSettle: false, so it re-checks signature/expiry/value but does not re-simulate before broadcasting; an on-chain revert after broadcast is therefore still possible and is whyprovablyUnpaidrequires an absent tx hash. - Non-string
tx_hashcrash — the MCP SDK's zod layer rejects it (-32602) beforerunExplain. Confirmed live. - Malformed-JSON stack leak — Express 5 under
NODE_ENV=productionreturns a genericBad Request, no stack. Confirmed live. - Prototype pollution via body —
JSON.parsemakes__proto__an own property; not merged into any prototype in the MCP/x402 spread path. - JSON-envelope breakout in
summary—JSON.stringifyescapes quotes; the only injection is within the string value (that is the token-name vector, Fixed). - Script injection (XSS) via decoded output in the site playground — the
playground renders results with
innerHTML, but every value goes throughesc()(escapes& < >) inrenderVal/curlBlock, and thetx_hashcomes from a text input (not a URL param), also escaped. Belt-and-suspenders with the source-levelsanitizeSymbolstrip of<>"'. Re-check if any site page ever renders a decoded field withoutesc(). - Secrets in git / scripts / dashboard —
.env,.stats-token,/data/gitignored; history clean;scripts/*make only unauthenticated GETs and log no keys; the dashboard embeds no token.X402_PAY_TOinfly.tomlis public by design in x402. - Caret dep ranges —
package-lock.json(v3) is committed and the Dockerfile usesnpm ci, so builds are pinned.
Re-run an adversarial pass, or at minimum read the diff with this file open, when a change touches any of:
-
Payment path —
initPayments, the x402 wrapper, settlement hooks,charge/hasPaymentlogic, facilitator config. (Do not reorder facilitators: keyless must stay first — the server does not fail over on rejection and CDP-first rejects real payments. Do not remove@x402/svm: imported unconditionally, its absence kills the process at module load.) -
Client identification / free tier —
clientIpOf,freeTier.ts, the rate limiter, anything keyed on the client. -
New attacker-controlled data into output — any new field sourced from on-chain data or a third-party API that reaches
summary/assets_moved/risk_flags/counterparties. Add it toprovenance.untrusted_fields. -
Any new revenue rail, or any change to what counts as a settled payment. Check it against the invariant below before it ships, and tell the accountant session, whose books are only as honest as that definition.
REVENUE IS BOOKED ONLY FROM A CONFIRMED, LIVE, SETTLED PAYMENT, AND EVERY SURFACE THAT DISPLAYS MONEY MUST DISTINGUISH ATTEMPTED FROM RECEIVED.
This is written as an invariant because it has now been violated three times on three rails in about twelve hours, each time as a fresh bug rather than a recurrence: x402 booked revenue on an unconfirmed settle;
paid_callscounted payment attempts and sat next torevenue_usdon a public endpoint, where two people who knew the system misread it as revenue and one nearly reported a first sale that had not happened; and the Stripe rail booked a real $9 row for a test-mode purchase. Each instance was fixed on its own. The pattern is that a new rail arrives without the rule, so state the rule rather than fixing a fourth instance. Note the reverse direction is deliberate and must stay: a pass activated on ambiguous settlement (listUnconfirmed()) is service delivered without confirmed payment, on purpose, because withholding from someone whose money probably moved is the worse error. -
Any new request logging, access logging, or error reporting that includes a URL. Pass tokens are carried in the MCP URL path (
/mcp/btxp_<48hex>), a deliberate and documented weakening justified only because the worst case is metered calls. Today nothing logs a request path, so no token reaches our logs — that is an ABSENCE, not a control. Any code that logs a path must route it throughredactPassPath(src/passUrl.ts), or every pass URL becomes a plaintext credential sitting in the log store for as long as logs are kept. Unverified and worth one check: whether Fly's edge logs request paths independently of the app, which no redaction of ours can reach. -
A new upstream dependency or a change to how an existing one's response is parsed.
-
src/labels.tsadditions are trust assertions, not just display. A labeled address makesgetLabel()truthy, which (a) suppressesunverified_contractandfirst_time_counterpartyagainst it inrisk/flags.ts, and (b) makes it the canonical address for its ticker in the impersonation guard. A wrong entry actively vouches for a scam. Add an address only when it is verified from the issuer's official source AND cross-checked on-chain (symbol + decimals). -
src/index.ts(shared, high-traffic) — announce to the other sessions before editing; read the whole request path after.
Three sessions ship to this repo daily and nobody else reviews for security: a trust-boundary change can arrive inside a commit whose message is about copy. Watch the diffs, not just the commit subjects.
Ads, tools and anything bought with a card are proposed approvals, not autonomous spend. An agent prepares the case, names the cost, links the exact page, explains what it buys — and then stops. The founder enters the card details himself.
An agent's work ends at the payment form. No agent drives a browser through a checkout on his behalf, even with his approval, and even when he has said yes to the purchase. Approval to buy a thing is not approval to have card details typed by a process that also reads attacker-authored text off a public blockchain.
Worth stating plainly so nobody treats this as a preference that could be traded away under time pressure: entering card numbers, bank details or credentials into a form is something these agents will refuse to do regardless of instruction. It is a constraint they operate under, not a team agreement. Any design that depends on an agent completing a checkout is not merely unwise, it will not run.
This needs no key, no wallet, no on-chain scoping, and no signer. It is a workflow. The security surface is the proposal record, not a credential — see the reconciliation posture in the ledger, which becomes the ONLY evidence a card payment was justified, since a card spend leaves no on-chain trace at all.
RECEIVE wallet — server side confirmed hardened, nothing to build. EOA, nonce 0. No private key for it exists in the codebase or environment; the exact scheme pins the destination so payments can only push toward it; there is no automated outbound path. The only server-side deliverable is detection (below).
CORRECTION 2026-08-23: "the residual is founder key custody, which is not an engineering problem" was true and incomplete. It is not an engineering problem, but it is a SPECIFIABLE one, and the rotation of the original payout wallet (retired because it had carried unrelated personal activity) showed what the specification is:
- A receive key must never sign anything. Receiving needs no connection and no signature, so single-purpose costs nothing. This is structural rather than maintenance: a key that never connects to a dApp never grants an approval, so there is never a stray approval to hunt down later. "Revoke approvals periodically" manages a problem the key should not have.
- Why a used key is a different category, not the same risk in a different app. An unused key's exposure is key custody alone. A key used for general activity is key custody PLUS the union of every contract it has ever approved — approvals do not expire, and a contract benign today can be upgraded behind a proxy tomorrow while the approval stays live. The wallet software (MetaMask vs a general-purpose app) is close to irrelevant next to that.
- The USDC-specific reason this matters more than generic wallet hygiene.
This key holds USDC, which supports
transferWithAuthorization: the holder signs off chain, anyone submits. A key phished into signing one has its USDC moved WITHOUT EVER SENDING A TRANSACTION — nonce stays 0, an explorer's Transactions tab shows nothing, and only the balance and the Transfer log reveal it. That is the nonce blind spot arriving from the opposite direction to the drain case below, and it is a stronger argument for "never sign on a website" than the approval argument, because an approval at least leaves an on-chain event someone might notice. - Retiring a payout address is not a config change, and the old one is not
closed when the config flips.
X402_PAY_TOis published inside theacceptsblock of every 402 challenge we serve, and discovery indexers cache it. After a rotation, payments already in flight and any client or registry holding cached requirements still pay the OLD address. So the old wallet stays live until deposits actually stop — watch both balances through the transition. The danger is not the retired wallet's history; it is a stray deposit arriving later into an address that still carries approvals. Drain it, keep watching it, and never let it be a destination again.
SPEND wallet 0x2E31f337… — the real exposure. EOA, nonce 0, and the
balance IS the blast radius.
Settled decisions:
- Balance is the PRIMARY control, not a supplement. Every software limit can be wrong about itself; none can spend money that is not there. Fund to roughly one period's expected spend, top up deliberately. The balance is now a security boundary, not just working capital.
- Zero ETH is NOT a control. It looks like one — no gas, so the wallet cannot move. But EIP-3009 lets any third party submit a signed authorization and pay the gas themselves, which is exactly why both our wallets show nonce 0 while holding funds. Zero ETH shapes a theft; it does not prevent one. Do not record it as a protection. This is proven here, not inferred: 0.02 USDC demonstrably LEFT the SPEND wallet while its nonce is still 0. Money has moved in both directions across two wallets that have never sent a transaction — and that mechanism is the one this product exists to explain, which makes writing zero ETH down as protection a particularly bad look.
- HARD RULE — no closed loop. A spending agent must NEVER consume this
product's own
explain_transactionoutput as decision input. We ingest attacker-controlled strings off a public chain (forged events, hostile token names — "BUY FLASH USDT" is the live example), so that wiring runs attacker-controlled data straight into our own spend decision, in one hop, with no key theft required. This outranks every mechanism below it. - Separate the signer from the parser. Whatever holds signing capability must not be the process that ingests untrusted chain data.
scripts/paid-call.tsis a working signer already in the repo. It readsX402_TEST_PRIVATE_KEYand signs with viem. It is a developer script, the variable is unset, and nothing the server runs imports it — so it is not a live exposure. It is a footgun: it is the nearest thing to reach for if someone builds autonomous spend under time pressure, and copying it yields exactly the design this section exists to prevent — a raw private key in an environment variable, in-process with everything else. If you are here because you are implementing spend, do not start from that file.- Detection: balance and ERC-20 Transfer events, NEVER transaction count. Transaction-based monitoring is blind on this rail by construction — a drain via a signed authorization leaves the wallet's nonce at 0 and puts no transaction "from" it. Alert on any SPEND outflow without a matching logged expense, and on ANY RECEIVE outflow at all.
- The honest framing, for anyone asked to promise more: compromise cannot be made impossible. The maximum loss is a number we choose — the balance — and any loss should be visible in minutes. Nobody should sign their name to "completely secure."
RESOLVED 2026-08-21: ads and tools are CARD (§9a). For x402, the founder's answer was conditional: "if any agent finds an x402 service that requires company spend to grow, then yeah."
Read that precisely, because the design follows from the grammar. It is an IF/THEN, not a present requirement. No third-party x402 service has been named. So:
- TODAY: pin
toto our own payTo. There is nothing else to pay, so pinning costs exactly nothing and buys near-total mitigation — a fully compromised agent can do nothing but pay us, which is a no-op. Build this. Do not build an unconstrained signer against a service nobody has identified. - TRIGGER TO EXPAND: an agent identifies a SPECIFIC x402 service that requires spend to grow. That named payee is then approved and added. Caps and balance apply as the backstop.
Checked, not merely unnamed (2026-08-21). Growth looked and found no x402 vendor worth paying: the ones with verified evidence are either free and keyless (GoPlus, Blockscout — both verified responding live) or direct competitors selling into our buyer, where paying them funds a competitor's traffic. No vendor was identified that would materially improve our own product. That is a real negative result rather than an absence of effort, so the pinned default is the ANSWER, not a placeholder awaiting research. Re-open only on a specific vendor with a price and a reason, not on a hunch that one must exist.
Forward note, since those free APIs were surfaced as candidates: free does not
mean safe to consume. Wiring a third-party token-security verdict (GoPlus or
similar) into risk_flags would make our safety signal depend on their
correctness and availability, and would add another attacker-influenceable input
to output that other agents act on. That is a §7 re-review trigger (new upstream
dependency AND new attacker-controlled data reaching output), not a drop-in.
His answer IS the allowlist, arrived at from the product side rather than imposed as a security constraint — "an agent finds a service, then we pay it" describes a payee being named before it is paid. Nobody had to narrow his scope; the scope was already shaped that way. Worth noting because it also means the allowlist-versus-caps question does not need re-asking: caps remain the backstop, the allowlist is simply how his conditional gets exercised.
One approval model across both rails, which is worth keeping deliberately: an agent proposes, a human approves, then it executes. On card the human enters the details (§9a); on x402 the human approves the payee once and it is then automatic within caps. Same shape, simpler to reason about and to build than two models.
The risk that is specific to this answer, and it is the crux. The payee is chosen by the agent, and the agent's inputs are attacker-reachable. A manipulated agent paying an ATTACKER'S x402 endpoint is indistinguishable from normal operation at every protocol level: well-formed transaction, valid signature, legitimate-looking service, correct amount. There is no anomaly to detect. So prevention cannot rest on spotting a "bad" payment, and detection cannot either — it must be RECONCILIATION: every outflow matched to a pre-justified, logged expense, alerting on anything unmatched rather than anything suspicious.
CONTROLS BEFORE CAPABILITY — the sequencing rule, and the most important line
here. As of today the $20 is NOT at risk: no key that can move it exists on the
server or within any agent's reach (Fly holds only STATS_TOKEN, the CDP keys,
STRIPE_WEBHOOK_SECRET, INTERNAL_MARKER; X402_TEST_PRIVATE_KEY appears only in
scripts/paid-call.ts and is unset). The balance becomes the blast radius at the
moment a signer is wired, not before. So: do not put a spending key anywhere an
agent can reach until the caps below are enforced. If a signer must be wired
first, draw the wallet down first instead.
Mechanism, given an unconstrained destination:
- Caps are now the control, because
tocannot be. Scoped signer (smart account + session key; viable here via ERC-1271/6492) enforcing USDC-only, a per-transaction cap, and a per-period cap — on-chain, so they hold when the agent does not. Sizing: typical x402 calls run $0.02–$9, so a ~$1 per-tx and ~$5 per-day ceiling keeps a fully-owned agent's daily damage small against a $20 balance. - Balance stays the primary control and is now 4x what it was ($4.98 → $20.00, funded in one transfer on 2026-08-21). Whatever limit gets built must hold against the WHOLE balance being at risk from day one, not a token amount.
- A destination allowlist is worth proposing even though "anything" is in scope. "Anything endpoint" as a PRODUCT goal does not require "anything endpoint" as a SIGNING permission. The set of x402 services actually worth paying is small; let the agent discover and propose, have a human approve a new payee once, then automate. That converts the strongest attack — pay the attacker — from an undetectable success into a blocked transaction, and it is dramatically stronger than caps alone. Worth putting to the founder as a product question, not deciding unilaterally on security grounds.
fly deploy from the repo root — and note it builds the WORKING DIRECTORY, not
HEAD, so verify git status --porcelain -- src package.json tsconfig.json package-lock.json is empty first or you may ship another session's uncommitted
work. scripts/predeploy.sh now enforces this; do not bypass it. Also confirm
your commit is not already live
(git merge-base --is-ancestor <commit> HEAD) before restarting a service real
users are mid-session on. After: confirm /healthz still shows the
lifetime ledger (it survives restarts via the /data volume) and that the 402
body still carries accepts + the bazaar extension (discovery crawlers read
it). A broken deploy is visible to real outside users now.