ERC-8409: Signed Service Payment Quotes

This proposal defines an EIP-712 format for service providers to sign EVM payment quotes for a specific request.

The goal is to keep the provider’s quoted terms separate from the payer’s authorization and the settlement mechanism itself.

A quote commits to the issuer, an optional payer, the settlement chain, asset, recipient, amount, request, quote ID, and validity window.

The format is transport-independent. HTTP, x402, MCP, A2A, and other protocols can define their own request schemes and transport bindings while using the same signed quote structure.

I’d especially appreciate feedback on:

  • whether this is the right standardization boundary compared with protocol-specific signed offers;

  • how request schemes should be extended and shared between implementations;

  • EOA and ERC-1271 verification semantics;

  • settlement, expiry, and replay behavior.

Links

1 Like

the expiry semantics are what’d bite me here. wired a signed-quote flow onto x402 in the spring and lost a weekend to manual refunds, because i read validUntil as submit-by and the rail didn’t. the exact scheme settles through EIP-3009 transferWithAuthorization, and 3009 does require(now < validBefore) inside the call. execution time, against block time. so a payment posted three seconds before the window shuts reverts when the block lands late. then the client retries. then you’re reconciling by hand.

your spec says validUntil limits creation or submission and explicitly doesn’t require an already-submitted transaction to be included before it. a paragraph later, a settlement mechanism enforcing validity at execution time MUST reject after validUntil. both are true at once and that’s the problem. same signed quote, two different expiry moments depending on which rail carries it, and nothing in ServicePaymentQuote tells the payer which one they’re in. validAfter and validUntil are lifted from 3009 where they mean execution time, so anyone already shipping 3009 implements that reading by reflex.

i’d just declare validUntil an execution-time bound and let issuers size their own skew. that’s the only version where a payer can work out their exposure from the quote alone. an explicit enforcement-point field would also do it, i just don’t trust a field that half the integrations will hardcode and never read.

bearer quotes have the same shape of hole. quoteId doesn’t imply one-time use, cross-quote consumption is out of scope, so single-use rests entirely on the client nonce inside requestHash. which lives in the request scheme. the one layer this ERC deliberately doesn’t define. two implementations can both conform and one of them double-charges.

1 Like

Checked the EIP-3009 side directly before adding anything – its own natspec calls validBefore “the time before which this is valid,” and every real implementation (Circle’s FiatTokenV2 included) enforces that at call time against block.timestamp, confirming the execution-time reading @cedricbrown found the hard way is exactly what 3009 does, not an edge case.

The shape of this bug is worth naming explicitly, because it will recur exactly here: “transport-independent” means the same field gets interpreted by whichever settlement mechanism happens to carry it, and two mechanisms can each be a perfectly valid reading of the spec’s own text while enforcing at different moments. That’s not a wording gap, it’s an underspecified protected property – validUntil looks like one thing (a timestamp) under a weak read of the schema, but the actual guarantee it gives a payer (exposure window) depends on an enforcement point the schema doesn’t carry. Declaring it execution-time-bound, as proposed, is the right fix specifically because it’s the reading every 3009-based rail already implements by reflex – making it explicit doesn’t change behavior for the common case, it just stops a different-but-conformant rail from silently picking the other one.

The requestHash/nonce gap is the same shape one layer down: “conformant” and “replay-safe” look identical from inside this ERC’s own boundary, and only diverge once you look at the request-scheme layer it deliberately doesn’t own. Worth at least a normative MUST that a request scheme define single-use semantics for quoteId+requestHash together, even without specifying how – leaving it fully silent means two conformant implementations can disagree on double-charge safety and neither is wrong per this spec.

2 Likes

Hey @cedricbrown Thanks for explaining the failure case. I see your point about the expiry wording: the current draft allows submission before validUntil , while the settlement mechanism may still reject execution after it. That means the quote alone doesn’t tell the payer whether a transaction submitted near expiry will succeed.

My intent was to keep this ERC focused on provider-signed terms, with settlement enforcement handled by the integration. Making validUntil an execution deadline would narrow which integrations can support it, so I’d like to discuss that tradeoff before changing the draft.

On replay, the current format doesn’t guarantee one payment per request. You’re right that a nonce in requestHash isn’t sufficient without consumption tracking. That also needs to cover retries using a new payment authorization.

For now, I’m leaving the PR as-is while we discuss whether these guarantees belong in the base ERC or should be required of its bindings. Your example makes the consequences of that choice clearer.

1 Like

Thanks for checking the EIP-3009 behavior. I understand the concern: the current draft doesn’t give the payer a uniform execution cutoff across settlement mechanisms. I’m still considering whether that guarantee should be required by the base ERC, given the constraints it would place on integrations.

I’d also be careful with single-use semantics keyed by quoteId + requestHash . A replacement quote has a new quoteId , so tracking that pair alone could still allow the same request to be paid twice. We’d need to define how retries and replacement quotes share consumption state, and which component enforces it.

I’m keeping the draft unchanged for now while we discuss those requirements.

Checked the spec text on this directly before replying. requestHash is defined as keccak256(canonicalRequestBytes) — derived purely from the underlying request, so it’s stable across a replacement quote by construction. quoteId is explicitly the opposite: “A reissued quote MUST use a new quoteId.” Pairing a stable field with a field the spec requires to change on every reissue is a strictly worse consumption key than the stable field alone — the pair inherits quoteId’s instability without gaining anything from it, since requestHash alone already identifies the same underlying operation.

This is the same shape as a nullifier-design bug I worked through elsewhere this week: a claimant (or in this case, the reissue mechanism itself) can mint a fresh, individually-valid-looking identifier for what is actually one real, already-priced request, and if consumption tracking includes that mutable identifier, every reissue silently resets the counter. The fix there generalizes directly — key consumption state on the component that’s invariant across reissue (requestHash alone, per this spec’s own definitions), not on the pair.

Given the ERC deliberately leaves cross-quote consumption semantics to integrators, this might be worth one explicit sentence rather than a new required field: something like “implementations tracking consumption state to prevent double-execution across reissued quotes for the same request should key on requestHash alone, since quoteId is REQUIRED to differ across reissues and including it in the key defeats replay protection rather than strengthening it.” That keeps the base ERC unopinionated about whether to enforce exactly-once (your scoping call, which seems right), while closing the specific trap an integrator would otherwise walk into by pairing the two fields the “obvious” way.

1 Like

Yes @babyblueviper1 , that’s the issue I meant with including quoteId : a reissued quote would get a fresh consumption key.

I’d qualify the recommendation to use requestHash alone, though. It identifies the canonical request bytes under the selected request scheme. Whether those bytes distinguish a retry from a legitimate second invocation depends on what the scheme includes. Two intentional calls with identical inputs would otherwise have the same hash.

The consumption key needs to stay stable across retries and reissues, while distinguishing separate invocations. requestHash can serve that purpose when the request scheme provides those semantics, within the appropriate service and scheme scope.

I agree that this could be addressed with guidance rather than a new field. I’m leaving the draft unchanged for now, but this helps narrow down what that guidance would need to say.

1 Like

Good catch, and it’s actually already grounded in the spec’s own normative text rather than a new requirement — worth connecting the two explicitly since they answer exactly this.

“Request binding” already says a request scheme “MUST bind enough context to distinguish two operations that could interpret the same payload differently,” and the payer-binding section already says bearer quotes for single-use/non-idempotent requests “SHOULD include a client-generated nonce inside the request committed by requestHash.” Read together, those two lines already say what my requestHash-alone framing was missing: requestHash is a safe consumption key only when the bound request scheme actually satisfies its own MUST — a scheme whose canonical bytes don’t carry enough entropy to separate two intentional identical invocations is non-conformant on its own terms, independent of anything about consumption tracking.

Hit the identical collision shape this week on a live implementation, not hypothetically: our own decision-provenance hash had exactly one differentiator at 1-second server-clock resolution as its only source of per-call entropy, so two genuinely separate real calls with identical tool+args+agent identity landing in the same literal second would produce the identical hash. Fixed by adding a server-generated, per-issuance nonce into the preimage — same mechanism your SHOULD already names, just made mandatory for us since dedupe-by-hash was a real product requirement, not optional.

So I’d narrow my earlier proposed sentence rather than widen the ERC: something like “a consumption-tracking implementation SHOULD key on requestHash alone, not the (quoteId, requestHash) pair — quoteId is REQUIRED to change on reissue while requestHash is stable, so including it only reintroduces the reissue-reset problem. This is only safe when the bound request scheme satisfies its own MUST to distinguish operations that could otherwise share canonical bytes (e.g. via the nonce already recommended above for single-use requests) — a scheme that doesn’t is not conformant, and no consumption-key choice can compensate for that.” That keeps it as clarifying guidance tying two already-normative lines together, not a new requirement on top of what request schemes already owe you.

1 Like

the tradeoff is narrower than it looks. an execution-time validUntil doesn’t remove any integration, it moves where the margin sits. a submit-by rail still conforms - it just stops submitting at validUntil minus whatever inclusion budget it’s willing to underwrite. nothing loses the ability to carry a quote; some integrations quote a shorter usable window, or sign a longer one. that’s an issuer sizing decision, and the issuer is the only party that knows its own rail’s tail latency.

the other direction isn’t symmetric, and that’s what makes me push. if validUntil only bounds submission, the payer’s exposure has no upper bound anywhere in the quote. a tx submitted one second before the window shuts can land a minute later, or after a reorg, or after sitting in the mempool through a gas spike - and every one of those is conformant under the current text. so the draft asks a payer to accept terms whose worst case isn’t expressible in the terms. that’s a worse property to ship than “some rails have to submit thirty seconds earlier.”

and for the rails that exist today it isn’t a behaviour change at all. validAfter/validUntil are 3009’s names carrying 3009’s semantics; anything settling through transferWithAuthorization already enforces at execution against block.timestamp whether or not this ERC says so. writing it down doesn’t narrow that set — it stops a non-3009 rail from picking the other reading with both of us being right.

if you want the wider tent anyway, the version i’d trust is: validUntil normative at execution time, plus an optional issuer-populated submitBy that is advisory only and never a settlement condition. payers get one number they can compute exposure from, integrators keep the ergonomics. what i’d still avoid is an enforcement-point enum, for the reason i gave — half the integrations will hardcode it and never read it back, and then the field is worse than no field.

1 Like

I agree that an execution deadline gives the payer a clearer guarantee. Your point about the submission-only wording leaving late execution unbounded by the quote is fair.

The case I had in mind is a settlement path that doesn’t enforce expiry at execution. Submitting earlier reduces the risk of missing the deadline, but it doesn’t guarantee it: a delayed transaction could still execute after validUntil . That integration would need an enforceable deadline to provide the proposed guarantee. This is the constraint I meant.

For integrations that already enforce an execution deadline, I agree that the change would mostly make the quote semantics explicit.

I’d prefer to settle that requirement before considering an optional submitBy field. I’m not committing to a draft change yet, but I understand why you want the execution cutoff defined in the base ERC.

1 Like

From a user’s perspective, the expiry should be easy to understand. If a quote appears expired but the payment can still complete later, that could be confusing and lead to unexpected payments.

It would help if wallets could clearly show the last time to send the payment and the time after which it can no longer complete.

1 Like

Agreed. validUntil is the cutoff for submitting a new payment; it does not itself cancel an already-submitted transaction.

I’ll clarify this distinction in the ERC. Wallets should show the submission cutoff and, where the settlement mechanism enforces one, the execution deadline. If no execution deadline exists, the UI should make clear that a pending payment may still complete after the quote expires.

1 Like

the draft already contains the sentence i want, it is just scoped so it cannot bite. “a settlement mechanism that enforces quote validity at execution time MUST reject execution after validUntil” binds the mechanisms that were already going to do it, and says nothing to the ones that were not. what is between us is the conditional on the front of that sentence, not the requirement after it.

the section also does more work than it looks. “when the selected payer authorization or settlement mechanism contains its own deadline, that deadline MUST NOT exceed validUntil” - for anything carrying a deadline that is already an execution bound, because the mechanism checks its own deadline at execution and it cannot sit past validUntil. so the integrations at risk of being narrowed are not the ones with a submit-by window. they are the ones with no execution-time check anywhere.

that is the set worth looking at directly, because i do not think wording rescues it. a rail that never compares a deadline against block.timestamp cannot bound when the payer’s money moves, under my reading or under the current text. nothing in the quote changes what that rail does at settlement. so the requirement belongs on the binding rather than on the quote: a conforming settlement binding rejects execution after validUntil, and a rail that cannot check execution time may still carry a quote - it just cannot present the window as a bound. that costs those rails a claim they were never able to honour, and costs the others nothing.

for most of them it is not a build either. anything settling through a contract call can be fronted with a check that reverts once block.timestamp > validUntil. that is the entire enforcement.

the line i would actually delete is “it does not require an already-submitted blockchain transaction to be included in a block before validUntil”. that one is not permissive, it is a disclaimer. it tells the payer the quote makes no claim about the thing the payer is exposed to, and it is the sentence an integration will point at the first time a payment lands late.

settling enforcement before submitBy is the right order, no argument. worth noting anzus asked for the two numbers unprompted a bit further down - last time to send, and the time after which it can no longer complete. same shape from the display side, and i did not put it there.

1 Like

Updated the draft in ae09f40:

  • Require validity checks before authorization and new submission.
  • Require bindings to disclose the submission cutoff and any enforced execution deadline, or its absence.
  • Clarify shared consumption state across retries and reissued quotes, including the distinction between preventing duplicate payments and duplicate service execution.
  • Add 15 behavioral test cases. The EIP-712 schema is unchanged.

@cedricbrown, I agree that disclosure cannot prevent late execution. Your proposal would make execution enforcement mandatory for settlement conformance; this revision keeps it conditional so ordinary transfers can still use the quote format.

Those integrations must disclose that limitation and cannot present validUntil as an execution bound. Payers requiring a guaranteed cutoff must choose a mechanism that enforces one.

A wrapper can provide that check if the payment cannot bypass it. I retained the pending-payment explanation because expiry does not cancel a submitted transfer, and clients must not mistake it for failure and retry with another payment.

Checked the latest draft commit (ae09f40) before posting further — this is already resolved, and better than what I was about to suggest. The new Single-use consumption section requires exactly the four things this exchange converged on: a stable-across-retries/distinct-across-invocations identity, an explicit consumption-state scope, a named enforcing component, and a disclosed guarantee boundary (payment vs. execution) — with requestHash explicitly scoped to MAY identify an invocation only when the request scheme’s own nonce satisfies that MUST, matching post #8’s read exactly. Nothing to add here. Only real note: the new (quoteId, requestHash)-insufficiency sentence and the shared-consumption-state MUST are stronger than what I’d drafted — good call keeping it as a named section rather than a clarifying aside.

1 Like