feat(x402): platform fee on every paid agent request, not once per session - #820
Open
bussyjd wants to merge 4 commits into
Open
feat(x402): platform fee on every paid agent request, not once per session#820bussyjd wants to merge 4 commits into
bussyjd wants to merge 4 commits into
Conversation
…ssion
The auth-capture unlock could never produce recurring revenue. isUnlockOffer
is only consulted inside `if rule.IsAuth()`, and auth routes take a verified
wallet INSTEAD of payment — so the turns behind a minted session are free and
the fee fired exactly once. Agent offers that declare no routes get
{Path: "/*", Gate: paid} from EffectiveRoutes(), so in practice they never
entered that branch at all.
The per-turn payments are ordinary paid requests, built by
BuildV2RequirementWithAsset with a hardcoded Scheme: "exact" and no fee
fields. That is where the fee has to go.
An agent offer now advertises both schemes at the same price: the
auth-capture twin first, its exact counterpart behind it. An x402 client
filters accepts[] down to the schemes it has registered and then takes the
first survivor, so a buyer that speaks auth-capture pays the fee and an
exact-only buyer falls through to exact and is unaffected. autoCapture makes
each charge single-shot, so the split lands on EVERY request rather than once
per session.
- platformfee.go: scope (agent offers only, by rule.AgentRuntime), the
fee-bearing requirement, and validateSignedAuthCapture
- forwardauth.go: ResolveMatchedRequirement + OnPaymentSettled hooks
- verifier.go: emit the fee twin in resolvePaidRoute, wire both hooks
- chat.html/chat-vendor.js: register AuthCaptureEvmScheme so the widget can
pay what it now gets offered (@x402/evm 2.18.0 already exports it; the
validated version pin is unchanged)
Auth-capture must settle against the requirement the client SIGNED, not our
rebuilt copy: the signed PaymentInfo hash commits server-issued deadlines that
drift between the 402 and the paid request. Settling a client-supplied struct
is only safe because validateSignedAuthCapture pins every economically
meaningful field against what we offered first — the facilitator does not know
our intended feeRecipient, payTo or amount, so a blind forward would let a
buyer redirect the fee or underpay. Tests cover thirteen tamper cases.
Two guards on the blast radius:
- authCaptureUnlock.network scopes the fee to one chain. A facilitator only
registers auth-capture for its configured chains, and advertising the fee
where it cannot settle would make it the entry a capable buyer picks first
and then fail their payment.
- offerPrefix is no longer required when enabled, and an empty prefix now
means "no unlock offer" rather than matching every root-mounted rule —
without that, enabling the fee would convert any gate:auth offer at "/"
into a paid unlock.
Off by default; http offers are untouched.
Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v
paymentRequiredBody runs every requirement through legacyCompatRequirements, which appends a copy under the pre-CAIP-2 network name. That doubled the fee twin into an entry no buyer can use: the alias exists for legacy v1 "exact" buyers, and auth-capture is v2-only, so the alias could only be picked by a client that validateSignedAuthCapture then rejects on the network — and settling a buyer's alias network verbatim is what findMatchingRequirementV1 warns against. Skip the alias for auth-capture. The exact twin keeps its alias, so v1 buyers are unaffected. Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v
…lidator Audit of our implementation against the x402 auth-capture spec found two real deviations and three duplications. maxTimeoutSeconds is the client SIGNING window — clients derive preApprovalExpiry = now + maxTimeoutSeconds — and is independent of captureDeadline, the much longer escrow-hold deadline. We derived it FROM captureDeadlineSecs, which conflated the two and made our own twins disagree: the exact requirement used the route's value while its auth-capture twin used 900s. BuildAuthCaptureRequirement now takes it as a parameter, and resolvePaidRoute passes the same opt.MaxTimeoutSeconds both twins already shared. The unlock gate passes 0 for DefaultMaxTimeoutSeconds. Revenue metrics assume the fee collected is maxFeeBps, but the scheme permits any value in [minFeeBps, maxFeeBps] at charge() and the facilitator does not report which was applied. Validate now requires min == max while enabled, so the assumption is enforced rather than merely true of today's config. validateSignedUnlockRequirement is deleted: validateSignedAuthCapture is a strict superset — it diffs the whole offered Extra map (so it also pins the EIP-712 name/version the enumerated version silently ignored, and covers any field added later) and upper-bounds captureDeadline, which the old one never did. Its test cases move across intact. exStr/exU64 go with it; the fee metric block in handlePaidUnlock collapses into recordFeeRevenue, which it duplicated formula-for-formula. Metric help strings said "unlock" for counters now driven mainly by the per-request fee. Names unchanged — they are already scraped. Left alone deliberately: findMatchingRequirementV1 reimplements the SDK's FindMatchingRequirements, but the whole ForwardAuth path bypasses x402ResourceServer and predates this work. Adopting it is a separate change. The third copy of the unlock predicate in authgate.go also stays: the rule is not in scope at that point and hoisting it would restructure the function. Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v
platformFeeHooks returns the two closures that carry the fee — the one substituting the client-signed requirement before verify, and the one recording revenue after settle — and neither was executed by any test. The advertising side was covered; the part that proves money moved was not. TestPlatformFee_PaidRoundTrip drives v.HandleProxy against a mock facilitator: 402 challenge (asserting the auth-capture twin is accepts[0]), replay with the signed requirement, 200 from the upstream, and exact fee/settled-volume metric deltas derived from the advertised amount. No chain, no cluster. The tamper case is the point of it: the same replay with extra.feeRecipient mutated must be rejected before settle, and the test asserts the facilitator's /settle was never reached — a redirected fee cannot make it on-chain. Confirmed non-vacuous: with recordFeeRevenue commented out the test fails (fee revenue 0, want 50). Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #819, which is closed.
Why #819 could not work
isUnlockOfferis only consulted insideif rule.IsAuth(), andgate: authroutes take a verified wallet instead of payment. So the turns behind a minted session are free, and a fee attached there fires exactly once per session — recurring revenue was structurally impossible.It also never fired in practice: an agent offer that declares no routes gets
{Path: "/*", Gate: paid}fromEffectiveRoutes(), so it does not enter the auth branch at all. And the shipped chat widget bundledExactEvmSchemeonly, so an auth-capture challenge would have been unpayable by the client that was supposed to pay it.What this does instead
The per-turn payments are ordinary paid requests, built by
BuildV2RequirementWithAssetwith a hardcodedScheme: "exact"and no fee fields. An agent offer now advertises both schemes at the same price:x402Client.selectPaymentRequirementsfiltersaccepts[]down to the schemes the buyer registered and then takes the first survivor. So a buyer that speaks auth-capture pays the fee, and an exact-only buyer falls through to the second entry and is unaffected — nothing that works today stops working.autoCapturemakes each charge single-shot, so the escrow splitsfeeBpstofeeRecipienton every request.Scope: agent offers only (
rule.AgentRuntime). http offers keep advertising exact alone — their buyers are third-party clients we do not ship.Settling a client-supplied struct
Auth-capture must settle against the requirement the client SIGNED: the signed PaymentInfo hash commits server-issued deadlines that are now-relative and drift between the 402 and the paid request, so our rebuilt copy would invalidate every signature.
ResolveMatchedRequirementsubstitutes the signed struct, and that is only safe becausevalidateSignedAuthCapturefirst pins every economically meaningful field against what we offered — the facilitator does not know our intended feeRecipient, payTo or amount, so a blind forward would let a buyer redirect the fee or underpay.It compares the whole
Extramap rather than an enumerated allowlist, so a field added to the requirement builder is pinned automatically instead of silently becoming client-controlled. Only the two deadlines may differ, and they are bounded on both ends. Thirteen tamper cases are covered, including fee redirection, fee zeroing, scheme downgrade, underpayment, and holding the authorization open past the configured window.Blast-radius guards
networkscopes the fee to one chain. A facilitator only registers auth-capture for its configured chains. Advertising the fee where it cannot settle would make it the entry a capable buyer picks first and then fail their payment at verify. Confirm the chain is in the facilitator/supportedbefore widening.offerPrefixis no longer required when enabled, and an empty prefix now means "no unlock offer" instead of matching every root-mounted rule. Without that, enabling the fee would convert anygate: authoffer at/into a paid unlock.Off by default. The standalone unlock gate is unchanged and still available via
offerPrefix.Known limits
accepts[0]will attempt auth-capture. Clients built on@x402/*filter correctly.@x402/evm2.18.0 already exportsAuthCaptureEvmScheme, so the vendor bundle rebuild keeps the validated version pin; only the export list and the?v=cache-buster changed.Verification
go build ./...,go vet, and the fullgo test ./...are green, andjust generateproduces no drift.https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v