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-builtx402-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.

LegResult
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

      , 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
       blocks
      (function() {
      function addCopyButtons() {
      document.querySelectorAll('pre code').forEach(function(codeBlock) {
      if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
      codeBlock.parentElement.setAttribute('data-copy-added', 'true');
      var btn = document.createElement('button');
      btn.textContent = 'Copy';
      btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
      btn.onmouseover = function() { this.style.opacity = '1'; };
      btn.onmouseout = function() { this.style.opacity = '0.7'; };
      btn.onclick = function() {
      navigator.clipboard.writeText(codeBlock.textContent).then(function() {
      btn.textContent = 'Copied!';
      setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
      });
      };
      codeBlock.parentElement.style.position = 'relative';
      codeBlock.parentElement.appendChild(btn);
      });
      }
      addCopyButtons();
      // Re-run on dynamic content
      var observer = new MutationObserver(addCopyButtons);
      observer.observe(document.body, { childList: true, subtree: true });
      })();
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      auth-capture unlock (#798): integration notes, dependencies, deferred work · Issue #799 · ObolNetwork/obol-stack · GitHub
      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-builtx402-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.

      LegResult
      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

          , 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' auth-capture unlock (#798): integration notes, dependencies, deferred work · Issue #799 · ObolNetwork/obol-stack · GitHub
          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-builtx402-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.

          LegResult
          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

              , 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' auth-capture unlock (#798): integration notes, dependencies, deferred work · Issue #799 · ObolNetwork/obol-stack · GitHub
              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-builtx402-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.

              LegResult
              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

                  , 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' auth-capture unlock (#798): integration notes, dependencies, deferred work · Issue #799 · ObolNetwork/obol-stack · GitHub
                  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-builtx402-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.

                  LegResult
                  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

                      , 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' auth-capture unlock (#798): integration notes, dependencies, deferred work · Issue #799 · ObolNetwork/obol-stack · GitHub
                      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-builtx402-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.

                      LegResult
                      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

                          , 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' auth-capture unlock (#798): integration notes, dependencies, deferred work · Issue #799 · ObolNetwork/obol-stack · GitHub
                          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-builtx402-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.

                          LegResult
                          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

                              , 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); auth-capture unlock (#798): integration notes, dependencies, deferred work · Issue #799 · ObolNetwork/obol-stack · GitHub
                              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-builtx402-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.

                              LegResult
                              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