Skip to content

auth-capture unlock (#798): integration notes, dependencies, deferred work #799

Description

@bussyjd

Integration notes for #798 (moved out of the PR tree — tracking issue instead of a committed doc).

Status update (2026-07-20)

  • Facilitator side is unblocked: overlay image ghcr.io/obolnetwork/x402-facilitator-prometheus-overlay:2.0.2-auth-capture.2 serves v2-eip155-auth-capture (the .1 tag does not register the blueprint — don't use it). obol-infrastructure#2986 pins .2 for the hosted facilitator.
  • Proven end-to-end on Base mainnet in a live cluster (fee split 250bps on-chain, cookie mint, free ride, fee metrics materialized) in addition to the Base Sepolia smoke below.

Auth-capture agent-unlock — integration notes

Platform fee capture on the seamless agent chat, via the x402 auth-capture scheme
(native on-chain fee split: the facilitator's charge() pays feeBpsfeeRecipient).
Branch: feat/authcapture-unlock (off integration/v0.14.0-rc0). Verifier side complete +
unit-tested; not deployed (see Dependencies).

Design — inline first-message fee (no gate ceremony)

The fee is captured once, on the first chat message, and folded invisibly into the price
(auth-capture bounds the cut with buyer-signed [minFeeBps,maxFeeBps]; the buyer sees one
total, no "platform fee" line). No /unlock endpoint, no button, no "unlock" wording.

first message (no cookie)  → 402 { accepts:[auth-capture requirement] }
   client pays (1 popup)   → facilitator /verify → /settle   (charge(): feeBps→obol, net→seller)
   settle success          → mint obol_siwx session cookie  → proxy the answer back
every message after        → cookie present → existing free gate:auth proxy path (no payment)

The unlock offer is a gate:auth route whose session is minted by paying instead of by a
SIWX signature. Mechanically: in HandleProxy's rule.IsAuth() branch, when Authenticate
fails AND the rule is the configured unlock offer → handlePaidUnlock (verify→settle→mint→proxy);
otherwise the normal SIWX challenge. The free <offer>/auth sign-in is suppressed for the unlock
offer (handleAuthEndpoints) so paying is the only way to mint the session.

Semantic decision: settle happens before proxying — the payment buys the session, not the
single answer. If the upstream errors on that first message, the session is still established and
the retry is free. This is intended for "fee at unlock" (differs from per-message exact, which
settles only on upstream success).

Files (all internal/x402/ unless noted)

  • authcapture.go (new) — AuthCaptureUnlockConfig, Validate(), BuildAuthCaptureRequirement().
  • unlockgate.go (new) — isUnlockOffer(), handlePaidUnlock() (verify→settle→mint→proxy).
  • config.goPricingConfig.AuthCaptureUnlock *AuthCaptureUnlockConfig (nil = off).
  • verifier.goHandleProxy IsAuth branch calls handlePaidUnlock for the unlock offer.
  • authgate.go — suppresses free /auth sign-in for the unlock offer.
  • monetizeapi/types.go — scheme enum widened exactexact;auth-capture.

Wire format the requirement MUST carry (facilitator AuthCapturePaymentRequirementsExtra)

Strict camelCase extra, every key required or the facilitator returns HTTP 400 invalid_format:
name, version, captureAuthorizer, captureDeadline, refundDeadline, feeRecipient, minFeeBps, maxFeeBps, autoCapture(=true), assetTransferMethod. BuildAuthCaptureRequirement mirrors the
facilitator's verify.rs checks (fee bounds ≤10000, non-zero feeRecipient when maxFeeBps>0,
non-zero captureAuthorizer, captureDeadline > now+6s ≤ refundDeadline) so a bad config fails
locally before the round-trip. Source of truth: x402-rs beta/batch-settlement-v2
crates/chains/x402-chain-eip155/src/v2_eip155_auth_capture/{types,constants}.rs + facilitator/verify.rs.

Dependencies / blockers (why it's not live)

  1. Facilitator must serve auth-capture. PR Helm chart merges default tcpSocket probe with custom httpGet probe, causing invalid Pod spec (multiple handler types) #9 is MERGED but only on x402-rs
    beta/batch-settlement-v2 — still needs a :beta image built + auth-capture enabled per-chain
    on x402.gcp.obol.tech, then /supported must advertise it (and hand out the captureAuthorizer
    operator address). As of writing the production facilitator returns no available server (down).
  2. captureAuthorizer must equal the facilitator's advertised operator (verify.rs rejects a
    mismatch). v1 takes it from config; TODO auto-source from /supported.

Config to fill before enabling (PricingConfig.authCaptureUnlock)

enabled, offerPrefix (the chat offer's StripPrefix), price, network (chains-map key e.g.
base), payTo (seller), feeRecipient (obol platform wallet), minFeeBps/maxFeeBps
(the platform fee rate — product decision), captureAuthorizer (from /supported),
captureDeadlineSecs/refundDeadlineSecs (default 900/1800).

Deferred / known gaps

  • Client widget: the chat widget must handle the auth-capture 402 on the first message
    (sign the ERC-3009 auth into AuthCaptureEscrow via @x402). Not built here (verifier-only).
  • Traefik ForwardAuth mode (HandleVerify): no paid-unlock branch — the settle-then-proxy model
    doesn't fit an auth-only hop. Agent chat runs via in-process HandleProxy, so unaffected; a
    ForwardAuth-mode unlock would need the hop to settle + return Set-Cookie + 200 and let Traefik
    forward. Follow-up if ForwardAuth deployments need it.
  • Per-offer config via CRD: v1 is one global unlock offer (offerPrefix). Multi-offer = CRD
    regen + thread the config per-route.
  • Live smoke: deferred until dependency Bring obolup code to this repo #1 clears (mirror the batch-settlement Base-Sepolia smoke:
    first paid message settles + mints cookie on-chain, second rides free).

Test status

go test ./internal/x402/... green. TestBuildAuthCaptureRequirement_ExtraShape (10-key wire shape),
TestAuthCaptureUnlockConfig_Validate (table), TestPaidUnlock_InlineFirstMessage (stub facilitator

  • stub upstream: 402→pay→200+UNLOCKED-OK+cookie→cookie-only free ride with facilitator call count flat).

Smoke — PROVEN end-to-end on Base Sepolia (2026-07-20)

Ran the real Go verifier against a locally-built x402-facilitator (x402-rs beta/batch-settlement-v2,
--features chain-eip155, auth-capture enabled on eip155:84532) — the production facilitator image
is still blocked on CI (Docker-Hub-login step fails) so we built + ran it locally. Escrow
AuthCaptureEscrow 0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff + ERC-3009 collector
0x0E3dF9510de65469C4518D7843919c0b8C7A7757 already live on Base Sepolia. Client @x402/evm v2.19.0
AuthCaptureEvmScheme. Roles: payer B 0xb035…27Bf; facilitator signer/operator = captureAuthorizer
Alice 0xC0De…297E; seller 0x04AD…1582; feeRecipient (obol) 0x5Ae2…c9db.

Leg Result
First message → 402 auth-capture (v2)
B signs ERC-3009 into escrow, resubmits ✓ 200, body UNLOCKED-OK, obol_siwx cookie set
On-chain fee split (charge()) feeRecipient +25 atomic (2.5%), seller +975 (net) — settle tx 0xdfbdd89128b590c4077995ce34f98d7d6e4e28fc042891afb1ecb3613b2c8549
Fixed-rate exactness (minFeeBps==maxFeeBps==250) ✓ fee == amount*250/10000 exactly
Second message on the cookie, NO payment ✓ 200 UNLOCKED-OK, seller delta 0 — fee captured once per session

Smoke harness (not committed): scratchpad/authcap-smoke/{facilitator-config.json,smoke-client.mjs},
cmd/authcap-smoke/main.go.

Fixes the smoke surfaced (folded into the feature)

  1. Challenge must advertise x402Version: 2 (was 1). auth-capture is a v2 scheme; the @x402
    client rejects x402Version != 2, and the verifier already settles with v2. (unlockgate.go)
  2. Settle against the client's signed payload.accepted, not a rebuilt requirement. auth-capture's
    signed PaymentInfo hash commits the server-issued deadlines; rebuilding them (fresh time.Now)
    drifts the hash → signature rejected. The verifier now takes the client's signed requirement AND
    validates every economically-sensitive field against config (validateSignedUnlockRequirement) so a
    client can't redirect the fee, underpay, or swap the authorizer. (unlockgate.go)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions