polycratia

Sign the quote behind every 402, or the retry pays a different price

· 8 min read

The x402 exchange comes in two halves with an unbounded gap between them. A server answers an unpaid request with 402 and the terms it accepts; some time later the client repeats the request with an X-PAYMENT header built against those terms. If the gate only knows its current price, it checks the second half against state that may have moved since the first half went out. The client can pay a price it was never shown, or get refused for paying exactly the price it was.

I have spent most of the last eight years on payment systems where the interesting failures sit in the bookkeeping rather than the transfer, and this is a bookkeeping failure in a pricing costume. It is an evidence problem: at retry time the server holds no artifact of what it offered, so it substitutes what it offers now and hopes the two are the same thing.

Where the gap actually is

Look at what a gate has in hand when the paid retry arrives. In paygate402 the accepted terms come from either a fixed Accepts list or a Prices table keyed by route. Both are server configuration, read at request time. The payment payload stays opaque on purpose: the package matches only the scheme and the network, because those are the only fields a web layer can honestly compare, and the amount, the asset and the recipient are checked by the facilitator that can read the payload.

That division of labour is right, and it is exactly what makes the stale-price case invisible. The facilitator verifies the payment against the terms the gate hands it, and the gate hands it today's terms. Nobody in the chain holds the terms that were actually printed in the 402. If the price moved between the challenge and the retry (a deploy, a config reload, a route that started resolving to a different row of the pricing table), the client's signed authorization gets judged against an offer it never saw. When the new price is higher the payment is declined, and the client is refused for doing precisely what the server told it to do. When the new price is lower, the authorization still covers it, and the client pays above the number now on the board.

The operational version of this is worse than the technical one. A client writes in saying it was quoted one amount and charged against another, and the server cannot contradict it, because the server kept no copy of the quote. Reconciliation work teaches you fast that a disagreement between two parties resolves only when both hold a record of the same row. A 402 that carries a bare number gives the client a record and keeps none.

The offer is a bearer artifact, not server state

The fix is to stop treating the price as something the server remembers and start treating it as something the client carries. paygate402 does this with a signer: when Quotes is set, the gate signs the priced offer behind every 402 with its own key (amount, asset, expiry and a nonce) and sends one X-PAYMENT-QUOTE header per accepted term. The client echoes the quote back with its payment.

go
gate := &paygate402.Gate{
    Accepts: []paygate402.Requirements{{
        Scheme:            "exact",
        Network:           "base",
        MaxAmountRequired: "10000",
        Resource:          "https://api.example.com/report",
        PayTo:             merchantAddress,
        Asset:             usdcAddress,
        MaxTimeoutSeconds: 60,
    }},
    Facilitator: &paygate402.HTTPFacilitator{BaseURL: "https://facilitator.example.com"},
    Quotes:      &paygate402.QuoteSigner{Key: quoteKey},
}

Nothing is stored for this. There is no quote table and no expiry sweeper, nothing to replicate between instances, because the offer proves its own provenance. The practical consequence: a price change stops being a race. Outstanding quotes stay honourable until they run out, new requests get the new number, and the two coexist for exactly the lifetime you chose when you signed. That is the property I actually wanted out of this, and it is not the one I expected to want.

Three checks, and only one of them is about forgery

With a quote in hand, the gate checks three things before a facilitator is asked anything: that this server signed the offer, that the offer has not run out, and that it is the offer for the terms being paid for now.

They fail differently, so they are worth separating.

The first is forgery. Without a signature the price is a number the client hands back, which means the client sets it. This one is obvious, and it is the one everybody implements.

The second is staleness. A quote without an expiry is a perpetual option on your price, written by you and held by whoever asked once. Anyone who has priced anything against a moving asset knows what a free option is worth to the holder and what it costs the writer. Call the expiry the term of the offer, because that is what it does, and hygiene has nothing to do with it.

The third gets forgotten: substitution. A signature says the artifact is genuine. It does not say the artifact is genuine about this. A valid, unexpired, correctly signed quote for a cheap route, presented on an expensive one, passes both of the first two checks. Binding the offer to the terms being bought is what closes that, and the binding has to live inside the signed bytes rather than get checked alongside them.

The ordering matters too. The quote is checked before the facilitator call, not after:

go
window := g.replayWindow()
if g.Quotes != nil {
    offer, err := g.presented(r, terms)
    if err != nil {
        g.challenge(w, accepts, reason(err))
        return
    }
    window = ReplayWindowFor(offer, g.Quotes.now(), 0)
}

verification, err := g.Facilitator.Verify(r.Context(), payment, terms)

A payment answering no offer this server made, or one that has run out, is not a question worth putting to a facilitator. The facilitator call is the expensive, rate-limited, occasionally unreachable part of the request. Anything you can refuse from local evidence, refuse from local evidence.

Canonical bytes, not the JSON

The offer is signed over a canonical length-prefixed form rather than over its JSON representation. Two separate reasons, and both bite in production.

JSON is not a stable byte string. Key order, whitespace, number formatting and escaping all vary by encoder and by version of encoder. A verifier that reconstructs the JSON to check the signature is betting that its serializer agrees byte for byte with the one that signed. That bet holds until the day it does not, and the symptom is a fleet where some instances reject quotes issued by others.

The length prefixes are the second reason. Concatenating fields to sign them lets the boundaries move: an amount of 10 followed by a field starting 000 signs the same bytes as an amount of 100 followed by 00. Length prefixes make the parse of the signed bytes unique, which is the whole point: you are not signing some fields, you are signing one unambiguous reading of them.

The expiry you already wrote is the retention you needed

There is a quiet payoff here. A spent payment has to be remembered until the offer behind it stops standing, plus a margin for the difference between the clock that stamped the quote and the clock that reads it. Without a signer, paygate402 remembers a used payment for a default hour, a number chosen because some number was required. With a signer, the window comes from the quote's own lifetime: ReplayWindowFor, above. Shorter leaves a window open, longer only makes the store grow.

This does not make the replay ledger optional. A settled X-PAYMENT header is still a bearer artifact and still has to be recorded as spent. But the retention parameter stops being a guess, because the offer already states when it stops being presentable.

What I would do differently

I built the quote signer as a way to bound replay retention and only afterwards understood it as a correctness property of pricing. That ordering cost me time. The argument that should have come first is the plain one: if you cannot show what you offered, you cannot defend what you charged.

I would also treat the skew margin as a first-class decision rather than a 0 passed at the call site. Two machines that disagree by a few seconds will produce a narrow band of quotes that one instance considers live and another considers dead, and in the logs that band looks exactly like a client bug...

The key deserves the same treatment. A signer with a single key works until the first rotation, and rotation on a bearer artifact with a lifetime means accepting the previous key for at least as long as the longest outstanding quote. Small amount of design done early, or an outage done late.

The gate is at https://github.com/polycratia/paygate402. The signer is optional there, and the gate without one behaves exactly as it did before. But where the price can move between the challenge and the retry, which is probably most things worth charging for, the optional part is what makes the 402 mean something.

react

$ new-project --brief

or email hey@polycratia.com