feat(x402): apply the paid unlock to agent offers by type, not a configured path - #819
feat(x402): apply the paid unlock to agent offers by type, not a configured path#819bussyjd wants to merge 1 commit into
Conversation
…igured path
The paid unlock gate selected its offer by comparing rule.StripPrefix to a
global authCaptureUnlock.offerPrefix string. That made the platform fee
something an operator had to opt into, by hand, for exactly one offer per
stack — and a node operator has no incentive to opt into a fee that goes to
someone else. It has to ship with the product.
isUnlockOffer now keys on the offer TYPE: every agent offer is unlock-gated,
http offers never are. rule.AgentRuntime is already populated from the
ServiceOffer in serviceoffer_source.go and is the same signal
mergeAgentExtras uses to decide a rule is an agent, so this adds no plumbing.
The split matches how the two types are actually sold. An agent is a
conversation a human opens in the chat widget: connect wallet, pay once to
mint the session, then per-turn billing with no further fee. An http offer is
a machine-to-machine API with no session concept, and gating it would tax
per-request traffic that never signs in. In practice a stack hosts a small
number of agent offers alongside many http APIs, so few offers become gated
and the rest are untouched.
Consequences:
- offerPrefix is removed. There is no longer a one-unlock-offer-per-stack
ceiling, and no per-offer opt-in.
- Price, PayTo and Network become OVERRIDES that fall back to the agent
offer's own values. This is load-bearing once several agents are covered:
without it every agent's unlock revenue would land in one wallet at one
price. The fallbacks are applied BEFORE Validate so it checks the values
that will actually be advertised, and an unlock priced by neither config
nor offer is now rejected rather than advertised at zero.
- handleAuthEndpoints moves its existing authRuleForPrefix lookup above the
check so it can test the rule instead of the path. Same call, earlier.
Also sidesteps the buyer-compatibility problem that a fee-on-everything
design would have: auth-capture only ever appears on the agent unlock, which
is a browser widget we control, so no third-party exact-only buyer meets it.
Tests pin the rule in both directions (agent gated, http not; a second agent
also gated), keep the operator kill switch honest, and cover the nil cases.
Verified the new test fails when the selector is reverted to a prefix match.
c0e5993 to
a7afe62
Compare
|
Closing: the mechanism this PR broadens does not reach the traffic it was aimed at.
Agent offers that declare no routes get Separately, the chat widget bundles The per-turn payments are ordinary paid requests built by |
|
Superseded by #820, which puts the fee on the per-request paid path instead. |
Makes the platform fee ship with the product instead of being an operator opt-in.
Problem
The paid unlock gate picked its offer by string-matching
rule.StripPrefixagainst a globalauthCaptureUnlock.offerPrefix. Two consequences, both bad for a platform fee:Change
isUnlockOffernow keys on the offer type:Every agent offer is unlock-gated; http offers never are.
rule.AgentRuntimeis already populated from the ServiceOffer (serviceoffer_source.go:306-308) and is the same signalmergeAgentExtrasuses to decide a rule is an agent — no new plumbing.Why type is the right axis
It matches how the two types are sold.
An agent is a conversation a human opens in the chat widget: connect wallet → pay once → session minted → per-turn billing, no further fee. An http offer is a machine-to-machine API with no session concept; gating it would tax per-request traffic that never signs in.
A stack typically hosts one or a few agent offers alongside many
type: httpAPIs, so in practice a small number of offers become unlock-gated and the rest are untouched:agenthttpMulti-agent correctness
Price,PayToandNetworkbecome overrides, falling back to the agent offer's own values.This is load-bearing the moment more than one agent is covered: without it every agent's unlock revenue would land in one wallet at one price. The fallbacks are applied before
Validate()so it checks the values that will actually be advertised, and an unlock priced by neither config nor offer is now rejected rather than advertised at zero.offerPrefixis removed.Also
handleAuthEndpointsmoves its existingauthRuleForPrefixlookup above the check so it can test the rule rather than the path — same call, twelve lines earlier.This also sidesteps the buyer-compatibility problem a fee-on-everything design would hit:
auth-captureonly ever appears on the agent unlock, which is a browser widget we control, so no third-partyexact-only buyer encounters it.Tests
unlock_agent_default_test.gopins the rule in both directions (agent gated, http not, a second agent also gated), keeps the operator kill switch honest, and covers the nil cases. Verified it fails when the selector is reverted to a prefix match:go build ./...,go vet, and thex402,serviceoffercontrollerandembedsuites all pass.Sequencing — needs a follow-up on #818
#818 templates
authCaptureUnlockinto the Helm chart includingofferPrefix, which this PR removes, and defaultsenabled: false. If both land as-is the chart would render a dead key and the fee would stay off.Whichever merges second should drop
offerPrefixfrombase/values.yaml, the helmfile state values and thex402-pricing.mdreference, and flip the default toenabled: truewith the platformfeeRecipientand bps — which is the whole point of type-based selection.This PR deliberately does not touch those files, to keep the verifier change reviewable on its own.