Skip to content

payments: a down facilitator must cost one chain, not every paid route - #651

Closed
MikeyPetrillo wants to merge 2 commits into
mainfrom
claude/sweet-brown-i99jl3
Closed

payments: a down facilitator must cost one chain, not every paid route#651
MikeyPetrillo wants to merge 2 commits into
mainfrom
claude/sweet-brown-i99jl3

Conversation

@MikeyPetrillo

Copy link
Copy Markdown
Owner

Brings main up to what production needs. Two commits.

1b7b986 — per-rail configured-vs-offered visibility

A rail whose facilitator is down is dropped from the offer so the other chains keep settling. That drop used to be silent: Celo vanished, the 402 quietly advertised 11 networks while config asked for 12, /health read ok, and the only trace was a boot log. It surfaced days later via a canary WARN.

GET /api/rails now reports, per chain, whether it is offered and why not:

{ "configured": 12, "offered": 11, "degraded": 1,
  "rails": [ { "network": "celo", "offered": false,
               "reason": "no reachable facilitator advertises exact for it" } ] }

Public deliberately — the 402 already advertises the offered set, so this adds no secret, it adds the difference, which was the invisible part.

29c7517 — a DOWN facilitator must cost one chain, not every paid route

The existing drop-don't-break guards only cover a missing url/key. A facilitator that is configured and simply down takes the opposite path: the client is added, its /supported handshake fails, the network stays in the offer with nothing able to settle it, and @x402/core refuses to BUILD the 402 — so every paid route on every chain answers 500 while /health stays 200.

Reproduced with Base healthy and Celo's facilitator always-500 (its real state on 2026-08-02):

before: POST /api/hash -> 500  RouteConfigurationError
after:  POST /api/hash -> 402  accepts: [exact eip155:8453]

Control-tested against the old code on identical inputs: 500.

The fix applies the discipline the upto gate always used and that never covered exact: verify the offer against what the facilitators we actually route through advertise, and drop what nobody can settle. Two deliberate safety properties:

  • a network is dropped only on a positive answer (some facilitator responded and did not list it). If none respond, the configured offer is left alone rather than emptied on a boot-time blip.
  • each client gets a second attempt, so a slow facilitator is not mistaken for a dead one and a transient blip cannot cost a rail.

One probe now serves both the exact and upto gates instead of two round trips.

Tests

scripts/test-rail-outage-isolation.js (new, wired into CI, 7 assertions) reproduces the outage end to end and asserts the 402 is payable, not merely a 402 — an empty accepts array would pass a status check while being just as unusable to a buyer. Mutation-checked: disabling the gate fails 5 of 7. scripts/test-rails-isolation.js pins the status shape. Full battery green: 210 strict / 296 lenient / shape clean.

Note

This lagging behind prod is itself the recurring problem: a Railway variable upsert rebuilds from main, so anything only on the dev branch is silently reverted on the next variable change. Merging is what makes the fix real.

…rail failing is visible instead of silent

Celo had dropped out of the 402 offer and nothing said so. The offer quietly
advertised 11 networks while the config asked for 12, /health read ok, and the
only trace was a boot log nobody re-reads. It surfaced days later through a
canary WARN - by which point the "12 chains" claim published to the MCP
Registry had been false the whole time.

The isolation itself was already right and is untouched: a rail whose
facilitator is missing or down is DROPPED from the offer rather than throwing
while the challenge is built, because one bad rail once became HTTP 500 on every
paid endpoint (2026-07-02). Revenue keeps flowing on the other chains. That is
the correct behaviour and the reason a dead Celo facilitator cost nothing.

What was missing is the difference between what we asked for and what we serve.
GET /api/rails now reports, per chain, whether it is offered and why not:

  { configured: 3, offered: 2, degraded: 1, rails: [
      { network: "base",  offered: true,  reason: null },
      { network: "celo",  offered: false, reason: "CELO_FACILITATOR_KEY unset" },
      { network: "monad", offered: true,  reason: null } ] }

Public deliberately. The 402 already advertises the offered set, so this
exposes no secret - it exposes the DIFFERENCE, which is the part that was
invisible. `degraded` non-zero means one chain is down, not the service, and
that distinction is the whole point.

Celo is back in PAYMENT_NETWORKS. Its facilitator is currently returning HTTP
500 on /supported, so the guard will keep it out of the offer until it
recovers, then re-admit it automatically - which is exactly the behaviour
wanted: configure the rail, let one chain's outage stay one chain's outage.

Monad is separately failing to settle ("fetch failed" from the facilitator);
that is a different defect and is not addressed here.

scripts/test-rails-isolation.js (new, wired into CI) pins the shape and two
traps: railStatus() must be safe to call before any payment mount, and a
recorded drop reason must never conjure a rail that was never configured.
…every paid route

I was wrong in the previous commit and this corrects it.

I claimed per-chain isolation already existed. It did not. The drop-don't-break
guards only cover a MISSING url/key. A facilitator that is configured and simply
DOWN takes the opposite path: the client is added, its /supported handshake
fails, and the network stays in the offer with nothing able to settle it.
@x402/core then refuses to BUILD the 402 and EVERY paid route on EVERY chain
answers 500 - while /health stays 200, so it reads healthy with all revenue
dead.

Reproduced, not reasoned about. Base healthy, Celo's facilitator always-500
(its real state on 2026-08-02):

  before: POST /api/hash -> 500  {"ok":false,"error":"internal"}
                                 RouteConfigurationError
  after:  POST /api/hash -> 402  accepts: [exact eip155:8453]

The only thing protecting production was that `celo` had been manually removed
from PAYMENT_NETWORKS. I had put it back on the live service based on a code
reading, which would have taken production down on the next deploy; caught it
while the run was still in `test` and reverted. That was the mistake: changing a
production variable on an argument instead of a reproduction, about the exact
claim I had been told was wrong.

The fix applies the discipline the upto gate has always used, which simply never
covered `exact`: verify the offer against what the facilitators we actually
route through advertise, and drop what nobody can settle. Two safety properties,
both deliberate:

  * a network is dropped only on a POSITIVE answer - at least one facilitator
    responded and did not list it. If none respond, the configured offer is left
    alone rather than emptied on what may be a boot-time blip.
  * each client gets a second attempt, so a slow facilitator is not mistaken for
    a dead one and a transient blip cannot cost a rail.

One probe now serves both the exact gate and the upto gate instead of two.

scripts/test-rail-outage-isolation.js (new, wired into CI) reproduces the
outage end to end and asserts the 402 is PAYABLE, not merely a 402: a 402 with
an empty accepts array would pass a status check while being just as unusable.
Mutation-checked: disabling the gate fails 5 of 7.

PAYMENT_NETWORKS on prod is unchanged (11 chains, no celo). Celo goes back only
after this ships and /api/rails shows it dropping cleanly instead of poisoning
the offer.
@MikeyPetrillo
MikeyPetrillo marked this pull request as ready for review August 2, 2026 10:58
@MikeyPetrillo

Copy link
Copy Markdown
Owner Author

Closing as superseded. main already carries the class-level fix for this in 502c1d5 ("one dead facilitator must cost one rail, not every paid route"), merged via #650, which landed before this branch's version was written. That fix is the reason production tolerated a configured Celo with a 500ing facilitator while this branch's local reproduction still showed the outage: the repro was against the dev branch, which lacked it.

502c1d5 is also the better implementation - it memoizes getSupported per instance with a shared in-flight promise, which this branch's version does not.

Re-submitting only the ADDITIVE part on top of it: GET /api/rails, the configured-vs-offered surface with per-rail reasons, so "we configured 12, we serve 11, here is which and why" is queryable rather than something you read out of boot logs.

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