Skip to content

feat(x402): apply the paid unlock to agent offers by type, not a configured path - #819

Draft
bussyjd wants to merge 1 commit into
mainfrom
feat/authcapture-agent-default
Draft

feat(x402): apply the paid unlock to agent offers by type, not a configured path#819
bussyjd wants to merge 1 commit into
mainfrom
feat/authcapture-agent-default

Conversation

@bussyjd

@bussyjd bussyjd commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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.StripPrefix against a global authCaptureUnlock.offerPrefix. Two consequences, both bad for a platform fee:

  • an operator had to opt in by hand, and a node operator has no incentive to opt into a fee that goes to someone else;
  • it covered exactly one offer per stack.

Change

isUnlockOffer now keys on the offer type:

return cfg != nil && cfg.AuthCaptureUnlock != nil && cfg.AuthCaptureUnlock.Enabled &&
    rule != nil && rule.AgentRuntime != ""

Every agent offer is unlock-gated; http offers never are. rule.AgentRuntime is already populated from the ServiceOffer (serviceoffer_source.go:306-308) and is the same signal mergeAgentExtras uses 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: http APIs, so in practice a small number of offers become unlock-gated and the rest are untouched:

offer type unlock + fee why
agent human opens a session in the chat widget
http machine-to-machine, no session to gate

Multi-agent correctness

Price, PayTo and Network become 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.

offerPrefix is removed.

Also

handleAuthEndpoints moves its existing authRuleForPrefix lookup 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-capture only ever appears on the agent unlock, which is a browser widget we control, so no third-party exact-only buyer encounters it.

Tests

unlock_agent_default_test.go pins 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:

--- FAIL: TestIsUnlockOffer_SelectsAgentsOnly
    agent offer must be unlock-gated — the fee ships with the product, not per-offer opt-in
    a second agent offer must also be unlock-gated (no one-offer-per-stack ceiling)

go build ./..., go vet, and the x402, serviceoffercontroller and embed suites all pass.

Sequencing — needs a follow-up on #818

#818 templates authCaptureUnlock into the Helm chart including offerPrefix, which this PR removes, and defaults enabled: false. If both land as-is the chart would render a dead key and the fee would stay off.

Whichever merges second should drop offerPrefix from base/values.yaml, the helmfile state values and the x402-pricing.md reference, and flip the default to enabled: true with the platform feeRecipient and 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.

…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.
@bussyjd
bussyjd force-pushed the feat/authcapture-agent-default branch from c0e5993 to a7afe62 Compare August 11, 2026 14:10
@bussyjd
bussyjd marked this pull request as draft August 11, 2026 14:45
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