From a7afe62458473e8c423cb73051ee3d81dce15c6a Mon Sep 17 00:00:00 2001 From: bussyjd Date: Tue, 11 Aug 2026 18:07:07 +0400 Subject: [PATCH] feat(x402): apply the paid unlock to agent offers by type, not a configured path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/x402/authcapture.go | 22 ++++-- internal/x402/authcapture_test.go | 22 ++++-- internal/x402/authgate.go | 15 +++-- internal/x402/unlock_agent_default_test.go | 78 ++++++++++++++++++++++ internal/x402/unlockgate.go | 38 ++++++++++- 5 files changed, 152 insertions(+), 23 deletions(-) create mode 100644 internal/x402/unlock_agent_default_test.go diff --git a/internal/x402/authcapture.go b/internal/x402/authcapture.go index 1c18e9dab..3cf4d6511 100644 --- a/internal/x402/authcapture.go +++ b/internal/x402/authcapture.go @@ -14,11 +14,16 @@ const ( defaultRefundDeadlineSecs = 1800 ) -// AuthCaptureUnlockConfig configures the single offer whose SIWX session is -// minted only after an auth-capture payment verifies and settles. +// AuthCaptureUnlockConfig configures the paid sign-in applied to AGENT offers: +// their SIWX session is minted only after an auth-capture payment verifies and +// settles. Which offers it covers is decided by type (see isUnlockOffer), not +// by config — this struct only carries how the payment is priced and split. +// +// Price, Network and PayTo are OVERRIDES. Left empty they fall back to the +// agent offer's own values, so a stack with several agents needs no per-agent +// configuration and each seller is paid to their own address. type AuthCaptureUnlockConfig struct { Enabled bool `yaml:"enabled"` - OfferPrefix string `yaml:"offerPrefix"` Price string `yaml:"price"` Network string `yaml:"network"` PayTo string `yaml:"payTo"` @@ -45,15 +50,20 @@ func (c *AuthCaptureUnlockConfig) Validate() error { } if c.Enabled { - if c.OfferPrefix == "" { - return fmt.Errorf("offerPrefix must be non-empty when enabled") - } if c.FeeRecipient == "" { return fmt.Errorf("feeRecipient must be non-empty when enabled") } if c.CaptureAuthorizer == "" { return fmt.Errorf("captureAuthorizer must be non-empty when enabled") } + // Price is resolved per-offer from the agent's own price, so it is + // deliberately NOT required here — but if it never resolves, the + // requirement builder would advertise an empty amount. handlePaidUnlock + // fills it from the rule before calling Validate's caller, so an empty + // price at this point means neither config nor offer priced it. + if c.Price == "" { + return fmt.Errorf("price is empty and the offer does not declare one") + } } if c.MinFeeBps > c.MaxFeeBps { return fmt.Errorf("minFeeBps %d exceeds maxFeeBps %d", c.MinFeeBps, c.MaxFeeBps) diff --git a/internal/x402/authcapture_test.go b/internal/x402/authcapture_test.go index 78f7e4b86..f9f0778f0 100644 --- a/internal/x402/authcapture_test.go +++ b/internal/x402/authcapture_test.go @@ -20,12 +20,14 @@ const ( testFeeRecipient = "0x1111111111111111111111111111111111111111" testCaptureAuthorizer = "0x2222222222222222222222222222222222222222" testUnlockPayer = "0xAbCdEfabcdefABCDefAbcdefabCDefABcDefAbCd" + // Unlock offers are now selected by offer TYPE, not by a configured + // path, so this is just where the fixture agent happens to live. + testOfferPrefix = "/services/agent" ) func validAuthCaptureConfig() AuthCaptureUnlockConfig { return AuthCaptureUnlockConfig{ Enabled: true, - OfferPrefix: "/services/agent", Price: "1.00", FeeRecipient: testFeeRecipient, MinFeeBps: 100, @@ -211,10 +213,12 @@ func TestPaidUnlock_InlineFirstMessage(t *testing.T) { Routes: []RouteRule{{ Pattern: "/services/agent/*", Gate: "auth", - StripPrefix: unlockConfig.OfferPrefix, + StripPrefix: testOfferPrefix, UpstreamURL: upstream.URL, OfferNamespace: "test", OfferName: "agent", + // Selects the unlock gate: isUnlockOffer keys on the agent type. + AgentRuntime: "hermes", }}, AuthCaptureUnlock: &unlockConfig, }) @@ -222,7 +226,7 @@ func TestPaidUnlock_InlineFirstMessage(t *testing.T) { t.Fatalf("NewVerifier: %v", err) } - path := unlockConfig.OfferPrefix + "/chat" + path := testOfferPrefix + "/chat" req := httptest.NewRequest(http.MethodGet, path, nil) w := httptest.NewRecorder() v.HandleProxy(w, req) @@ -345,10 +349,12 @@ func TestPaidUnlock_SettleErrorSurfacesTxHash(t *testing.T) { Routes: []RouteRule{{ Pattern: "/services/agent/*", Gate: "auth", - StripPrefix: unlockConfig.OfferPrefix, + StripPrefix: testOfferPrefix, UpstreamURL: "http://upstream.invalid", OfferNamespace: "test", OfferName: "agent", + // Selects the unlock gate: isUnlockOffer keys on the agent type. + AgentRuntime: "hermes", }}, AuthCaptureUnlock: &unlockConfig, }) @@ -356,7 +362,7 @@ func TestPaidUnlock_SettleErrorSurfacesTxHash(t *testing.T) { t.Fatalf("NewVerifier: %v", err) } - path := unlockConfig.OfferPrefix + "/chat" + path := testOfferPrefix + "/chat" challengeReq := httptest.NewRequest(http.MethodGet, path, nil) cw := httptest.NewRecorder() v.HandleProxy(cw, challengeReq) @@ -510,10 +516,12 @@ func TestPaidUnlock_RejectsTamperedPayment(t *testing.T) { Routes: []RouteRule{{ Pattern: "/services/agent/*", Gate: "auth", - StripPrefix: unlockConfig.OfferPrefix, + StripPrefix: testOfferPrefix, UpstreamURL: upstream.URL, OfferNamespace: "test", OfferName: "agent", + // Selects the unlock gate: isUnlockOffer keys on the agent type. + AgentRuntime: "hermes", }}, AuthCaptureUnlock: &unlockConfig, }) @@ -521,7 +529,7 @@ func TestPaidUnlock_RejectsTamperedPayment(t *testing.T) { t.Fatalf("NewVerifier: %v", err) } - path := unlockConfig.OfferPrefix + "/chat" + path := testOfferPrefix + "/chat" cw := httptest.NewRecorder() v.HandleProxy(cw, httptest.NewRequest(http.MethodGet, path, nil)) var challenge struct { diff --git a/internal/x402/authgate.go b/internal/x402/authgate.go index 96e110d78..d60ee25a8 100644 --- a/internal/x402/authgate.go +++ b/internal/x402/authgate.go @@ -282,18 +282,19 @@ func (v *Verifier) handleAuthEndpoints(w http.ResponseWriter, r *http.Request) b return false } cfg := v.config.Load() - if cfg != nil && cfg.AuthCaptureUnlock != nil && cfg.AuthCaptureUnlock.Enabled && - strings.TrimSuffix(prefix, "/") == strings.TrimSuffix(cfg.AuthCaptureUnlock.OfferPrefix, "/") { - // ponytail: unlock-gated offer: paid /unlock is the only mint path; free SIWX signin - // suppressed here. Ceiling: one unlock offer (global config); per-offer via CRD is the - // follow-up. - return false - } // Only offers that actually declare an auth route get sign-in // endpoints — everything else falls through to normal route matching // (and its fail-closed handling). rule := v.authRuleForPrefix(prefix) + + if v.isUnlockOffer(cfg, rule) { + // Unlock-gated offer: paid /unlock is the only mint path, so the free + // SIWX sign-in is suppressed here. Selection is by offer type — see + // isUnlockOffer — so this now covers every agent offer rather than a + // single configured prefix. + return false + } if rule == nil { return false } diff --git a/internal/x402/unlock_agent_default_test.go b/internal/x402/unlock_agent_default_test.go new file mode 100644 index 000000000..edd012b95 --- /dev/null +++ b/internal/x402/unlock_agent_default_test.go @@ -0,0 +1,78 @@ +package x402 + +import "testing" + +// TestIsUnlockOffer_SelectsAgentsOnly pins the product rule: the paid unlock +// (and its platform fee split) applies to AGENT offers and only agent offers. +// +// An agent is sold as a conversation a human opens in the chat widget — +// connect wallet, pay once, then per-turn billing — so paid sign-in belongs to +// the type. http offers are machine-to-machine APIs with no session concept +// and must stay on plain per-request `exact` payments. +// +// This replaces selection by a configured offerPrefix, which capped a stack at +// one unlock offer and required the operator to opt in by hand. +func TestIsUnlockOffer_SelectsAgentsOnly(t *testing.T) { + enabled := validAuthCaptureConfig() + cfg := &PricingConfig{AuthCaptureUnlock: &enabled} + + agent := &RouteRule{StripPrefix: "/services/analyst", AgentRuntime: "hermes"} + httpOffer := &RouteRule{StripPrefix: "/services/kalshi-intel"} + + var v Verifier + if !v.isUnlockOffer(cfg, agent) { + t.Error("agent offer must be unlock-gated — the fee ships with the product, not per-offer opt-in") + } + if v.isUnlockOffer(cfg, httpOffer) { + t.Error("http offer must NOT be unlock-gated — gating it would tax per-request API traffic") + } + + // Every agent on the stack is covered, not just one: the old offerPrefix + // ceiling is gone. + second := &RouteRule{StripPrefix: "/services/auditor", AgentRuntime: "hermes"} + if !v.isUnlockOffer(cfg, second) { + t.Error("a second agent offer must also be unlock-gated (no one-offer-per-stack ceiling)") + } +} + +// TestIsUnlockOffer_RespectsDisableAndNils keeps the kill switch honest: an +// operator can still turn the gate off entirely, and a nil rule or config must +// never select the paid path. +func TestIsUnlockOffer_RespectsDisableAndNils(t *testing.T) { + off := validAuthCaptureConfig() + off.Enabled = false + agent := &RouteRule{StripPrefix: "/services/analyst", AgentRuntime: "hermes"} + + var v Verifier + if v.isUnlockOffer(&PricingConfig{AuthCaptureUnlock: &off}, agent) { + t.Error("disabled config must not gate anything") + } + if v.isUnlockOffer(&PricingConfig{}, agent) { + t.Error("absent authCaptureUnlock must not gate anything") + } + if v.isUnlockOffer(nil, agent) { + t.Error("nil config must not gate anything") + } + enabled := validAuthCaptureConfig() + if v.isUnlockOffer(&PricingConfig{AuthCaptureUnlock: &enabled}, nil) { + t.Error("nil rule must not gate anything") + } +} + +// TestAuthCaptureUnlockConfig_PriceResolvedPerOffer guards the multi-agent +// correctness bit. Price, payTo and network are overrides now; with several +// agents on a stack they must fall back to each offer's own values, or every +// agent's unlock revenue lands in one wallet at one price. +func TestAuthCaptureUnlockConfig_PriceResolvedPerOffer(t *testing.T) { + c := validAuthCaptureConfig() + c.Price = "" + if err := c.Validate(); err == nil { + t.Error("an unlock with no price from config OR offer must be rejected, not advertised at zero") + } + + c = validAuthCaptureConfig() + c.Price = "0.01" + if err := c.Validate(); err != nil { + t.Errorf("priced config must validate: %v", err) + } +} diff --git a/internal/x402/unlockgate.go b/internal/x402/unlockgate.go index 22e337eb4..5df73ea76 100644 --- a/internal/x402/unlockgate.go +++ b/internal/x402/unlockgate.go @@ -18,10 +18,25 @@ import ( // inline auth-capture payment on its first request; handleAuthEndpoints // suppresses its free SIWX sign-in endpoints. -// isUnlockOffer reports whether rule is the configured auth-capture unlock offer. +// isUnlockOffer reports whether rule is an auth-capture unlock offer. +// +// Selection is by OFFER TYPE, not by a configured path: every agent offer is +// unlock-gated, http offers never are. An agent is sold as a conversation a +// human opens in the chat widget — connect wallet, pay once, then per-turn +// billing — so the paid sign-in belongs to the whole type. http offers are +// machine-to-machine APIs with no session concept, and gating them would tax +// per-request traffic that never signs in. +// +// Deriving this from the type rather than an operator-set offerPrefix is what +// makes the platform fee ship WITH the product: an operator does not opt in +// per offer, and there is no longer a one-unlock-offer-per-stack ceiling. +// +// rule.AgentRuntime is populated from the ServiceOffer in serviceoffer_source.go +// and is the same signal mergeAgentExtras already uses to decide a rule is an +// agent, so this adds no new plumbing. func (v *Verifier) isUnlockOffer(cfg *PricingConfig, rule *RouteRule) bool { return cfg != nil && cfg.AuthCaptureUnlock != nil && cfg.AuthCaptureUnlock.Enabled && - strings.TrimSuffix(rule.StripPrefix, "/") == strings.TrimSuffix(cfg.AuthCaptureUnlock.OfferPrefix, "/") + rule != nil && rule.AgentRuntime != "" } // handlePaidUnlock runs the auth-capture pay->settle->mint flow for the unlock @@ -36,8 +51,25 @@ func (v *Verifier) handlePaidUnlock(w http.ResponseWriter, r *http.Request, rule } uc := *cfg.AuthCaptureUnlock + // Resolve per-offer fallbacks BEFORE validating: every agent offer is now + // unlock-gated, so price and the seller leg come from the OFFER unless the + // operator pinned them globally. Without this each agent's unlock revenue + // would land in one wallet at one price. Validate then sees the values that + // will actually be advertised. + if uc.Price == "" { + // One turn's worth buys the session. + uc.Price = rule.Price + } + if uc.PayTo == "" { + uc.PayTo = rule.PayTo + } + if uc.Network == "" { + uc.Network = rule.Network + } + if err := uc.Validate(); err != nil { - log.Printf("x402-verifier: auth-capture unlock config invalid: %v", err) + log.Printf("x402-verifier: auth-capture unlock config invalid for %s/%s: %v", + rule.OfferNamespace, rule.OfferName, err) writeUnlockJSON(w, http.StatusInternalServerError, map[string]any{"error": "unlock_misconfigured"}) return }