Skip to content

Seller pricing from the live 402, a per-payer spend ceiling, a higher routing tier, and a wallet-trend alarm - #715

Merged
MikeyPetrillo merged 10 commits into
mainfrom
claude/sweet-brown-i99jl3
Aug 7, 2026
Merged

Seller pricing from the live 402, a per-payer spend ceiling, a higher routing tier, and a wallet-trend alarm#715
MikeyPetrillo merged 10 commits into
mainfrom
claude/sweet-brown-i99jl3

Conversation

@MikeyPetrillo

Copy link
Copy Markdown
Owner

Started from a seller's report that their 39 listed endpoints all showed price:null. Confirmed independently before changing anything, and it turned out not to be about them.

Live-402 pricing. A manifest may list resources as bare URL strings (permitted, and theirs does), so our reader found no price and defaulted the method to GET — while their routes are POST-only. Worse, probePaywall filtered on price > 0 before probing, so a priceless route was never probed and probing is the only thing that could price it. Sampling the 69k-row index showed roughly a third of rows priceless. The crawler now reads price and networks from the live challenge, tries GET then POST {}, never probes PUT/PATCH/DELETE, and leaves an unpriceable asset as null rather than $0 — publishing a paid tool as free is the one wrong answer a buyer acts on.

Per-payer spend ceiling. Settlement runs after the handler, and the external routing handler pays a third-party seller from our wallet. A payment that verifies and then fails to settle leaves us out the spend with the buyer charged nothing; self-dealt, every drained dollar returns to the attacker. Spend is now recorded before the buy and resolved on the FINAL response, never on handler success.

Higher tier. route-execute-pro covers underlying ≤$3.00 at $3.30 (same 10% spread as max), because the $0.50 ceiling made the premium half of the index unroutable.

Wallet trend alarm. That spending wallet is self-funding and every tier is net-positive, so it should only rise. A fall means a withdrawal, a spend whose revenue never arrived, or something we don't understand — all worth waking up for. Tolerates the dip that settlement ordering guarantees.

Self-review found four defects in my own new code: no global probe budget (issue #645 rebuilt), exposure cleared on a throw that can happen post-broadcast, a dead import, and a redaction that walked past a JWT. The redaction fixture then failed our own gitleaks history scan, which is that rule working; the fixture is shape-only now and the commit was rewritten out rather than allowlisted.

MikeyPetrillo and others added 10 commits August 7, 2026 10:56
The canary gate + settlement alarm, the facilitator diagnostics and labels, and
the redis CI coverage. Each entry leads with the defect it exists to prevent,
because the pattern behind all three was a guarantee that was described but
could not fail when violated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…logue has none

A seller listed 39 endpoints and every row indexed as price:null, priceUsd:0,
payable:"unknown" - while each endpoint returns a textbook x402 v2 challenge
(eip155:8453, Base USDC, amount 990000 = $0.99, real payTo) the moment you POST
{} at it. Confirmed independently before touching anything: their manifest,
their live 402, and our index rows.

Two causes, both ours, and neither specific to them:

1. A manifest may list `resources` as bare URL STRINGS - theirs does, and the
   shape is permitted. normaliseManifestTools reads a price only from an
   OBJECT, so a string-listing seller is permanently priceless however well
   their endpoints behave. It also leaves method defaulting to GET, and their
   routes 404 on GET and 402 on POST, so a GET-only probe sees a dead
   catalogue.

2. probePaywall - the only thing that talks to a seller's endpoint - filters on
   `Number(t.price) > 0`. A priceless route is never probed, and probing is the
   only thing that would give it a price. Circular by construction: the sellers
   who most need the probe are precisely the ones excluded from it.

Not one seller's problem. Measured across the index the same day: 146 of 500
sellers had ZERO priced rows and 127 rows read payable:"unknown".

OpenAPI cannot close this - it has no place for an x402 quote - so the 402
itself is the only source of truth, which is why the router already reads a
live 402 for payTo before spending. This reads the same challenge for price and
networks, and mirrors bazaarItemToTool's preference order (Base USDC, then any
USDC, then first) so a live-probed row and a Bazaar row stay comparable.

Conservative about money in three ways. An asset we cannot price leaves price
NULL and still records the networks, so the row reads payable-on-Base rather
than a guessed figure - $0 would publish a paid tool as free, the one wrong
answer a buyer acts on immediately. PUT/PATCH/DELETE are never probed, so an
unpaid probe cannot mutate a stranger's server. And it only ever ADDS: a priced
row is skipped and a failed probe leaves the row untouched.

Gentle on sellers, per the #645 lesson: at most 3 priceless routes per seller
per crawl, per-ROUTE backoff through probeDue, and a route that gets priced is
never a candidate again.

Verified against the reporting seller's live endpoint through the real code
path - assertPublicUrl and the SSRF dispatcher included - GET 404 skipped, POST
402 read, $0.99 and their payTo learned. 28 offline assertions on fixtures
captured from that same response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… routing ceiling

Two things, and the order matters: the guard is what makes the price rise safe.

THE HOLE. @x402/express runs the handler FIRST and settles AFTER, and the
external routing handler pays a third-party seller from our spending wallet.
So: buyer's payment verifies, we pay the seller real USDC, our settlement
fails, buyer is charged nothing, we are out the spend. Self-dealt - one wallet
listing the seller and buying from it - every drained dollar lands back in the
attacker's pocket, bounded per call only by the tier cap.

Verify-then-fail-to-settle is not theoretical; it happens naturally when a
payer's balance drops between the two, which is documented on Solana where our
own best buyer drained to $0 and its last four purchases "timed out".

What already existed bounds WHAT we pay and none of it bounds WHETHER WE GET
PAID: the canonical-USDC asset pin (a decoy in another token cannot be signed),
the tier cap re-checked against the live 402 rather than the seller's
advertised price, and the 50-settlement/3-payer reliability floor. All of them
run before a spend that settlement has not yet blessed.

So a payer now carries a DEBT CEILING of unsettled upstream spend. Recorded
before the buy, resolved on the FINAL response - res.on("finish") with
statusCode 200, after settlement - never on handler success, which is precisely
the state that precedes a settlement failure. An unsettled spend keeps counting
until it ages out; a settled one clears instantly. It only ever bites a wallet
whose payments are failing.

Not a reputation system: 25 settled calls in a row are never impeded.

THE PRICE. The $0.50 underlying ceiling made the premium half of the index
unroutable - the seller who reported the price:null bug prices their gates at
$0.99, $1.50 and $2.99, every one above the top tier, so the router could only
409 them to their own direct route. route-execute-pro covers underlying <=$3.00
at $3.30, the same 10% spread as the max tier.

Three bugs found while doing it, two of them mine:

- The first draft registered res.on("finish") inside the tool handler, where
  `res` does not exist: handlers are called as handler(input, req). It would
  have installed nothing, reported nothing, and left the guard recording spends
  that never resolved. The handle now rides the request and server.js resolves
  it centrally.
- I added a tier without adding it to SELF_FUNDING_SLUGS, so its revenue would
  have settled to the treasury while its spend came from the burner - a slow
  one-way drain. Caught by the existing test that locks that set against
  EXEC_TIERS, written after the same mistake in July.
- The ceiling and the largest tier are coupled: a ceiling below the biggest
  underlying cap makes that tier dead on arrival, refusing every payer
  including honest ones, with nothing to report it. Now asserted, not merely
  commented.

20 + 45 offline assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…all, not the smallest

Raising the routing ceiling to $3.00 quietly broke the alarm that watches the
wallet paying for it. UPSTREAM_BUYER_LOW_USD defaulted to $0.50, which was
correct when the only thing spending from that wallet was Blockscout at
$0.002/call - $0.50 covered hundreds of calls. With route-execute-pro, "ok"
means "has at least $0.50" for a wallet that cannot fund ONE call, so
/api/gateway-status would have reported ok and the heartbeat would have stayed
green right up to the failure the alarm exists to prevent.

Measured before writing this: prod reports upstreamBuyer "ok" right now, and
"ok" is a bucket, not a balance - it cannot distinguish $0.60 from $60.

Default is now two largest-tier calls, so we are paged with room to top up
rather than at the moment of starvation, and an assertion locks it to
EXEC_TIERS: a threshold that silently stops covering the biggest call reports
nothing on its own. Same coupling, and same fix, as the unsettled-spend ceiling
in the previous commit - adding a tier now fails CI in two places instead of
degrading two guards in silence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… that runs low

Mike's observation, and it holds up in the code: that wallet should never go
down. Everything that spends from it also settles INTO it - SELF_FUNDING_SLUGS
sets payTo to the burner for exactly those tools (payments.js acceptsForItem) -
and every execution tier charges more than it can spend. Worst case per call:
+$0.005, +$0.01, +$0.05, +$0.30. Blockscout is the same shape. So barring a
manual withdrawal the balance is monotonically non-decreasing.

Which makes the low-water threshold the wrong instrument. It answers "is there
enough left", and only after the money has gone. The interesting question is
"did it fall at all", because a fall means one of exactly three things: someone
withdrew, a spend's revenue never arrived (the verify-then-fail-to-settle drain
the per-payer ceiling now bounds), or something we do not understand.

A withdrawal trips it too, on purpose. The alarm's job is to say "this wallet
fell and nobody told me"; a human who withdrew closes the issue in one click,
and a silent fall is the one we must never miss.

The hard part is the dip that settlement ordering GUARANTEES: we pay the seller
during the handler and collect afterwards, so the balance is legitimately lower
in between. An alarm that cannot tell that from a drain would page on every
healthy call and be muted within a day. Hence a high-water mark, a tolerance,
and a requirement that the fall persist across consecutive 5-minute reads.

One subtlety with its own assertion: a read INSIDE tolerance must not reset the
counter. A wallet bleeding $0.40 per read sits within tolerance every single
time, so a counter that cleared on those would never fire while the wallet
quietly emptied.

Balances stay off /api/gateway-status - the trend is bucketed to "ok" /
"draining" / "unknown", never a number, same rule as the existing status.

16 offline assertions, including the false positive that matters (an in-flight
dip that recovers) and the recovery path that clears the issue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ction, and the redaction's own leak

Reviewed everything I added today instead of assuming it was clean.

GLOBAL PROBE BUDGET (the one that mattered). The live-quote enrichment was
bounded per SELLER at three routes per crawl, which sounds gentle until you
multiply: roughly a third of indexed rows carry no price, so a per-seller-only
limit fires thousands of outbound requests every five minutes across the whole
index. That is issue #645 rebuilt with a different label - the report about 686
requests to a path that 404'd every time. Per-route backoff quiets it
eventually, but "eventually" is the first several cycles, and the seller feels
those. Now bounded per CYCLE as well; the rest wait their turn.

EXPOSURE CLEARED ON A THROW. The external-spend catch cleared the payer's
exposure on the theory that a failed buy never spent. payExternal can throw
AFTER signing and broadcasting - a network error on the response, a timeout -
and clearing on those is precisely how a spend disappears from the ledger the
guard exists to keep. The exposure now stands and ages out on its own.

DEAD IMPORT. exposureSnapshot was imported into server.js and never used. It
returns payer addresses, so the load-bearing fact is that it was never wired to
a public surface; the import is gone.

REDACTION, AND ITS OWN LEAK. The facilitator diagnostic logs a body we did not
write, from a host we authenticate to, into a log aggregator - so long
hex/base64 runs are redacted, since an error page echoing a request header
would put a credential there permanently. The first version walked straight
past a JWT (segments are dot-separated and individually short), so a bearer
token would have been logged whole.

Then the fixture I wrote to prove that failed our own gitleaks history scan:
RuleID jwt. A realistic three-segment token in the repo is exactly what that
rule is for, and .gitleaks.toml is explicit that paths are never excluded
because a path exclude would hide a real credential in a test fixture. So the
fixture is now shape-only with no eyJ prefix - it exercises the same branch
without putting a token-shaped string in the repo - and the commit that carried
it was rewritten out of the branch rather than allowlisted.

Also asserted: a Cloudflare ray id must SURVIVE redaction, since it is the
thing a provider asks for.

Checked and found fine: price stays polymorphic (number from bazaar, "$x" from
manifests) but every surface reads it through parsePrice/fmtUsd.

Local sweep green: facilitator-diagnostics 35, live-quote 28, spend-guard 20,
balance-trend 16, route-execute 46, wish 54, canary-coverage 148, and the MCP
self-consistency guard at 1467 against a booted server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MikeyPetrillo
MikeyPetrillo merged commit 5497b7a into main Aug 7, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant