Skip to content

feat(x402): platform fee on every paid agent request, not once per session - #820

Open
bussyjd wants to merge 4 commits into
mainfrom
feat/platform-fee-per-request
Open

feat(x402): platform fee on every paid agent request, not once per session#820
bussyjd wants to merge 4 commits into
mainfrom
feat/platform-fee-per-request

Conversation

@bussyjd

@bussyjd bussyjd commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Supersedes #819, which is closed.

Why #819 could not work

isUnlockOffer is only consulted inside if rule.IsAuth(), and gate: auth routes 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} from EffectiveRoutes(), so it does not enter the auth branch at all. And the shipped chat widget bundled ExactEvmScheme only, 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 BuildV2RequirementWithAsset with a hardcoded Scheme: "exact" and no fee fields. An agent offer now advertises both schemes at the same price:

"accepts": [
  { "scheme": "auth-capture", "amount": "10000", "extra": { "feeRecipient": "0x…", "minFeeBps": 50, "maxFeeBps": 50, "autoCapture": true } },
  { "scheme": "exact",        "amount": "10000" }
]

x402Client.selectPaymentRequirements filters accepts[] 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. autoCapture makes each charge single-shot, so the escrow splits feeBps to feeRecipient on 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. ResolveMatchedRequirement substitutes the signed struct, and that is only safe because validateSignedAuthCapture first 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 Extra map 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

  • network scopes 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 /supported before widening.
  • offerPrefix is 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 any gate: auth offer at / into a paid unlock.

Off by default. The standalone unlock gate is unchanged and still available via offerPrefix.

Known limits

  • A buyer that ignores the scheme filter and blindly signs accepts[0] will attempt auth-capture. Clients built on @x402/* filter correctly.
  • Enforcement is by distribution, not cryptography: an operator can turn it off.
  • @x402/evm 2.18.0 already exports AuthCaptureEvmScheme, 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 full go test ./... are green, and just generate produces no drift.

https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v

…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
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