Uh oh!
There was an error while loading. Please reload this page.
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
a7afe62Comparebussyjd
commented
Aug 12, 2026
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 |
bussyjd
commented
Aug 12, 2026
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.