From 26c058d71ec2bff7fe6b793998e3aa0036ebfc85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Thu, 2 Jul 2026 02:38:12 +0100 Subject: [PATCH 1/5] feat(buy-x402): go auto-dispatch front door and buyer UX hardening --- internal/embed/skills/buy-x402/SKILL.md | 42 ++- internal/embed/skills/buy-x402/scripts/buy.py | 357 ++++++++++++++++-- 2 files changed, 353 insertions(+), 46 deletions(-) diff --git a/internal/embed/skills/buy-x402/SKILL.md b/internal/embed/skills/buy-x402/SKILL.md index a3e02a5d..71a9e8de 100644 --- a/internal/embed/skills/buy-x402/SKILL.md +++ b/internal/embed/skills/buy-x402/SKILL.md @@ -1,12 +1,24 @@ --- name: buy-x402 -description: "Buy from any x402-gated endpoint. Two flows: `pay` for one-shot HTTP services (single authorization, no sidecar), and `buy` for long-running paid inference (pre-authorized batch via PurchaseRequest, exposed as `paid/`). Supports USDC (EIP-3009) and OBOL (Permit2). Zero signer access at runtime — spending is capped by design and nothing moves on-chain until a voucher is spent." +description: "Buy from any x402-gated endpoint. Start with `go ` — it probes, detects the offer type, and runs the right flow. Expert flows underneath: `pay` for one-shot HTTP services (single authorization, no sidecar), `pay-agent` for one-shot streaming agent calls, and `buy` for long-running paid inference (pre-authorized batch via PurchaseRequest, exposed as `paid/`). Supports USDC (EIP-3009) and OBOL (Permit2). Zero signer access at runtime — spending is capped by design and nothing moves on-chain until a voucher is spent." metadata: { "openclaw": { "emoji": "\ud83d\uded2", "requires": { "bins": ["python3"] } } } --- # Buy x402 -Purchase access to remote x402-gated services. There are two flows, picked by usage shape: +Purchase access to remote x402-gated services. + +**Not sure which command? Use `go`.** It probes the URL, reads the 402 to detect what's being sold, and dispatches with the right path, method, streaming mode, and timeout: + +```bash +# Agent or chat-inference offer — one paid round of work, streamed: +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py go --message 'your task' + +# Plain HTTP offer — one paid request: +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py go +``` + +`go` never creates persistent state; for a pre-authorized inference pool it points you at `buy`. The expert flows underneath, picked by usage shape: - **`pay `** — single-shot. Probe the URL, sign **one** payment authorization, attach `X-PAYMENT`, send the request, return the response. Stateless. Use for `type:http` services and any one-off purchase. Max loss = price of one request. Settlement normally lands only after the request succeeds — but a facilitator can submit the settle tx on-chain and *then* fail the request. When that happens the failure report prints `⚠️ SETTLEMENT MAY HAVE COMPLETED ON-CHAIN` with the tx hash: verify with `balance --chain ` before retrying (mechanism: docs/observability.md, "Verify settlement against the chain"). Applies to `pay-agent` too. - **`pay-agent --model `** — single-shot paid **streaming** agent call. Same payment shape as `pay` (one auth, X-PAYMENT, max-loss = price), but POSTs to `/v1/chat/completions` with `stream: true` and forwards every SSE event verbatim to stdout as it arrives. Use this for `type:agent` ServiceOffers when the calling agent wants to consume the response *itself* (memory, tool-call traces, partial results) instead of routing it through LiteLLM as a paid alias. Default HTTP read timeout is **1 hour** — agent calls can legitimately run for many minutes; override with `--timeout `. @@ -15,7 +27,9 @@ Purchase access to remote x402-gated services. There are two flows, picked by us Both flows auto-detect the token + transfer method from the seller's 402 response. Currently supported: **USDC via EIP-3009** (Base Sepolia, Base Mainnet, Ethereum Mainnet) and **OBOL via Permit2** (Ethereum Mainnet). -**Multi-currency offers (pick what you pay with).** A seller can advertise several payment options (e.g. *1 USDC on Base* OR *10 OBOL on Ethereum*) in the 402 `accepts[]` array. `probe` lists them all. For `pay`, `pay-agent`, and `buy`, choose one with `--token ` (e.g. `--token OBOL`), `--network `, and/or `--payment-option ` (the 1-based index from `probe`). With a single option the choice is automatic; with several and no selector, the command errors and lists the options (or, on a TTY, prompts). `--token`/`--network` also act as a guard — if the filter matches no advertised option, it aborts rather than paying the wrong asset. +**Multi-currency offers (pick what you pay with).** A seller can advertise several payment options (e.g. *1 USDC on Base* OR *10 OBOL on Ethereum*) in the 402 `accepts[]` array. `probe` lists them all. For `go`, `pay`, `pay-agent`, and `buy`, choose one with `--token ` (e.g. `--token OBOL`), `--network `, and/or `--payment-option ` (the 1-based index from `probe`). With a single option the choice is automatic. With several and no selector: a TTY prompts; non-interactive runs **auto-select the first option the wallet can afford** (announced loudly, with the full option list, before any signing). `--token`/`--network` also act as a guard — if the filter matches no advertised option, it aborts rather than paying the wrong asset. + +**Money amounts (`--budget`, `--cost-cap`).** Both accept atomic units (`1500000`), token units (`1.5`), or token units with a symbol (`'1.5 USDC'`, `'$1.50'`). Anything with a decimal point, symbol, or `$` is treated as token units and scaled by the asset's decimals; the parsed interpretation is echoed before signing. A symbol that contradicts the asset being paid with aborts. **Auth expiry (`OBOL_X402_AUTH_TTL` / `--auth-ttl`).** A pre-signed pool is spent over time, so each auth carries a *spendability* deadline — distinct from the per-request settle window (`maxTimeoutSeconds`). One knob controls **both** payment methods (Permit2 `deadline` and ERC-3009 `validBefore`): default **30 days (1 month)**; pass a number of seconds (floored at 600s = the verifier's default settle window, so an auth cannot expire between request acceptance and settlement); or pass **`never`** (also `0`/`none`) for a non-expiring pool (mapped to the uint sentinel `4294967295`, ~year 2106, which both contracts accept). Set per-buy with `--auth-ttl ` or globally via the `OBOL_X402_AUTH_TTL` env. A too-short value silently expires the pool minutes after buy. @@ -85,10 +99,13 @@ This is one tx, ~46k gas, valid forever (unless the user later revokes). EIP-300 storefront publishes machine-readable metadata at `/api/services.json` with full asset, EIP-712 signing domain, transfer method, and atomic-unit price for every offered service. -- **`pay` timeout defaults to ~100 s.** This is the Cloudflare free-tier - tunnel cap — longer requests get killed by the edge before our client - ever sees a response. Reasoning models, long generations, or large - batches need `--timeout ` set explicitly, and the seller's own +- **`pay` timeout defaults: ~100 s for http, 600 s for inference.** The + 100 s http default matches the Cloudflare free-tier tunnel cap — longer + requests get killed by the edge before our client ever sees a response. + Non-streaming inference gets 600 s, but streaming (`go` / `pay-agent`) + is the safer shape for slow models: a client-side kill on a non-streamed + paid call risks paying for a response that was still coming, and a blind + retry double-pays. `--timeout ` overrides; the seller's own upstream/edge limit still applies. - **Avoid `/` in remote model identifiers.** LiteLLM's `paid/*` wildcard route only matches a single segment; a remote `vendor/model` would @@ -100,6 +117,7 @@ This is one tx, ~46k gas, valid forever (unless the user later revokes). EIP-300 ## When to Use +- Any one-off purchase where you'd have to guess the offer type — `go` (probes, classifies, dispatches) - Probing an endpoint to check pricing before buying — `probe` - One-shot paid HTTP request (e.g. `demo-hello`, sponsored API endpoints) — `pay` - One-shot paid streaming agent call where the calling agent wants to consume the response itself — `pay-agent` @@ -119,6 +137,11 @@ This is one tx, ~46k gas, valid forever (unless the user later revokes). EIP-300 ## Quick Start ```bash +# One command for any one-off purchase: probe, classify, pay. +# Agent / chat offers stream the paid response; HTTP offers do a single paid request. +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py go https://seller.example.com/services/demo-quant --message 'summarize the latest research on staking' +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py go https://seller.example.com/services/demo-hello + # Probe an inference endpoint to see its pricing (default --type inference) python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py probe https://seller.example.com/services/my-model/v1/chat/completions @@ -185,14 +208,15 @@ python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py maint | Command | Description | |---------|-------------| +| `go [--message ] [--data ] [--method GET\|POST]` | Probe, detect offer type (agent / chat inference / http), dispatch to the right flow | | `probe [--model ] [--type http\|inference\|agent] [--method GET\|POST]` | Send request without payment, parse 402 response for pricing | | `pay [--type http\|inference] [--method GET\|POST] [--data ]` | Single-shot paid request: sign 1 auth, attach X-PAYMENT, send | | `pay-agent --model [--message \| --data ] [--timeout ]` | Single-shot paid streaming agent call: SSE events flush to stdout as they arrive (default timeout 1h) | -| `buy --endpoint --model [--budget N] [--count N]` | Pre-sign auths, create/update `PurchaseRequest`, expose `paid/` | +| `buy --endpoint --model [--budget ] [--count N]` | Pre-sign auths, create/update `PurchaseRequest`, expose `paid/`. `--budget` takes atomic units or token units (`'1.5 USDC'`) | | `buy --endpoint --model --set-default [--auto-refill]` | As above, then set `paid/` as the agent's own primary model in-pod (no restart, no host CLI) | | `process \| --all` | Reconcile `autoRefill` policies against live `x402-buyer` status | | `list` | List purchased providers + remaining auth counts | -| `status ` | Check sidecar pod status + remaining auths | +| `status ` | Check sidecar pod status + remaining auths + auth-expiry countdown | | `balance [--chain ]` | Check agent's USDC balance via eRPC | ## Surfaces diff --git a/internal/embed/skills/buy-x402/scripts/buy.py b/internal/embed/skills/buy-x402/scripts/buy.py index caf4cbc5..8b0b571f 100644 --- a/internal/embed/skills/buy-x402/scripts/buy.py +++ b/internal/embed/skills/buy-x402/scripts/buy.py @@ -32,6 +32,7 @@ import http.client import json import os +import re import secrets import shutil import subprocess @@ -281,6 +282,58 @@ def _format_amount(amount, asset, extra=None): return f"{raw} {units_label} ({scaled_str} {symbol})" +def _parse_money_amount(value, asset, extra=None): + """Parse a human or atomic money amount into atomic units (int). + + Accepted forms: + - bare integer → atomic units, unchanged ("1500000") + - decimal → token units scaled by the asset's decimals ("1.5") + - decimal + symbol / "$" → token units ("1.5 USDC", "1.5USDC", "$1.50") + + Weak buyers reliably pass dollars where micro-units are expected; treating + any decimal point / symbol / $ as token units and echoing the + interpretation at the call site turns that from a silent 1,000,000x + budgeting error into a non-event. Raises ValueError with a corrective + message on anything unparseable, or on a symbol that contradicts the + asset being paid with. + """ + raw = str(value).strip() + if not raw: + raise ValueError("empty amount") + + symbol, decimals, _ = _asset_display_meta(asset, extra) + + text = raw + if text.startswith("$"): + text = text[1:].strip() + # Trailing token symbol, with or without a space ("1.5 USDC", "1.5USDC"). + m = re.match(r"^([0-9][0-9_,]*(?:\.[0-9]+)?)\s*([A-Za-z]{2,10})?$", text) + if not m: + raise ValueError( + f"could not parse amount {raw!r} — pass atomic units (e.g. 1500000), " + f"token units (e.g. 1.5), or token units with symbol (e.g. '1.5 {symbol}')" + ) + number, sym = m.group(1).replace(",", "").replace("_", ""), m.group(2) + if sym and symbol not in (None, "asset") and sym.upper() != symbol.upper(): + raise ValueError( + f"amount {raw!r} names {sym.upper()} but this payment settles in {symbol} — " + f"restate the amount in {symbol}" + ) + + human_units = raw.startswith("$") or sym is not None or "." in number + if not human_units: + return int(number) + if decimals is None: + raise ValueError( + f"cannot scale {raw!r}: unknown decimals for asset {asset} — pass atomic units instead" + ) + whole, _, frac = number.partition(".") + frac = (frac or "").ljust(decimals, "0") + if len(frac) > decimals: + raise ValueError(f"amount {raw!r} has more precision than {symbol}'s {decimals} decimals") + return int(whole or "0") * (10 ** decimals) + int(frac or "0") + + # --------------------------------------------------------------------------- # Buyer sidecar status helpers # --------------------------------------------------------------------------- @@ -408,21 +461,25 @@ def _default_refill_threshold(count): return max(1, count // DEFAULT_REFILL_THRESHOLD_DIVISOR) -def _resolve_auto_refill(opts, desired_count, existing_policy=None): +def _resolve_auto_refill(opts, desired_count, existing_policy=None, asset=None, asset_extra=None): existing_policy = existing_policy or {} auto_refill = _parse_boolish(opts.get("auto_refill"), "--auto-refill") threshold = _parse_positive_int(opts.get("refill_threshold"), "--refill-threshold", minimum=0) refill_count = _parse_positive_int(opts.get("refill_count"), "--refill-count", minimum=1) - # --cost-cap is a per-unit price ceiling (atomic units) the refill loop - # checks against the seller's current quote before re-signing. It does - # NOT bound the initial buy — the initial buy's protection is --budget. + # --cost-cap is a per-unit price ceiling the refill loop checks against + # the seller's current quote before re-signing. It does NOT bound the + # initial buy — the initial buy's protection is --budget. Accepts atomic + # units or human token units ("0.002 USDC") when asset context is known. cost_cap_raw = opts.get("cost_cap") cost_cap = None if cost_cap_raw is not None: try: - cost_cap = int(str(cost_cap_raw)) - except (TypeError, ValueError): - raise ValueError(f"--cost-cap must be an integer atomic-units value, got {cost_cap_raw!r}") + if asset is not None: + cost_cap = _parse_money_amount(cost_cap_raw, asset, asset_extra) + else: + cost_cap = int(str(cost_cap_raw)) + except (TypeError, ValueError) as exc: + raise ValueError(f"--cost-cap: {exc}") if cost_cap <= 0: raise ValueError("--cost-cap must be > 0") @@ -618,6 +675,30 @@ def _expired_in_active_pool(spec, live_status): return expired +def _pool_expiry_horizon(spec, live_status, now=None): + """Earliest on-chain deadline among still-valid auths in the active pool. + + Returns a unix timestamp, or None when no auth carries a future deadline. + Pools expire silently otherwise — the first symptom is a failed spend + weeks later — so `status` surfaces a countdown.""" + if now is None: + now = int(time.time()) + try: + pool = _active_auth_pool(spec.get("preSignedAuths"), live_status) + except ValueError: + pool = spec.get("preSignedAuths") or [] + if not pool: + # Sidecar unreachable / reporting zero: the countdown is informational, + # so fall back to the full declared pool rather than staying silent. + pool = spec.get("preSignedAuths") or [] + deadlines = [] + for a in pool or []: + deadline = _auth_deadline(a) + if deadline is not None and deadline > now: + deadlines.append(deadline) + return min(deadlines) if deadlines else None + + def _build_active_auth_pool(existing_auths, live_status, new_auths): return _active_auth_pool(existing_auths, live_status) + list(new_auths or []) @@ -1441,12 +1522,15 @@ def _reconcile_purchase_autorefill(pr, live_status, signer_address): # Probe # --------------------------------------------------------------------------- -def _probe_endpoint(endpoint_url, model_id="test", kind="inference", method=None): +def _probe_endpoint(endpoint_url, model_id="test", kind="inference", method=None, quiet=False): """Probe an endpoint for x402 pricing. Returns parsed 402 body or None. kind="inference" appends /v1/chat/completions and POSTs a chat-completions body (the inference contract). kind="http" sends the URL as-is using `method` (default GET) with no body — appropriate for `type:http` ServiceOffers. + + quiet=True suppresses the failure prints — used by `go`, which probes two + URL shapes and only wants noise from the attempt that mattered. """ if kind == "http": url = endpoint_url.rstrip("/") @@ -1472,25 +1556,29 @@ def _probe_endpoint(endpoint_url, model_id="test", kind="inference", method=None except urllib.error.HTTPError as e: if e.code != 402: body = e.read().decode() if e.fp else "" - print(f"Unexpected HTTP {e.code} (expected 402).", file=sys.stderr) - if body: - print(f"Body: {body[:500]}", file=sys.stderr) + if not quiet: + print(f"Unexpected HTTP {e.code} (expected 402).", file=sys.stderr) + if body: + print(f"Body: {body[:500]}", file=sys.stderr) return None body = e.read().decode() try: pricing = json.loads(body) except json.JSONDecodeError: - print(f"402 response is not valid JSON: {body[:200]}", file=sys.stderr) + if not quiet: + print(f"402 response is not valid JSON: {body[:200]}", file=sys.stderr) return None if not pricing.get("accepts"): - print("402 response has no 'accepts' array.", file=sys.stderr) + if not quiet: + print("402 response has no 'accepts' array.", file=sys.stderr) return None return pricing except urllib.error.URLError as e: - print(f"Connection error: {e.reason}", file=sys.stderr) + if not quiet: + print(f"Connection error: {e.reason}", file=sys.stderr) return None @@ -1592,10 +1680,55 @@ def _select_payment(accepts, token=None, network=None, index=None): return accepts[int(ans) - 1] print(" Enter a number from the list.") - print(f"Error: this service accepts {len(accepts)} payment options — choose one with " - f"--token , --network , or --payment-option :", file=sys.stderr) - _print_options(accepts, sys.stderr) - sys.exit(1) + return _auto_select_payment(accepts) + + +def _auto_select_payment(accepts): + """Non-interactive default for multi-currency offers: pick the first + option the wallet can actually afford instead of erroring out. + + Hard-erroring here forced every non-TTY buyer (i.e. every agent) to + re-run with a selector flag — a guaranteed extra round-trip that weak + models often fumble. The options are alternatives by design, so paying + with any affordable one is correct; the choice is announced loudly and + every selector flag still overrides. Balance lookups are best-effort: + if the wallet or RPC is unreachable we fall back to option 1 and let the + downstream pre-flight checks produce the precise error. + """ + chosen_idx = None + note = "balance check unavailable — defaulting to option 1" + try: + signer = _get_signer_address() + except Exception: + signer = None + if signer: + for i, acc in enumerate(accepts): + try: + chain = _normalize_chain_name(acc.get("network")) + asset = acc.get("asset") or _canonical_usdc(chain) + if not asset: + continue + amount = int(acc.get("amount", acc.get("maxAmountRequired", "0"))) + balance = int(_get_usdc_balance(signer, asset, chain)) + if balance >= amount: + chosen_idx = i + note = "first option this wallet can afford" + break + except Exception: + continue + if chosen_idx is None and note.startswith("balance check unavailable"): + note = "no option is affordable with the current balances — defaulting to option 1; fund the wallet or pick another option" + if chosen_idx is None: + chosen_idx = 0 + + acc = accepts[chosen_idx] + amount = acc.get("amount", acc.get("maxAmountRequired", "?")) + price = _format_amount(amount, acc.get("asset"), acc.get("extra")) if amount != "?" else "?" + print(f"This service accepts {len(accepts)} payment options; auto-selected " + f"[{chosen_idx + 1}] {price} on {_option_chain(acc)} ({note}).") + print(" Override with --token , --network , or --payment-option :") + _print_options(accepts, sys.stdout) + return acc def cmd_probe(endpoint_url, model_id=None, kind="inference", method=None): @@ -1715,7 +1848,15 @@ def cmd_buy(name, endpoint, model_id, budget=None, count=None, opts=None): print(f" {symbol} balance: {_format_amount(balance, usdc_addr, extra)}") # 4. Calculate count. - budget_val = int(budget) if budget else int(DEFAULT_BUDGET) + if budget: + try: + budget_val = _parse_money_amount(budget, usdc_addr, extra) + except ValueError as exc: + print(f"Error: --budget {exc}", file=sys.stderr) + sys.exit(1) + print(f" Budget: {_format_amount(budget_val, usdc_addr, extra)}") + else: + budget_val = int(DEFAULT_BUDGET) price_int = int(price) if count: n = min(int(count), MAX_AUTH_COUNT) @@ -1824,7 +1965,7 @@ def cmd_buy(name, endpoint, model_id, budget=None, count=None, opts=None): sys.exit(1) n = len(auths) try: - auto_refill = _resolve_auto_refill(opts, n, (existing or {}).get("spec", {}).get("autoRefill")) + auto_refill = _resolve_auto_refill(opts, n, (existing or {}).get("spec", {}).get("autoRefill"), asset=usdc_addr, asset_extra=extra) except ValueError as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) @@ -2190,6 +2331,16 @@ def cmd_status(name): if expired: print(f" WARNING: {expired} of {remaining_display} remaining auth(s) are EXPIRED and unusable; " f"run `buy {name} ...` to top up with fresh authorizations") + horizon = _pool_expiry_horizon(spec, live_status) + if horizon is not None: + secs_left = horizon - int(time.time()) + days_left = secs_left // 86400 + when = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(horizon)) + if days_left >= 1: + print(f"Earliest auth expiry: {when} (in {days_left} day(s))") + else: + print(f" WARNING: auths start expiring at {when} (in {secs_left // 3600}h) — " + f"top up soon with `buy {name} ...`") print(f"Auths spent: {live_status.get('spent', status.get('spent', 0))}") print() @@ -2259,7 +2410,15 @@ def cmd_pay(url, method="GET", data=None, kind="http", network=None, timeout=Non upstream/edge limit. """ if timeout is None or float(timeout) <= 0: - timeout = 100.0 + # http: ~100s matches Cloudflare's quick-tunnel cap — longer requests + # die at the edge before the client would time out anyway. + # inference: model latency routinely exceeds 100s; a client-side kill + # at 100s risks paying for a response that was still coming (and a + # blind retry then double-pays). Streaming via `pay-agent`/`go` is + # still the safer shape for slow models. + timeout = 600.0 if kind == "inference" else 100.0 + if kind == "inference": + print("Note: non-streaming inference call, timeout 600s — prefer `go`/`pay-agent` (streaming) for slow models.") else: timeout = float(timeout) method = (method or "GET").upper() @@ -2667,6 +2826,112 @@ def cmd_maintain(): cmd_process(process_all=True) +# --------------------------------------------------------------------------- +# Go (auto-dispatch front door) +# --------------------------------------------------------------------------- + +def _offer_shape(pricing): + """Classify a 402 pricing doc into ('agent'|'chat'|'http', model_id). + + Every wrong pre-flight decision (pay vs pay-agent, path shape, streaming, + timeout) is derivable from the 402 itself: agent offers advertise + accepts[].extra.agentModel/agentRuntime, chat-completions offers advertise + a messages-shaped body in the bazaar extension. `go` uses this to make + those decisions for the caller. + """ + accepts = pricing.get("accepts") or [] + extra = (accepts[0].get("extra") or {}) if accepts else {} + if extra.get("agentModel") or extra.get("agentRuntime") or extra.get("agentSkills"): + return "agent", extra.get("agentModel") or "" + ext = pricing.get("extensions") or {} + bazaar_info = (ext.get("bazaar") or {}).get("info") or {} + body = (bazaar_info.get("input") or {}).get("body") or {} + if "messages" in body: + return "chat", body.get("model") or "" + return "http", "" + + +def cmd_go(url, opts): + """One-command paid call: probe, classify, dispatch to the right flow. + + - agent / chat-completions offer + --message → streaming pay-agent flow + - agent / chat-completions offer, no --message → explain + exact next command + - plain http offer → single-shot pay flow + All pay/pay-agent selector flags (--token/--network/--payment-option, + --timeout, --data, --method) pass through. + """ + timeout = opts.get("timeout") + if timeout is not None: + try: + timeout = float(timeout) + except ValueError: + print(f"Error: --timeout must be a number of seconds, got '{timeout}'", file=sys.stderr) + sys.exit(1) + + print(f"Probing {url} ...") + # Try the URL exactly as given first (correct for http offers and for + # cluster offers, which gate every sub-path); fall back to the + # chat-completions probe shape for standalone inference gateways that + # only gate /v1/chat/completions. + pricing = _probe_endpoint(url, kind="http", method="GET", quiet=True) + if not pricing: + pricing = _probe_endpoint(url, kind="inference", quiet=True) + if not pricing: + # Re-run the primary probe loudly so the caller sees the real error. + pricing = _probe_endpoint(url, kind="http", method="GET") + if not pricing: + print("Failed to get x402 pricing. Check the URL is the service base " + "(e.g. https://seller.example.com/services/) and reachable.", file=sys.stderr) + sys.exit(1) + + shape, model = _offer_shape(pricing) + message = opts.get("message") + data = opts.get("data") + + if shape in ("agent", "chat"): + label = "Obol Agent" if shape == "agent" else "chat-completions (inference)" + print(f"Detected {label} offer" + (f" (model {model})" if model else "") + " → streaming paid call.") + if not message and not data: + accepts = pricing.get("accepts") or [] + if accepts: + print("Payment options:") + _print_options(accepts, sys.stdout) + print() + print("This endpoint sells one round of chat work — include the prompt to buy:") + print(f" go {url} --message 'your task here'") + if shape == "chat": + print("For a persistent pre-authorized pool (repeat inference), use:") + print(f" buy --endpoint {url} --model {model or ''}") + return + cmd_pay_agent( + url, + messages=message, + model_id=opts.get("model") or model or "auto", + network=opts.get("network"), + timeout=timeout, + body=data, + token=opts.get("token"), + payment_option=opts.get("payment_option"), + ) + return + + print("Detected plain HTTP offer → single-shot paid request.") + if message and not data: + print("Note: --message is for chat/agent offers; this HTTP offer gets a " + "plain request. Pass --data '' to send a body.", file=sys.stderr) + method = opts.get("method") or ("POST" if data else "GET") + cmd_pay( + url, + method=method, + data=data, + kind="http", + network=opts.get("network"), + timeout=timeout, + token=opts.get("token"), + payment_option=opts.get("payment_option"), + ) + + # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- @@ -2700,34 +2965,41 @@ def parse_flags(args): def usage(): print("Usage: python3 scripts/buy.py [args]") print() - print("Commands:") + print("Start here:") + print(" go [--message ''] One-command paid call: probes the endpoint, detects the") + print(" offer type (agent / chat inference / plain HTTP), and runs") + print(" the right flow with sane path, streaming, and timeout.") + print(" Chat/agent offers need --message; HTTP offers take") + print(" [--data ''] [--method GET|POST].") + print(" Also accepts --token/--network/--payment-option/--timeout.") + print() + print("Expert commands (what `go` dispatches to):") print(" probe [--model ] [--type http|inference|agent] [--method GET|POST]") - print(" Probe x402 pricing (default --type inference)") + print(" Probe x402 pricing without paying (default --type inference)") print(" pay [--type http|inference] [--method GET|POST] [--data ''] [--timeout ]") print(" [--token ] [--network ] [--payment-option ]") print(" Single-shot paid request (sign 1 auth, attach X-PAYMENT)") - print(" Multi-currency offers: pick which asset/price to pay with") - print(" --token/--network/--payment-option (probe to see options)") print(" pay-agent --model [--message '' | --data ''] [--timeout ]") print(" [--token ] [--network ] [--payment-option ]") print(" Single-shot paid streaming agent call (POST /v1/chat/completions,") - print(" stream: true). Each SSE event flushes to stdout so a calling") - print(" agent can re-emit the stream to its own user. Default timeout 1h.") - print(" buy --endpoint --model Pre-sign + configure paid/") - print(" [--budget ] [--count ]") + print(" stream: true). Each SSE event flushes to stdout. Default timeout 1h.") + print(" buy --endpoint --model Pre-sign a batch + configure paid/ (persistent inference)") + print(" [--budget ] [--count ]") print(" [--token ] [--network ] [--payment-option ] pick the asset/price on multi-currency offers") print(" [--auto-refill[=true|false]] [--refill-threshold ]") - print(" [--refill-count ] [--cost-cap ]") + print(" [--refill-count ] [--cost-cap ]") print(" [--auth-ttl ] [--set-default]") + print(" --budget atomic units (1500000) or token units ('1.5', '1.5 USDC', '$1.50')") print(" --auth-ttl pool expiry: seconds, or 'never' (default 30d/1mo); env OBOL_X402_AUTH_TTL") - print(" --cost-cap per-unit price ceiling (atomic units) for auto-refill — refills above this are skipped") + print(" --cost-cap per-unit price ceiling for auto-refill (same formats as --budget)") print(" --set-default inference only: adopt paid/ as the agent's primary model") - print(" list List purchased providers") - print(" status Check sidecar + auths") + print(" list [--json] List purchased providers") + print(" status Check sidecar + auths (incl. expiry countdown)") print(" process | --all Reconcile auto-refill policies") - print(" balance [--chain ] Check USDC balance") - print(" refill|remove Present for compatibility; not available in controller mode") - print(" maintain Deprecated alias for process --all") + print(" balance [--chain ] Check wallet token balances") + print() + print("Multi-currency offers auto-select the first affordable payment option when") + print("no --token/--network/--payment-option is given (choice is printed).") if __name__ == "__main__": @@ -2740,7 +3012,18 @@ def usage(): cmd = args[0] rest = args[1:] - if cmd == "probe": + if cmd == "go": + positional, opts = parse_flags(rest) + if not positional: + print("Usage: go [--message ''] [--data ''] [--method GET|POST] " + "[--token ] [--network ] [--payment-option ] [--timeout ]", + file=sys.stderr) + sys.exit(1) + if opts.get("auth_ttl") is not None: + os.environ["OBOL_X402_AUTH_TTL"] = str(opts["auth_ttl"]) + cmd_go(positional[0], opts) + + elif cmd == "probe": positional, opts = parse_flags(rest) if not positional: print("Usage: probe [--model ] [--type http|inference|agent]", file=sys.stderr) From cfaf3e9ccf861c647e78d3844bd08ee3b69f95a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Thu, 2 Jul 2026 02:41:16 +0100 Subject: [PATCH 2/5] feat(catalog): single-source buyer prompts, docs deep links, signing metadata - new internal/buyprompts package is the one authoring point for how-to-buy copy; the 402 page, /api/services.json ('buy' block with callShape + prompts per buyer-software kind), the storefront, and 'obol sell info' all render it, so instructions can no longer drift between surfaces (the storefront was still advertising the removed --no-verify-identity flag) - catalog entries gain openapiPath + docsPath (Scalar deep-link anchor); storefront shows API-docs links for every offer type, skill.md service details link the anchored docs - x-payment-info always emits accepts[] with payTo, CAIP-2 network, atomic amount, and the asset's EIP-712 signing domain, so an OpenAPI-only client can construct a valid X-PAYMENT without a second fetch - @scalar/api-reference bumped 1.34.0 -> 1.62.1; renovate custom manager + postUpgradeTasks now keep it current, with scripts/update-scalar-sri.sh refreshing the SRI hash inside the same renovate PR Co-Authored-By: Claude Fable 5 --- .github/workflows/renovate.yml | 5 + cmd/obol/sell_info.go | 9 + internal/buyprompts/buyprompts.go | 269 ++++++++++++++++++ internal/buyprompts/buyprompts_test.go | 94 ++++++ internal/schemas/service-catalog.schema.json | 69 +++++ internal/schemas/service_catalog.go | 27 +- internal/serviceoffercontroller/openapi.go | 106 +++++-- .../serviceoffercontroller/openapi_test.go | 51 ++++ internal/serviceoffercontroller/render.go | 24 ++ .../serviceoffercontroller/scalar_html.go | 19 +- internal/x402/paymentrequired.go | 159 +++-------- renovate.json | 37 +++ scripts/update-scalar-sri.sh | 56 ++++ .../src/components/ServiceCard.tsx | 71 +++-- web/public-storefront/src/types.ts | 33 +++ 15 files changed, 866 insertions(+), 163 deletions(-) create mode 100644 internal/buyprompts/buyprompts.go create mode 100644 internal/buyprompts/buyprompts_test.go create mode 100755 scripts/update-scalar-sri.sh diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml index 2e0a2a30..86ada275 100644 --- a/.github/workflows/renovate.yml +++ b/.github/workflows/renovate.yml @@ -34,5 +34,10 @@ jobs: RENOVATE_DRY_RUN: ${{ github.event.inputs.dry_run == 'true' && 'full' || '' }} RENOVATE_REPOSITORIES: ${{ github.repository }} RENOVATE_BASE_BRANCHES: ${{ github.ref_name }} + # Allow-list for postUpgradeTasks (renovate.json). Only the Scalar + # SRI refresh is permitted: the @scalar/api-reference bump must + # rewrite the SRI hash in the same commit or the /api docs page + # ships a browser-blocked script tag. + RENOVATE_ALLOWED_COMMANDS: '["bash scripts/update-scalar-sri.sh"]' with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/cmd/obol/sell_info.go b/cmd/obol/sell_info.go index 8270d76e..1fb9ccf5 100644 --- a/cmd/obol/sell_info.go +++ b/cmd/obol/sell_info.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/ObolNetwork/obol-stack/internal/buyprompts" "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/kubectl" "github.com/ObolNetwork/obol-stack/internal/schemas" @@ -269,7 +270,15 @@ func serviceHealth(e schemas.ServiceCatalogEntry) string { } // howToBuy renders a concise, type-appropriate purchase hint for a service. +// The published catalog carries the canonical instructions (entry.buy, +// generated by internal/buyprompts); the switch below is only a fallback for +// catalogs published by pre-buy-block controllers. func howToBuy(e schemas.ServiceCatalogEntry) []string { + if e.Buy != nil { + if cli := strings.TrimSpace(e.Buy.Prompts[buyprompts.PromptCLI]); cli != "" { + return []string{cli} + } + } switch e.Type { case "inference": base := endpointBase(e.Endpoint) diff --git a/internal/buyprompts/buyprompts.go b/internal/buyprompts/buyprompts.go new file mode 100644 index 00000000..dbd498d6 --- /dev/null +++ b/internal/buyprompts/buyprompts.go @@ -0,0 +1,269 @@ +// Package buyprompts is the single authoring point for buyer-facing "how to +// buy" instructions. Three surfaces show a buyer how to pay for a service — +// the 402 paywall page (internal/x402/paymentrequired.go), the public +// storefront (web/public-storefront), and the machine-readable catalog +// (/api/services.json via internal/serviceoffercontroller) — and when each +// composed its own copy they drifted: the 402 page taught agent buyers a +// call path that 404'd while every other surface taught the right one. +// +// The controller publishes the output of this package in each catalog +// entry's `buy` block; the storefront renders that block verbatim; the 402 +// page builds its prompt cards from the same functions. Adding support for a +// new kind of buying software means adding one prompt key here — not forking +// a fourth copy of the instructions. +package buyprompts + +import ( + "fmt" + "strings" +) + +// DefaultTaskExample is the placeholder task used in copy-paste prompts and +// wire examples wherever the buyer hasn't supplied a real one. +const DefaultTaskExample = "Summarise the README and list the top 3 risks." + +// Prompt keys published in the catalog `buy.prompts` map. Stable API for +// storefront and downstream consumers. +const ( + // PromptObolAgent is pasted into an Obol Stack agent (Hermes/OpenClaw) + // that has the buy-x402 skill. + PromptObolAgent = "obol-agent" + // PromptGenericLLM is pasted into any other AI agent (Claude, ChatGPT, + // Gemini, ...) with tool access but no Obol tooling. + PromptGenericLLM = "generic-llm" + // PromptCLI is the shell command a human runs from an obol-stack host. + PromptCLI = "cli" +) + +// Input describes one purchasable service. All fields are display-ready +// strings; zero values degrade gracefully (placeholders, omitted clauses). +type Input struct { + // Type is the ServiceOffer type: inference, agent, http, fine-tuning. + // Unknown/empty values get http (single-shot pay) semantics. + Type string + // URL is the service base URL (e.g. https://host/services/). + URL string + // SiteURL is the storefront origin used for discovery references + // (skill.md / openapi.json links). Empty falls back to x402.org. + SiteURL string + // Model is the model id for inference/agent offers ("" when unknown). + Model string + // PriceDisplay is the formatted price (e.g. "0.001 USDC per request"). + PriceDisplay string + // NetworkLabel is the human-friendly chain name (e.g. "Base Sepolia"). + NetworkLabel string + // TaskExample overrides DefaultTaskExample in prompts and examples. + TaskExample string +} + +// CallShape is the machine-readable request recipe for a service. Buying +// software uses it instead of guessing the path/method/streaming mode. +type CallShape struct { + Method string `json:"method"` + // Path is relative to the service base URL ("" = the base itself). + Path string `json:"path,omitempty"` + // BodyKind: "openai-chat" (chat-completions JSON), "json" (operator- + // defined JSON), "multipart" (fine-tuning), or "none". + BodyKind string `json:"bodyKind"` + // Streaming reports whether the endpoint supports (and slow calls + // should use) `"stream": true`. + Streaming bool `json:"streaming"` +} + +// Block is the full buyer-instruction block for one service, published as +// the catalog entry's `buy` field. +type Block struct { + CallShape CallShape `json:"callShape"` + Prompts map[string]string `json:"prompts"` + // Example is a copy-pasteable wire example of one paid request. + Example string `json:"example,omitempty"` +} + +// Build assembles the canonical Block for a service. +func Build(in Input) Block { + switch normalizeType(in.Type) { + case "agent": + return agentBlock(in) + case "inference": + return inferenceBlock(in) + case "fine-tuning": + return fineTuningBlock(in) + default: + return httpBlock(in) + } +} + +// GuideRef is the discovery reference woven into generic-LLM prompts: it +// tells a buyer with no Obol tooling where the full payment recipe lives. +func GuideRef(siteURL string) string { + siteURL = strings.TrimRight(siteURL, "/") + if siteURL == "" { + return "x402 micropayments (see https://www.x402.org)" + } + return fmt.Sprintf( + "x402 micropayments — read %s/skill.md for the full payment flow and %s/openapi.json for the exact request shapes", + siteURL, siteURL, + ) +} + +// ChatCompletionsURL is the canonical paid-call URL for chat-shaped offers +// (inference and agent). Must stay in lockstep with the gateway's tolerant +// path rewrite (internal/x402/verifier.go normalizeChatCompletionsPath) and +// buy.py's target construction. +func ChatCompletionsURL(baseURL string) string { + return strings.TrimSuffix(baseURL, "/") + "/v1/chat/completions" +} + +// ChatExample renders the wire example of one paid chat-completions call. +func ChatExample(url, model, task string) string { + if task == "" { + task = DefaultTaskExample + } + modelClause := "" + if model != "" { + modelClause = fmt.Sprintf("\n \"model\": %q,", model) + } + return fmt.Sprintf(`POST %s +Content-Type: application/json +X-PAYMENT: + +{%s + "messages": [ + {"role": "user", "content": %q} + ] +}`, ChatCompletionsURL(url), modelClause, task) +} + +func normalizeType(t string) string { + switch t { + case "inference", "agent", "fine-tuning": + return t + default: + return "http" + } +} + +func task(in Input) string { + if in.TaskExample != "" { + return in.TaskExample + } + return DefaultTaskExample +} + +func modelOr(in Input, placeholder string) string { + if in.Model != "" { + return in.Model + } + return placeholder +} + +func agentBlock(in Input) Block { + modelFlag := modelOr(in, "") + return Block{ + CallShape: CallShape{ + Method: "POST", + Path: "/v1/chat/completions", + BodyKind: "openai-chat", + Streaming: true, + }, + Prompts: map[string]string{ + PromptObolAgent: fmt.Sprintf( + "Use the buy-x402 skill's `pay-agent` command to buy one round of work from this "+ + "Obol Agent — it has its own skills, tools, and memory, not just a model. Edit the "+ + "message, then run:\n\n"+ + "pay-agent %s --model %q --message %q", + in.URL, modelFlag, task(in), + ), + PromptGenericLLM: fmt.Sprintf( + "Help me call the Obol Agent at %s — it's an autonomous agent (tools + skills + memory), "+ + "not a raw LLM. It's gated by %s. POST OpenAI-style chat-completions JSON to "+ + "%s with this user message in `messages`: {\"role\":\"user\",\"content\":%q}. "+ + "Attach a signed EIP-3009/Permit2 authorization as `X-PAYMENT`, use `\"stream\": true`, "+ + "and report what the agent does.", + in.URL, GuideRef(in.SiteURL), ChatCompletionsURL(in.URL), task(in), + ), + PromptCLI: fmt.Sprintf( + "python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py go %s --message %q", + in.URL, task(in), + ), + }, + Example: ChatExample(in.URL, in.Model, in.TaskExample), + } +} + +func inferenceBlock(in Input) Block { + model := modelOr(in, "") + return Block{ + CallShape: CallShape{ + Method: "POST", + Path: "/v1/chat/completions", + BodyKind: "openai-chat", + Streaming: true, + }, + Prompts: map[string]string{ + PromptObolAgent: fmt.Sprintf( + "There's an Obol paid-inference service at %s offering the %s model. "+ + "Explain to me how it works, then — if I'm interested — run "+ + "`obol buy inference %s` from this host to pre-authorize it and wire "+ + "`paid/%s` into our local LiteLLM gateway. After it lands, switch "+ + "yourself over to the new model and confirm.", + in.URL, model, in.URL, model, + ), + PromptGenericLLM: fmt.Sprintf( + "I want to use the remote LLM at %s (model %s) as a paid OpenAI-compatible "+ + "chat-completions endpoint at %s, paid with %s. Pre-sign a budget of EIP-3009/Permit2 "+ + "authorizations and POST chat-completions bodies with the X-PAYMENT header attached.", + in.URL, model, ChatCompletionsURL(in.URL), GuideRef(in.SiteURL), + ), + PromptCLI: fmt.Sprintf("obol buy inference %s", in.URL), + }, + // The model field is required by chat-completions upstreams for + // inference offers, so the example keeps a placeholder when the + // real id is unknown (agents, by contrast, ignore it). + Example: ChatExample(in.URL, model, ""), + } +} + +func httpBlock(in Input) Block { + priceClause := "" + if in.PriceDisplay != "" { + priceClause = " Pay " + in.PriceDisplay + "." + } + netClause := "" + if in.NetworkLabel != "" { + netClause = " Network: " + in.NetworkLabel + "." + } + return Block{ + CallShape: CallShape{ + Method: "GET", + BodyKind: "none", + }, + Prompts: map[string]string{ + PromptObolAgent: fmt.Sprintf( + "Use the buy-x402 skill's `pay` command to call %s once.%s%s "+ + "Use the method and payload the seller documents, and report what it returns.", + in.URL, priceClause, netClause, + ), + PromptGenericLLM: fmt.Sprintf( + "Call the paid HTTP endpoint at %s once. It's gated by %s.%s%s "+ + "Fetch it with no payment to read the 402 `accepts[]` pricing, sign a matching "+ + "EIP-3009/Permit2 authorization, retry the identical request with the payload "+ + "base64-encoded in the `X-PAYMENT` header, and report the response.", + in.URL, GuideRef(in.SiteURL), priceClause, netClause, + ), + PromptCLI: fmt.Sprintf( + "python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py go %s", + in.URL, + ), + }, + } +} + +func fineTuningBlock(in Input) Block { + block := httpBlock(in) + block.CallShape = CallShape{ + Method: "POST", + BodyKind: "multipart", + } + return block +} diff --git a/internal/buyprompts/buyprompts_test.go b/internal/buyprompts/buyprompts_test.go new file mode 100644 index 00000000..0f79ceec --- /dev/null +++ b/internal/buyprompts/buyprompts_test.go @@ -0,0 +1,94 @@ +package buyprompts + +import ( + "strings" + "testing" +) + +// TestBuild_ChatOffersTeachCanonicalPath pins the invariant that caused the +// original cross-surface drift: every instruction for a chat-shaped offer +// (agent, inference) must name the canonical /v1/chat/completions call path. +// A prompt that teaches the bare service base sends a paying buyer to a 404. +func TestBuild_ChatOffersTeachCanonicalPath(t *testing.T) { + for _, typ := range []string{"agent", "inference"} { + block := Build(Input{ + Type: typ, + URL: "https://seller.example.com/services/demo", + SiteURL: "https://seller.example.com", + Model: "qwen3.5:9b", + }) + + if block.CallShape.Path != "/v1/chat/completions" { + t.Errorf("%s: callShape.path = %q, want /v1/chat/completions", typ, block.CallShape.Path) + } + if block.CallShape.Method != "POST" || block.CallShape.BodyKind != "openai-chat" || !block.CallShape.Streaming { + t.Errorf("%s: callShape = %+v, want POST/openai-chat/streaming", typ, block.CallShape) + } + if !strings.Contains(block.Prompts[PromptGenericLLM], "/v1/chat/completions") { + t.Errorf("%s: generic-llm prompt must name the chat-completions path:\n%s", typ, block.Prompts[PromptGenericLLM]) + } + if !strings.Contains(block.Example, "POST https://seller.example.com/services/demo/v1/chat/completions") { + t.Errorf("%s: example must POST the canonical path:\n%s", typ, block.Example) + } + if !strings.Contains(block.Example, "X-PAYMENT") { + t.Errorf("%s: example must show the X-PAYMENT header", typ) + } + } +} + +// TestBuild_AllTypesCarryEveryPromptKey ensures every surface can rely on +// the three standard prompt keys existing for every offer type. +func TestBuild_AllTypesCarryEveryPromptKey(t *testing.T) { + for _, typ := range []string{"agent", "inference", "http", "fine-tuning", "", "bogus"} { + block := Build(Input{Type: typ, URL: "https://s.example/services/x"}) + for _, key := range []string{PromptObolAgent, PromptGenericLLM, PromptCLI} { + if strings.TrimSpace(block.Prompts[key]) == "" { + t.Errorf("type %q: missing prompt %q", typ, key) + } + } + } +} + +// TestBuild_UnknownTypeGetsHTTPSemantics pins the safe default: unknown +// types must get single-shot pay instructions, never a pre-authorization +// flow. +func TestBuild_UnknownTypeGetsHTTPSemantics(t *testing.T) { + block := Build(Input{Type: "mystery", URL: "https://s.example/services/x"}) + if block.CallShape.BodyKind != "none" || block.CallShape.Method != "GET" { + t.Errorf("unknown type callShape = %+v, want GET/none", block.CallShape) + } + if !strings.Contains(block.Prompts[PromptObolAgent], "`pay`") { + t.Errorf("unknown type obol-agent prompt should teach single-shot pay:\n%s", block.Prompts[PromptObolAgent]) + } +} + +// TestGuideRef_PointsAtSellerDocs pins that generic-LLM buyers are pointed +// at the seller's own machine-readable docs, not a generic external page. +func TestGuideRef_PointsAtSellerDocs(t *testing.T) { + ref := GuideRef("https://seller.example.com/") + for _, want := range []string{"https://seller.example.com/skill.md", "https://seller.example.com/openapi.json"} { + if !strings.Contains(ref, want) { + t.Errorf("GuideRef = %q, want it to reference %s", ref, want) + } + } + if fallback := GuideRef(""); !strings.Contains(fallback, "x402.org") { + t.Errorf("empty-site GuideRef = %q, want x402.org fallback", fallback) + } +} + +// TestBuild_AgentPromptRunsAsIs pins that the agent prompt embeds a concrete +// example task and the pay-agent invocation, so a buyer can paste it +// unedited and have it work. +func TestBuild_AgentPromptRunsAsIs(t *testing.T) { + block := Build(Input{Type: "agent", URL: "https://s.example/services/quant", Model: "m1"}) + obol := block.Prompts[PromptObolAgent] + if !strings.Contains(obol, "pay-agent https://s.example/services/quant") { + t.Errorf("obol-agent prompt missing pay-agent invocation:\n%s", obol) + } + if !strings.Contains(obol, DefaultTaskExample) { + t.Errorf("obol-agent prompt missing the concrete example task:\n%s", obol) + } + if !strings.Contains(block.Prompts[PromptCLI], "buy.py go ") { + t.Errorf("cli prompt should use the go front door:\n%s", block.Prompts[PromptCLI]) + } +} diff --git a/internal/schemas/service-catalog.schema.json b/internal/schemas/service-catalog.schema.json index 366a9b85..a7b0f046 100644 --- a/internal/schemas/service-catalog.schema.json +++ b/internal/schemas/service-catalog.schema.json @@ -249,6 +249,75 @@ "type": "string", "format": "date-time", "description": "RFC3339 timestamp at which the offer's HTTPRoute will be torn down. Set only when the offer is draining. Catalog consumers should detect drain via the presence of this field." + }, + "buy": { + "$ref": "#/$defs/buyBlock" + }, + "openapiPath": { + "type": "string", + "minLength": 1, + "description": "This offer's key in the /openapi.json paths object (e.g. \"/services/foo/v1/chat/completions\"), for jumping straight to its request/response schema." + }, + "docsPath": { + "type": "string", + "minLength": 1, + "description": "Site-relative deep link into the human API docs UI for this offer's operation (e.g. \"/api#tag/agent/POST/services/foo/v1/chat/completions\")." + } + } + }, + "buyBlock": { + "type": "object", + "additionalProperties": false, + "required": [ + "callShape", + "prompts" + ], + "description": "Canonical buyer instructions, generated in one place (internal/buyprompts) and rendered verbatim by every surface so they cannot drift.", + "properties": { + "callShape": { + "type": "object", + "additionalProperties": false, + "required": [ + "method", + "bodyKind" + ], + "properties": { + "method": { + "type": "string", + "enum": [ + "GET", + "POST" + ] + }, + "path": { + "type": "string", + "description": "Request path relative to the service base URL; absent means the base itself." + }, + "bodyKind": { + "type": "string", + "enum": [ + "openai-chat", + "json", + "multipart", + "none" + ] + }, + "streaming": { + "type": "boolean" + } + } + }, + "prompts": { + "type": "object", + "description": "Copy-paste prompts keyed by buyer-software kind (obol-agent, generic-llm, cli, ...). Additional keys are additive.", + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "example": { + "type": "string", + "description": "Copy-pasteable wire example of one paid request." } } } diff --git a/internal/schemas/service_catalog.go b/internal/schemas/service_catalog.go index 5b052827..d401508c 100644 --- a/internal/schemas/service_catalog.go +++ b/internal/schemas/service_catalog.go @@ -1,6 +1,10 @@ package schemas -import _ "embed" +import ( + _ "embed" + + "github.com/ObolNetwork/obol-stack/internal/buyprompts" +) // ServiceCatalogJSONSchema is the JSON Schema for the public // /api/services.json catalog served by sellers. @@ -68,6 +72,27 @@ type ServiceCatalogEntry struct { // purely additive vs. pre-drain catalogs. Buyers SHOULD migrate to // alternative providers before this time. DrainEndsAt string `json:"drainEndsAt,omitempty"` + + // Buy is the canonical buyer-instruction block for this offer: the + // machine-readable call shape (method/path/body/streaming) plus + // copy-paste prompts keyed by buyer-software kind (obol-agent, + // generic-llm, cli). Generated by internal/buyprompts — the single + // authoring point — and rendered verbatim by the storefront so buyer + // instructions cannot drift between surfaces. + Buy *buyprompts.Block `json:"buy,omitempty"` + + // OpenAPIPath is this offer's key in the /openapi.json `paths` object + // (e.g. "/services/foo/v1/chat/completions"), so consumers can jump + // from a catalog entry straight to its request/response schema without + // scanning the whole spec. + OpenAPIPath string `json:"openapiPath,omitempty"` + + // DocsPath is the site-relative deep link into the human API docs UI + // for this offer's operation (e.g. + // "/api#tag/agent/POST/services/foo/v1/chat/completions"). The anchor + // format is renderer-specific (Scalar hash routing), so it's published + // here rather than reconstructed by consumers. + DocsPath string `json:"docsPath,omitempty"` } // ServiceCatalogPaymentOption is one accepted (price, payTo, network, asset) diff --git a/internal/serviceoffercontroller/openapi.go b/internal/serviceoffercontroller/openapi.go index 4af732c3..702e28bb 100644 --- a/internal/serviceoffercontroller/openapi.go +++ b/internal/serviceoffercontroller/openapi.go @@ -184,6 +184,35 @@ func buildOpenAPIPaths(offers []*monetizeapi.ServiceOffer) map[string]any { return paths } +// openAPIPrimaryPathForOffer returns the offer's key in the OpenAPI document's +// `paths` object (e.g. "/services/foo/v1/chat/completions"). Published on the +// catalog entry as `openapiPath` so consumers can jump from /api/services.json +// straight to the offer's request/response schema. +func openAPIPrimaryPathForOffer(offer *monetizeapi.ServiceOffer) string { + if offer == nil { + return "" + } + if offer.IsInference() || offer.IsAgent() { + return joinOpenAPIPath(offer.EffectivePath(), "/v1/chat/completions") + } + return joinOpenAPIPath(offer.EffectivePath(), "") +} + +// openAPIDocsAnchorForOffer returns the site-relative Scalar deep link for +// this offer's operation, e.g. +// "/api#tag/agent/POST/services/foo/v1/chat/completions". Scalar's default +// hash routing is "#tag//"; centralising the format +// here (published as the catalog entry's `docsPath`) means consumers link +// docs without hardcoding a renderer-version-specific anchor scheme. +func openAPIDocsAnchorForOffer(offer *monetizeapi.ServiceOffer) string { + path := openAPIPrimaryPathForOffer(offer) + if path == "" { + return "" + } + // Every operation emitted today is a POST (see openAPIPathsForOffer). + return "/api#tag/" + fallbackOfferType(offer) + "/POST" + path +} + // openAPIPathsForOffer returns the set of {path → pathItem} entries this // offer contributes. Phase 1 uses pure type-based heuristics: // @@ -360,29 +389,68 @@ func offerPaymentInfoExtension(offer *monetizeapi.ServiceOffer) map[string]any { "protocols": []any{map[string]any{"x402": map[string]any{}}}, } - // For multi-currency offers, advertise every accepted option so indexers - // can surface the cheapest / a buyer's preferred chain. Each entry carries - // the same {mode,currency,amount} shape as `price`, plus the CAIP-2 chain. - // Omitted for single-payment offers — `price` already says everything. - if len(payments) > 1 { - accepts := make([]any, 0, len(payments)) - for i := range payments { - entry := paymentInfoPrice(payments[i]) - if net := strings.TrimSpace(payments[i].Network); net != "" { - if caip, _ := caip2ForNetwork(net); caip != "" { - entry["network"] = caip - } else { - entry["network"] = net - } - } - accepts = append(accepts, entry) - } - info["accepts"] = accepts - } + // Advertise every accepted option (single-payment offers included) with + // full signing metadata: an OpenAPI-only client should be able to + // construct a valid X-PAYMENT from this document alone, without a second + // fetch of /api/services.json or a probe round-trip. + accepts := make([]any, 0, len(payments)) + for i := range payments { + accepts = append(accepts, paymentInfoAccept(payments[i])) + } + info["accepts"] = accepts return info } +// paymentInfoAccept renders one payment option with everything a signer +// needs: {mode,currency,amount} (x402scan price shape) plus the CAIP-2 +// network, recipient, atomic amount, and asset metadata including the +// EIP-712 signing domain. Mirrors the /api/services.json payments[] entries +// so OpenAPI-only consumers aren't second-class. +func paymentInfoAccept(p monetizeapi.ServiceOfferPayment) map[string]any { + entry := paymentInfoPrice(p) + if net := strings.TrimSpace(p.Network); net != "" { + if caip, _ := caip2ForNetwork(net); caip != "" { + entry["network"] = caip + } else { + entry["network"] = net + } + } + if payTo := strings.TrimSpace(p.PayTo); payTo != "" { + entry["payTo"] = payTo + } + asset := paymentAssetJSON(p) + if asset == nil { + return entry + } + assetMap := map[string]any{} + if asset.Address != "" { + assetMap["address"] = asset.Address + } + if asset.Symbol != "" { + assetMap["symbol"] = asset.Symbol + } + if asset.Decimals != 0 { + assetMap["decimals"] = asset.Decimals + } + if asset.TransferMethod != "" { + assetMap["transferMethod"] = asset.TransferMethod + } + if asset.EIP712Domain != nil { + assetMap["eip712Domain"] = map[string]any{ + "name": asset.EIP712Domain.Name, + "version": asset.EIP712Domain.Version, + } + } + if len(assetMap) > 0 { + entry["asset"] = assetMap + } + if raw, _ := paymentPriceRawAndUnit(p); raw != "" && catalogAssetHasKnownDecimals(asset) { + entry["amountAtomicUnits"] = decimalToAtomicString(raw, int(asset.Decimals)) + } + return entry +} + // paymentInfoPrice renders one payment option as an x402scan-style price // object: {mode:"fixed", currency, amount}. USDC-settled options advertise // ISO-4217 "USD" (1:1); other assets advertise their token symbol. perMTok diff --git a/internal/serviceoffercontroller/openapi_test.go b/internal/serviceoffercontroller/openapi_test.go index a9ca1a8e..90459b46 100644 --- a/internal/serviceoffercontroller/openapi_test.go +++ b/internal/serviceoffercontroller/openapi_test.go @@ -201,6 +201,57 @@ func TestBuildOpenAPIDocument_InferenceOffer(t *testing.T) { // multi-currency x-payment-info contract: `price` stays the primary option // (for single-price indexers), and `accepts[]` lists every option with its // currency and CAIP-2 network so indexers can surface the cheapest. +// TestBuildOpenAPIDocument_AcceptsCarrySigningMetadata pins that every +// x-payment-info advertises accepts[] (single-payment offers included) with +// the full signing recipe — payTo, CAIP-2 network, atomic amount, and the +// asset's EIP-712 domain — so an OpenAPI-only client can construct a valid +// X-PAYMENT without a second fetch of /api/services.json. +func TestBuildOpenAPIDocument_AcceptsCarrySigningMetadata(t *testing.T) { + offer := readyOfferWithSpec("solo", "svc", monetizeapi.ServiceOfferSpec{ + Type: "inference", + Model: monetizeapi.ServiceOfferModel{Name: "m1"}, + Upstream: monetizeapi.ServiceOfferUpstream{Service: "up", Port: 8000}, + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base-sepolia", + PayTo: "0x2222222222222222222222222222222222222222", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + }) + + doc := parseOpenAPI(t, buildOpenAPIDocument([]*monetizeapi.ServiceOffer{offer}, "https://tunnel.example")) + op := dig(t, doc, "paths", "/services/solo/v1/chat/completions", "post") + info, _ := op.(map[string]any)["x-payment-info"].(map[string]any) + if info == nil { + t.Fatalf("x-payment-info missing") + } + + accepts, ok := info["accepts"].([]any) + if !ok || len(accepts) != 1 { + t.Fatalf("accepts = %#v, want exactly 1 entry for a single-payment offer", info["accepts"]) + } + entry := accepts[0].(map[string]any) + if entry["payTo"] != "0x2222222222222222222222222222222222222222" { + t.Errorf("payTo = %v", entry["payTo"]) + } + if entry["network"] != "eip155:84532" { + t.Errorf("network = %v, want CAIP-2 eip155:84532", entry["network"]) + } + if entry["amountAtomicUnits"] != "1000" { + t.Errorf("amountAtomicUnits = %v, want 1000 (0.001 USDC)", entry["amountAtomicUnits"]) + } + asset, ok := entry["asset"].(map[string]any) + if !ok { + t.Fatalf("asset missing: %#v", entry) + } + domain, ok := asset["eip712Domain"].(map[string]any) + if !ok { + t.Fatalf("asset.eip712Domain missing: %#v (wrong-domain signing is the top silent buyer killer)", asset) + } + if domain["name"] == "" || domain["version"] == "" { + t.Errorf("eip712Domain incomplete: %#v", domain) + } +} + func TestBuildOpenAPIDocument_MultiPaymentAdvertisesAllOptions(t *testing.T) { offer := readyOfferWithSpec("dual", "llm", monetizeapi.ServiceOfferSpec{ Type: "inference", diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index a5b50637..bf0bde54 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/ObolNetwork/obol-stack/internal/buyprompts" "github.com/ObolNetwork/obol-stack/internal/erc8004" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" "github.com/ObolNetwork/obol-stack/internal/schemas" @@ -984,6 +985,13 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin lines = append(lines, fmt.Sprintf("### %s", offer.Name)) lines = append(lines, fmt.Sprintf("- **Endpoint**: `%s`", endpoint)) lines = append(lines, fmt.Sprintf("- **Call**: %s", offerCallHint(offer, endpoint))) + if anchor := openAPIDocsAnchorForOffer(offer); anchor != "" { + lines = append(lines, fmt.Sprintf( + "- **API docs**: [%s%s](%s%s) — schema path `%s` in [openapi.json](%s/openapi.json)", + baseURL, anchor, baseURL, anchor, + openAPIPrimaryPathForOffer(offer), baseURL, + )) + } lines = append(lines, fmt.Sprintf("- **Type**: %s", fallbackOfferType(offer))) if modelName != "" { lines = append(lines, fmt.Sprintf("- **Model**: %s", modelName)) @@ -1228,6 +1236,22 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string, // flat fields above). The storefront renders one pay-row per option. svc.Payments = buildCatalogPayments(offer) + // Canonical buyer instructions — generated once here and rendered + // verbatim by the storefront (and any other consumer) so how-to-buy + // copy cannot drift between surfaces. The 402 paywall page builds + // its prompt cards from the same buyprompts package. + buy := buyprompts.Build(buyprompts.Input{ + Type: fallbackOfferType(offer), + URL: svc.Endpoint, + SiteURL: baseURL, + Model: modelName, + PriceDisplay: svc.Price, + NetworkLabel: offer.Spec.Payment.Network, + }) + svc.Buy = &buy + svc.OpenAPIPath = openAPIPrimaryPathForOffer(offer) + svc.DocsPath = openAPIDocsAnchorForOffer(offer) + services = append(services, svc) } diff --git a/internal/serviceoffercontroller/scalar_html.go b/internal/serviceoffercontroller/scalar_html.go index 9f6c03be..3b8678e0 100644 --- a/internal/serviceoffercontroller/scalar_html.go +++ b/internal/serviceoffercontroller/scalar_html.go @@ -1,23 +1,22 @@ package serviceoffercontroller // scalarBundleVersion is the pinned NPM version of @scalar/api-reference -// served from jsdelivr. Renovate keeps this current; bumps land as -// reviewable PRs so the bundled JS payload never drifts silently. -const scalarBundleVersion = "1.34.0" +// served from jsdelivr. Renovate tracks it via the scalar_html.go custom +// manager in renovate.json; bumps land as reviewable PRs so the bundled JS +// payload never drifts silently. After a bump, refresh the SRI hash below +// with scripts/update-scalar-sri.sh (the renovate PR body links it too). +// renovate: datasource=npm depName=@scalar/api-reference +const scalarBundleVersion = "1.62.1" // scalarBundleSRI is the Subresource Integrity hash for the pinned bundle. // The /api page is served over the public tunnel, so the third-party Scalar // JS it pulls from jsdelivr must be integrity-checked: without this the // browser executes whatever the CDN returns, unverified. Re-derive on every -// version bump (Renovate touches scalarBundleVersion above) by running: -// -// curl -sL https://cdn.jsdelivr.net/npm/@scalar/api-reference@ \ -// | openssl dgst -sha384 -binary | base64 -// -// and prefixing the result with `sha384-`. The hash is taken over the exact +// version bump by running scripts/update-scalar-sri.sh (fetches the pinned +// bundle and rewrites this constant). The hash is taken over the exact // (jsdelivr-minified) bytes that the pinned URL serves; it must be refreshed // in lockstep with scalarBundleVersion or the browser will block the script. -const scalarBundleSRI = "sha384-tNJHhVh8smfB4VJcBxQf3Q0Soj15UqqyVJ6Q6OTwqGVEyxy57gfDLo7DGcSclH7I" +const scalarBundleSRI = "sha384-PbtNjho0PH2QGB1/2Su//W99xhKoSNGumXARK4KrO/4daQHPSI4029R1KQtXcaw5" // scalarHTML returns the static HTML shell served at /api. It loads the // pinned @scalar/api-reference bundle from jsdelivr, points it at the diff --git a/internal/x402/paymentrequired.go b/internal/x402/paymentrequired.go index f9e2a8c0..979cb083 100644 --- a/internal/x402/paymentrequired.go +++ b/internal/x402/paymentrequired.go @@ -13,6 +13,8 @@ import ( "strings" x402types "github.com/x402-foundation/x402/go/v2/types" + + "github.com/ObolNetwork/obol-stack/internal/buyprompts" ) // displayTokenRe is the allowed charset for ServiceOffer-sourced strings @@ -40,9 +42,10 @@ func sanitizeDisplayToken(s, placeholder string) string { // defaultAgentTaskExample is the concrete sample task baked into the // agent-type copy so a buyer can paste the pay-agent command (or the // chat-completions body) and have it run as-is, then edit the message. -// Mirrors AGENT_TASK_PLACEHOLDER in the storefront's ServiceCard.tsx so the -// public storefront and the 402 page hand out the same example. -const defaultAgentTaskExample = "Summarise the README and list the top 3 risks." +// Aliased from buyprompts (the single authoring point for buyer copy) so the +// public storefront, /api/services.json, and the 402 page hand out the same +// example. +const defaultAgentTaskExample = buyprompts.DefaultTaskExample //go:embed templates/payment_required.html var paymentRequiredHTMLSrc string @@ -387,14 +390,7 @@ func buildTypeCopy(siteURL, endpoint string, d PaymentDisplay) typeCopy { // learn how to pay. siteURL is the public origin (scheme://host); when empty // the prompt degrades to a generic x402 mention. func x402GuideRef(siteURL string) string { - siteURL = strings.TrimRight(siteURL, "/") - if siteURL == "" { - return "x402 micropayments (see https://www.x402.org)" - } - return fmt.Sprintf( - "x402 micropayments — read %s/skill.md for the full payment flow and %s/openapi.json for the exact request shapes", - siteURL, siteURL, - ) + return buyprompts.GuideRef(siteURL) } // normalizeOfferType collapses the spec.type values into the three render @@ -419,27 +415,12 @@ func normalizeOfferType(t string) string { // raw-JSON paths, but reframed so users understand they're buying remote // model time, not an agent with tools/memory. func inferenceCopy(url, siteURL string, d PaymentDisplay) typeCopy { - model := sanitizeDisplayToken(d.Model, "") - - // Positional seller URL, no required --model/--budget. Identity check - // is opt-in via --expected-agent-id, so the copy stays clean. - cmd := fmt.Sprintf("obol buy inference %s", url) - - prompt := fmt.Sprintf( - "There's an Obol paid-inference service at %s offering the %s model. "+ - "Explain to me how it works, then — if I'm interested — run "+ - "`obol buy inference %s` from this host to pre-authorize it and wire "+ - "`paid/%s` into our local LiteLLM gateway. After it lands, switch "+ - "yourself over to the new model and confirm.", - url, model, url, model, - ) - - other := fmt.Sprintf( - "I want to use the remote LLM at %s (model %s) as a paid OpenAI-compatible "+ - "chat-completions endpoint, paid with %s. Pre-sign a budget of EIP-3009/Permit2 "+ - "authorizations and POST chat-completions bodies with the X-PAYMENT header attached.", - url, model, x402GuideRef(siteURL), - ) + block := buyprompts.Build(buyprompts.Input{ + Type: "inference", + URL: url, + SiteURL: siteURL, + Model: sanitizeDisplayToken(d.Model, ""), + }) return typeCopy{ Lede: template.HTML( @@ -448,24 +429,17 @@ func inferenceCopy(url, siteURL string, d PaymentDisplay) typeCopy { "pre-authorizes the provider through your agent's wallet and registers the model as " + "paid/<model> in your local LiteLLM gateway, so every agent in your stack " + "can call it like any other OpenAI-compatible model."), - ShowPrimary: true, - PrimaryTitle: "Use this service for your Obol Agent's model", - PrimaryLede: "Run this from your obol-stack host. The CLI walks `/api/services.json`, prompts for auto-refill + a request count, and pre-signs the authorizations from your master agent's wallet. Pass `--yes --count ` for non-interactive runs.", - PrimaryIsCode: true, - PrimaryPayload: cmd, - PromptObol: prompt, - PromptOther: other, + ShowPrimary: true, + PrimaryTitle: "Use this service for your Obol Agent's model", + PrimaryLede: "Run this from your obol-stack host. The CLI walks `/api/services.json`, prompts for auto-refill + a request count, and pre-signs the authorizations from your master agent's wallet. Pass `--yes --count ` for non-interactive runs.", + PrimaryIsCode: true, + // Positional seller URL, no required --model/--budget. Identity check + // is opt-in via --expected-agent-id, so the copy stays clean. + PrimaryPayload: block.Prompts[buyprompts.PromptCLI], + PromptObol: block.Prompts[buyprompts.PromptObolAgent], + PromptOther: block.Prompts[buyprompts.PromptGenericLLM], ChatCompletionsNote: "Direct HTTP buyers use OpenAI-style chat-completions. A minimal paid request looks like:", - ChatCompletionsBody: fmt.Sprintf(`POST %s/v1/chat/completions -Content-Type: application/json -X-PAYMENT: - -{ - "model": "%s", - "messages": [ - {"role": "user", "content": ""} - ] -}`, strings.TrimSuffix(url, "/"), model), + ChatCompletionsBody: block.Example, } } @@ -476,44 +450,17 @@ X-PAYMENT: // example sits next to the raw x402 JSON in the Pay-manually card to // make the wire shape obvious to readers walking the spec by hand. func agentCopy(url, siteURL string, d PaymentDisplay) typeCopy { - model := sanitizeDisplayToken(d.Model, "") - modelClause := "" - if model != "" { - modelClause = fmt.Sprintf(`"model": "%s",`, model) - } - - body := fmt.Sprintf(`POST %s -Content-Type: application/json -X-PAYMENT: - -{ - %s - "messages": [ - {"role": "user", "content": %q} - ] -}`, url, modelClause, defaultAgentTaskExample) - // pay-agent requires a --model value, but an agent runs its own pinned // model server-side and ignores the field, so we don't editorialize about - // which model the seller uses — we just fill the required flag (the - // seller's model when known, a placeholder otherwise) and hand the buyer a - // command that runs as-is with a concrete example task they can edit. - modelFlag := sanitizeDisplayToken(d.Model, "") - prompt := fmt.Sprintf( - "Use the buy-x402 skill's `pay-agent` command to buy one round of work from this "+ - "Obol Agent — it has its own skills, tools, and memory, not just a model. Edit the "+ - "message, then run:\n\n"+ - "pay-agent %s --model %q --message %q", - url, modelFlag, defaultAgentTaskExample, - ) - - other := fmt.Sprintf( - "Help me call the Obol Agent at %s — it's an autonomous agent (tools + skills + memory), "+ - "not a raw LLM. It's gated by %s. POST OpenAI-style chat-completions JSON with this user "+ - "message in `messages`: {\"role\":\"user\",\"content\":%q}. Attach a signed "+ - "EIP-3009/Permit2 authorization as `X-PAYMENT`, and report what the agent does.", - url, x402GuideRef(siteURL), defaultAgentTaskExample, - ) + // which model the seller uses — buyprompts fills the required flag (the + // seller's model when known, a placeholder otherwise) and hands the buyer + // a command that runs as-is with a concrete example task they can edit. + block := buyprompts.Build(buyprompts.Input{ + Type: "agent", + URL: url, + SiteURL: siteURL, + Model: sanitizeDisplayToken(d.Model, ""), + }) return typeCopy{ Lede: template.HTML( @@ -527,42 +474,24 @@ X-PAYMENT: // Primary card is suppressed for agents — the actionable // example lives next to the raw x402 JSON instead. ShowPrimary: false, - PromptObol: prompt, - PromptOther: other, + PromptObol: block.Prompts[buyprompts.PromptObolAgent], + PromptOther: block.Prompts[buyprompts.PromptGenericLLM], ChatCompletionsNote: "Obol Agents accept OpenAI-style chat-completions bodies. A request like the following will get you an answer:", - ChatCompletionsBody: body, + ChatCompletionsBody: block.Example, } } // httpCopy: legacy default. Stateless single-shot pay; no model, no // pre-payment, no LiteLLM mounting. Matches the pre-existing copy. func httpCopy(url, siteURL string, d PaymentDisplay) typeCopy { - priceClause := "" - if d.PriceDisplay != "" { - priceClause = " Pay " + d.PriceDisplay + "." - } - netClause := "" - if d.NetworkLabel != "" { - netClause = " Network: " + d.NetworkLabel + "." - } - prompt := fmt.Sprintf( - "Use the buy-x402 skill's `pay` command to call %s once.%s%s "+ - "Use the method and payload the seller documents.", - url, priceClause, netClause) - - priceWord := "the listed price" - if d.PriceDisplay != "" { - priceWord = d.PriceDisplay - } - onNet := "" - if d.NetworkLabel != "" { - onNet = " on " + d.NetworkLabel - } - other := fmt.Sprintf( - "Help me buy access to %s for %s%s, paid with %s. Sign the EIP-3009 or Permit2 "+ - "authorization and call the endpoint with the X-PAYMENT header.", - url, priceWord, onNet, x402GuideRef(siteURL), - ) + block := buyprompts.Build(buyprompts.Input{ + Type: "http", + URL: url, + SiteURL: siteURL, + PriceDisplay: d.PriceDisplay, + NetworkLabel: d.NetworkLabel, + }) + prompt := block.Prompts[buyprompts.PromptObolAgent] return typeCopy{ Lede: template.HTML("This is a paid HTTP endpoint gated by x402 micropayments. Each call is a one-shot purchase — no subscription, no pre-authorization, no LLM model behind it."), @@ -575,7 +504,7 @@ func httpCopy(url, siteURL string, d PaymentDisplay) typeCopy { // "Pay with another AI agent" card still renders. PrimaryPayload: prompt, PromptObol: prompt, - PromptOther: other, + PromptOther: block.Prompts[buyprompts.PromptGenericLLM], } } diff --git a/renovate.json b/renovate.json index 81ad5e39..2ffbfae4 100644 --- a/renovate.json +++ b/renovate.json @@ -142,6 +142,17 @@ ], "versioningTemplate": "loose" }, + { + "customType": "regex", + "description": "Track the @scalar/api-reference bundle version pinned in scalar_html.go (the /api docs renderer served over the public tunnel). Uses the `// renovate: datasource=npm depName=@scalar/api-reference` annotation immediately above the const. NOTE: the SRI hash next to it must be re-derived on every bump (Renovate can't compute it) — run scripts/update-scalar-sri.sh on the renovate branch before merging, or the browser blocks the script and /api renders blank.", + "matchStrings": [ + "//\\s*renovate:\\s*datasource=(?\\S+)\\s+depName=(?\\S+)\\s*\\nconst scalarBundleVersion = \"(?[^\"]+)\"" + ], + "fileMatch": [ + "^internal/serviceoffercontroller/scalar_html\\.go$" + ], + "versioningTemplate": "semver" + }, { "customType": "regex", "description": "Track Helm chart versions pinned in network helmfiles. Uses the `# renovate: datasource=helm depName=X registryUrl=Y` annotation immediately above each `version:` line in a release block.", @@ -298,6 +309,32 @@ ], "groupName": "openclaw chart updates" }, + { + "description": "Group Scalar API reference bundle updates. The SRI hash in scalar_html.go must move in lockstep with the version const; postUpgradeTasks runs scripts/update-scalar-sri.sh inside the renovate branch so the bump PR arrives complete (requires RENOVATE_ALLOWED_COMMANDS in .github/workflows/renovate.yml). If the hash line is missing from the PR diff, the task was blocked — run the script manually before merge or /api ships a browser-blocked script tag.", + "matchDatasources": [ + "npm" + ], + "matchPackageNames": [ + "@scalar/api-reference" + ], + "labels": [ + "renovate/scalar" + ], + "schedule": [ + "before 6am on monday" + ], + "groupName": "Scalar API reference updates", + "postUpgradeTasks": { + "commands": [ + "bash scripts/update-scalar-sri.sh" + ], + "fileFilters": [ + "internal/serviceoffercontroller/scalar_html.go" + ], + "executionMode": "update" + }, + "prBodyTemplate": "This PR updates the **@scalar/api-reference** bundle pinned in `internal/serviceoffercontroller/scalar_html.go` (the /api docs UI served over the public tunnel).\n\n### SRI hash\n`scripts/update-scalar-sri.sh` ran as a post-upgrade task and refreshed `scalarBundleSRI` in the same commit. **Verify the diff touches BOTH const lines** — if only the version moved, the task was blocked; run the script on this branch before merging or the browser blocks the bundle and /api renders blank.\n\n### What Changed\n- **Current Version**: `{{currentVersion}}`\n- **New Version**: `{{newVersion}}`\n\n---\n**Auto-generated by Renovate Bot**" + }, { "description": "Group Foundry updates", "matchDatasources": [ diff --git a/scripts/update-scalar-sri.sh b/scripts/update-scalar-sri.sh new file mode 100755 index 00000000..70ea796c --- /dev/null +++ b/scripts/update-scalar-sri.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Refresh the Subresource Integrity hash for the pinned @scalar/api-reference +# bundle after a version bump (Renovate bumps scalarBundleVersion but cannot +# compute SRI). Fetches the exact bytes jsdelivr serves for the pinned +# version, derives the sha384, and rewrites scalarBundleSRI in +# internal/serviceoffercontroller/scalar_html.go. +# +# Runs in two contexts: +# - developer machines (openssl available), manually after editing the pin +# - the self-hosted Renovate container, as a postUpgradeTasks command +# (renovate.json), so version-bump PRs arrive with the hash refreshed. +# Falls back to node's crypto when openssl is absent — the Renovate +# image always ships node. +# +# Usage: scripts/update-scalar-sri.sh +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +target="$repo_root/internal/serviceoffercontroller/scalar_html.go" + +version="$(sed -n 's/^const scalarBundleVersion = "\(.*\)"$/\1/p' "$target")" +if [ -z "$version" ]; then + echo "error: could not read scalarBundleVersion from $target" >&2 + exit 1 +fi + +url="https://cdn.jsdelivr.net/npm/@scalar/api-reference@${version}" +echo "Fetching $url ..." +bundle="$(mktemp)" +trap 'rm -f "$bundle"' EXIT +curl -fsSL "$url" -o "$bundle" + +# Refuse to hash an error page: the bundle is multi-MB minified JS. +size="$(wc -c < "$bundle" | tr -d ' ')" +if [ "$size" -lt 100000 ]; then + echo "error: fetched only ${size} bytes — not the Scalar bundle (bad version?)" >&2 + exit 1 +fi + +if command -v openssl >/dev/null 2>&1; then + hash="sha384-$(openssl dgst -sha384 -binary "$bundle" | base64 | tr -d '\n')" +else + hash="$(node -e ' +const { createHash } = require("crypto"); +const { readFileSync } = require("fs"); +const digest = createHash("sha384").update(readFileSync(process.argv[1])).digest("base64"); +process.stdout.write("sha384-" + digest); +' "$bundle")" +fi +echo "Version: $version" +echo "SRI: $hash" + +tmp="$(mktemp)" +sed "s|^const scalarBundleSRI = \".*\"$|const scalarBundleSRI = \"$hash\"|" "$target" > "$tmp" +mv "$tmp" "$target" +echo "Updated $target" diff --git a/web/public-storefront/src/components/ServiceCard.tsx b/web/public-storefront/src/components/ServiceCard.tsx index 05746621..0d336bb1 100644 --- a/web/public-storefront/src/components/ServiceCard.tsx +++ b/web/public-storefront/src/components/ServiceCard.tsx @@ -67,6 +67,28 @@ function resolvedAgentTask(task: string): string { return task.trim() || AGENT_TASK_PLACEHOLDER; } +// buyPrompt returns the controller-published canonical prompt (service.buy, +// generated by internal/buyprompts) for a buyer-software kind, with the +// shared task placeholder swapped for the user's task. Returns null on +// catalogs from pre-buy-block controllers so callers fall back to the legacy +// inline copy. Rendering the published prompt verbatim is what keeps the +// storefront, the 402 page, and /api/services.json teaching identical buy +// instructions. +function buyPrompt( + service: Service, + key: "obol-agent" | "generic-llm" | "cli", + agentTask?: string, +): string | null { + const raw = service.buy?.prompts?.[key]; + if (!raw) return null; + if (agentTask === undefined) return raw; + const task = resolvedAgentTask(agentTask); + if (task === AGENT_TASK_PLACEHOLDER) return raw; + return raw + .replaceAll(JSON.stringify(AGENT_TASK_PLACEHOLDER), JSON.stringify(task)) + .replaceAll(AGENT_TASK_PLACEHOLDER, task); +} + function quoteAgentTask(task: string): string { return JSON.stringify(resolvedAgentTask(task)); } @@ -201,16 +223,20 @@ export function ServiceCard({ service }: { service: Service }) { {service.endpoint} - {kind === "http" && endpointOrigin(service.endpoint) ? ( + {endpointOrigin(service.endpoint) ? (
API docs - Swagger UI ↗ + API docs ↗ · "; - const cmd = `obol buy inference ${service.name} \\ - --seller ${service.endpoint} \\ - --model ${model} \\ - --budget 1 \\ - --no-verify-identity`; + // Canonical CLI from the catalog buy block; the inline form is only a + // fallback for pre-buy-block catalogs. (The previous inline command + // advertised --no-verify-identity, a flag the CLI has since removed — + // exactly the drift the published block exists to prevent.) + const cmd = + buyPrompt(service, "cli") ?? `obol buy inference ${service.endpoint}`; return (

@@ -387,11 +414,9 @@ function BuyViaObolAgent({ } if (kind === "agent") { - const prompt = buildAgentObolPrompt( - service.endpoint, - service.model, - agentTask, - ); + const prompt = + buyPrompt(service, "obol-agent", agentTask) ?? + buildAgentObolPrompt(service.endpoint, service.model, agentTask); return (

@@ -409,8 +434,10 @@ function BuyViaObolAgent({ ); } - // http (default): legacy single-shot pay. - const prompt = `Use the buy-x402 skill's \`pay\` command to call ${service.endpoint} once. Pay ${opt.price} on ${opt.network}. Report what it returns.`; + // http (default): single-shot pay. + const prompt = + buyPrompt(service, "obol-agent") ?? + `Use the buy-x402 skill's \`pay\` command to call ${service.endpoint} once. Pay ${opt.price} on ${opt.network}. Report what it returns.`; return (

@@ -439,17 +466,25 @@ function BuyViaOtherAgent({ requireTask: boolean; }) { + // Canonical prompts from the catalog buy block; inline strings are only + // fallbacks for pre-buy-block catalogs. let prompt: string; if (kind === "inference") { const model = service.model || "the advertised model"; - prompt = `${docsRef(service.endpoint)} I want to use the remote LLM at ${service.endpoint} (model ${model}) as a paid OpenAI-compatible chat-completions endpoint. Pre-sign a budget of EIP-3009 or Permit2 authorisations and POST chat-completions bodies with the X-PAYMENT header attached.`; + prompt = + buyPrompt(service, "generic-llm") ?? + `${docsRef(service.endpoint)} I want to use the remote LLM at ${service.endpoint} (model ${model}) as a paid OpenAI-compatible chat-completions endpoint. Pre-sign a budget of EIP-3009 or Permit2 authorisations and POST chat-completions bodies with the X-PAYMENT header attached.`; } else if (kind === "agent") { // An agent runs its own pinned model server-side and ignores the request's // model field, so we don't tell the buyer which model it uses — the request // shape is what matters. - prompt = `${docsRef(service.endpoint)} Help me call the Obol Agent at ${service.endpoint} — it's an autonomous agent (tools + skills + memory), not a raw LLM. POST OpenAI-style chat-completions JSON with this user message in \`messages\`: {"role":"user","content":${quoteAgentTask(agentTask)}}. Attach a signed EIP-3009 or Permit2 authorisation as \`X-PAYMENT\`, and report what the agent does.`; + prompt = + buyPrompt(service, "generic-llm", agentTask) ?? + `${docsRef(service.endpoint)} Help me call the Obol Agent at ${service.endpoint} — it's an autonomous agent (tools + skills + memory), not a raw LLM. POST OpenAI-style chat-completions JSON to ${service.endpoint}/v1/chat/completions with this user message in \`messages\`: {"role":"user","content":${quoteAgentTask(agentTask)}}. Attach a signed EIP-3009 or Permit2 authorisation as \`X-PAYMENT\`, and report what the agent does.`; } else { - prompt = `I want to purchase a service offered by an Obol Agent at ${service.endpoint} for ${opt.price} on ${opt.network}. Please install the run-obol-stack skill from https://github.com/ObolNetwork/skills, ask me for permission to set up the obol stack, and use the buy-x402 skill to make the purchase on my behalf.`; + prompt = + buyPrompt(service, "generic-llm") ?? + `I want to purchase a service offered by an Obol Agent at ${service.endpoint} for ${opt.price} on ${opt.network}. Please install the run-obol-stack skill from https://github.com/ObolNetwork/skills, ask me for permission to set up the obol stack, and use the buy-x402 skill to make the purchase on my behalf.`; } return ( diff --git a/web/public-storefront/src/types.ts b/web/public-storefront/src/types.ts index 2ce09882..fff23f20 100644 --- a/web/public-storefront/src/types.ts +++ b/web/public-storefront/src/types.ts @@ -17,6 +17,28 @@ export interface ServicePayment { asset?: ServiceAsset; } +// ServiceBuyCallShape is the machine-readable request recipe published by the +// controller (internal/buyprompts) so buying software doesn't guess the +// path/method/streaming mode. +export interface ServiceBuyCallShape { + method: string; + path?: string; + bodyKind: string; + streaming?: boolean; +} + +// ServiceBuy is the canonical buyer-instruction block. Prompts are keyed by +// buyer-software kind: "obol-agent" (paste into an Obol agent with the +// buy-x402 skill), "generic-llm" (paste into any other AI agent), "cli" +// (run from an obol-stack host). Rendering these verbatim — instead of +// composing storefront-local copy — is what keeps buy instructions from +// drifting between the storefront, the 402 page, and the catalog. +export interface ServiceBuy { + callShape: ServiceBuyCallShape; + prompts: Record; + example?: string; +} + export interface Service { name: string; namespace: string; @@ -50,6 +72,17 @@ export interface Service { category?: string; // weight orders services within a category; higher sorts earlier. weight?: number; + // buy is the canonical buyer-instruction block generated by the + // controller. Absent on catalogs from pre-buy-block controllers — the + // card falls back to its legacy inline copy. + buy?: ServiceBuy; + // openapiPath is this service's key in /openapi.json's paths object, + // used for deep links into the API docs. + openapiPath?: string; + // docsPath is the site-relative deep link into the API docs UI for this + // service's operation (e.g. "/api#tag/agent/POST/services/x/v1/chat/completions"). + // Controller-published so the anchor format lives in one place. + docsPath?: string; } export interface StorefrontProfile { From c5a2c57df4a1f91084eb4c1d20490ee7a157149d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Thu, 2 Jul 2026 02:44:24 +0100 Subject: [PATCH 3/5] feat(x402): tolerant chat-path gateway, structured payment errors, funnel metrics - HandleProxy rewrites bare POST /services/ (and /chat/completions, /v1) to /v1/chat/completions for agent/inference offers, so the most common wrong-path mistake from external buyers succeeds instead of paying into a 404; the 402 page's agent copy taught exactly that bare form until this change - terminal payment failures return structured JSON {error, reason, hint, retriable}; the facilitator's invalidReason (previously discarded) now rides the re-issued 402 challenge in error + extensions.paymentFailure, and signature rejections state the expected EIP-712 domain - legacy error phrases kept verbatim (flows/lib.sh greps for them) - new funnel metrics: payment_failure_reasons_total{reason} (bounded 6-value set) and upstream_failed_after_verify_total, so first-try buyer success is measurable per stage; docs/observability.md updated Co-Authored-By: Claude Fable 5 --- docs/observability.md | 12 +- internal/x402/forwardauth.go | 176 ++++++++++++++++++++++++++-- internal/x402/forwardauth_test.go | 183 ++++++++++++++++++++++++++++++ internal/x402/metrics.go | 33 ++++++ internal/x402/verifier.go | 52 +++++++++ internal/x402/verifier_test.go | 89 ++++++++++++++- 6 files changed, 535 insertions(+), 10 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index a3728aec..e606a941 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -386,7 +386,17 @@ contributors: if you write a guarded division, the epsilon is `1e-9`. - `internal/x402/metrics.go` — verifier metric definitions (`obol_x402_verifier_requests_total`, `_payment_required_total`, - `_payment_verified_total`, `_payment_failed_total`, `_charged_requests_total`). + `_payment_verified_total`, `_payment_failed_total`, `_charged_requests_total`, + `_payment_failure_reasons_total`, `_upstream_failed_after_verify_total`). + `_payment_failure_reasons_total` facets failures by a bounded `reason` + label (`invalid_payment_header`, `no_matching_requirement`, + `facilitator_unreachable`, `payment_invalid`, `settlement_failed`, + `settlement_rejected` — the set enumerated in + `internal/x402/forwardauth.go`), turning "the buy funnel leaks" into + "this stage eats the buyers". `_upstream_failed_after_verify_total` + counts paid requests bounced by the seller's own upstream after the + payment verified (never settled) — a seller-side problem, not a + payment-flow one. - `internal/x402/verifier.go` — `prometheusLabels()` controls the verifier label set; this is the canonical place to add a new bounded label. - `internal/x402/buyer/metrics.go` — buyer-side counters diff --git a/internal/x402/forwardauth.go b/internal/x402/forwardauth.go index e2ec9170..a7e97abb 100644 --- a/internal/x402/forwardauth.go +++ b/internal/x402/forwardauth.go @@ -11,6 +11,7 @@ import ( "log" "net" "net/http" + "strings" "time" x402types "github.com/x402-foundation/x402/go/v2/types" @@ -55,6 +56,12 @@ type ForwardAuthConfig struct { // the offer advertises a single option. OnPaymentMatched func(x402types.PaymentRequirements) + // OnPaymentFailure, if non-nil, is invoked once per payment-flow failure + // with the machine-readable reason (the same string written into the + // response body / extensions.paymentFailure). Lets the caller attribute + // funnel-leak metrics per failure stage. + OnPaymentFailure func(reason string) + // SettlesInProcess marks the in-process seller-gateway path (HandleProxy / // obol sell inference) where VerifyOnly=false is correct BY DESIGN — the // middleware proxies to the real upstream and settles only after a <400 @@ -99,6 +106,79 @@ var ( facilitatorSettleTimeout = 60 * time.Second ) +// paymentErrorBody is the structured JSON body written on terminal +// payment-flow failures (malformed header, facilitator unreachable, +// settlement error). Buying agents retry blind when a failure is an opaque +// plain-text line; giving them a machine-readable reason plus a +// next-action hint converts a dead retry loop into a self-correcting one. +// The `error` field keeps the exact legacy phrases ("Invalid payment +// header", "Payment verification failed", "Payment settlement failed") so +// existing greps and log matchers keep working. +type paymentErrorBody struct { + Error string `json:"error"` + Reason string `json:"reason"` + Detail string `json:"detail,omitempty"` + Hint string `json:"hint,omitempty"` + Retriable bool `json:"retriable"` +} + +// writePaymentError emits a structured JSON error. Headers already set on w +// (e.g. X-PAYMENT-RESPONSE with a settle tx hash) are preserved. +func writePaymentError(w http.ResponseWriter, status int, body paymentErrorBody) { + payload, err := json.Marshal(body) + if err != nil { + http.Error(w, body.Error, status) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(payload) + _, _ = w.Write([]byte("\n")) +} + +// paymentFailure carries the facilitator's rejection detail from the +// middleware to the 402 renderer. The x402 contract on an invalid payment is +// to re-issue the full PaymentRequired challenge (so the buyer can re-probe +// and re-sign); without this the facilitator's invalidReason was logged +// server-side and the buyer saw only the generic challenge — no way to tell +// a wrong-domain signature from an expired auth. +type paymentFailure struct { + Reason string // machine-readable, e.g. "payment_invalid", "settlement_rejected" + Detail string // facilitator invalidReason/invalidMessage or errorReason + Hint string // buyer's next action +} + +type paymentFailureCtxKey struct{} + +func withPaymentFailure(r *http.Request, f paymentFailure) *http.Request { + return r.WithContext(context.WithValue(r.Context(), paymentFailureCtxKey{}, f)) +} + +func paymentFailureFrom(r *http.Request) (paymentFailure, bool) { + f, ok := r.Context().Value(paymentFailureCtxKey{}).(paymentFailure) + return f, ok +} + +// signatureFailureHint returns a targeted hint when the facilitator rejection +// looks like a signature problem. The #1 silent killer for external buyers is +// signing the wrong EIP-712 domain for the asset; the seller is the only +// party that knows the right answer, so say it in the response instead of +// making the buyer guess. +func signatureFailureHint(detail string, req x402types.PaymentRequirements) string { + if !strings.Contains(strings.ToLower(detail), "signature") { + return "" + } + name, _ := req.Extra["name"].(string) + version, _ := req.Extra["version"].(string) + if name == "" && version == "" { + return "signature rejected — re-sign using the EIP-712 domain advertised in accepts[].extra for this asset" + } + return fmt.Sprintf( + "signature rejected — sign the EIP-712 domain advertised in accepts[].extra (name=%q version=%q) for asset %s on %s", + name, version, req.Asset, req.Network, + ) +} + // NewForwardAuthMiddleware creates an x402 payment-gating middleware compatible // with the v1 wire format. It checks the X-PAYMENT header, verifies the payment // with the facilitator, and optionally settles after a successful downstream @@ -125,6 +205,10 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa if send == nil { send = sendPaymentRequiredJSON } + reportFailure := cfg.OnPaymentFailure + if reportFailure == nil { + reportFailure = func(string) {} + } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -138,7 +222,12 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa payloadBytes, err := base64.StdEncoding.DecodeString(paymentHeader) if err != nil { log.Printf("x402: invalid X-PAYMENT base64: %v", err) - http.Error(w, "Invalid payment header", http.StatusBadRequest) + reportFailure("invalid_payment_header") + writePaymentError(w, http.StatusBadRequest, paymentErrorBody{ + Error: "Invalid payment header", + Reason: "invalid_payment_header", + Hint: "X-PAYMENT must be the base64-encoded x402 PaymentPayload JSON — re-encode and retry the identical request", + }) return } @@ -146,13 +235,23 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa var payload x402types.PaymentPayload if err := json.Unmarshal(payloadBytes, &payload); err != nil { log.Printf("x402: invalid payment JSON: %v", err) - http.Error(w, "Invalid payment header", http.StatusBadRequest) + reportFailure("invalid_payment_header") + writePaymentError(w, http.StatusBadRequest, paymentErrorBody{ + Error: "Invalid payment header", + Reason: "invalid_payment_header", + Hint: "X-PAYMENT decoded but is not valid PaymentPayload JSON — re-fetch the 402 requirements and re-sign", + }) return } matchedReq, found := findMatchingRequirementV1(payload, requirements) if !found { - send(w, r, requirements, cfg.Extensions) + reportFailure("no_matching_requirement") + send(w, withPaymentFailure(r, paymentFailure{ + Reason: "no_matching_requirement", + Detail: fmt.Sprintf("payment offered scheme=%q network=%q, which matches none of the accepts[] entries", payload.Accepted.Scheme, payload.Accepted.Network), + Hint: "sign against one accepts[] entry verbatim — scheme and network must match exactly", + }), requirements, cfg.Extensions) return } if cfg.OnPaymentMatched != nil { @@ -163,13 +262,26 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa verifyResp, err := facilitatorVerify(r.Context(), verifyClient, cfg.FacilitatorURL, payloadBytes, matchedReq) if err != nil { log.Printf("x402: facilitator verify error: %v", err) - http.Error(w, "Payment verification failed", http.StatusServiceUnavailable) + reportFailure("facilitator_unreachable") + writePaymentError(w, http.StatusServiceUnavailable, paymentErrorBody{ + Error: "Payment verification failed", + Reason: "facilitator_unreachable", + Hint: "transient facilitator error — retry the identical request in a few seconds; the payment authorization was not consumed", + Retriable: true, + }) return } if !verifyResp.IsValid { log.Printf("x402: payment invalid: %s", verifyResp.InvalidReason) - send(w, r, requirements, cfg.Extensions) + detail := strings.TrimSpace(strings.TrimSpace(verifyResp.InvalidReason) + " " + strings.TrimSpace(verifyResp.InvalidMessage)) + hint := signatureFailureHint(detail, matchedReq) + reportFailure("payment_invalid") + send(w, withPaymentFailure(r, paymentFailure{ + Reason: "payment_invalid", + Detail: detail, + Hint: hint, + }), requirements, cfg.Extensions) return } @@ -193,19 +305,37 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa // before erroring so the buyer (or operator) can // reconcile against the chain. The header has to land // before http.Error commits the status code. + settledOnChain := false if settleResp != nil && settleResp.Transaction != "" { + settledOnChain = true settleJSON, _ := json.Marshal(settleResp) w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(settleJSON)) log.Printf("x402: facilitator returned tx %s with the error — verify on-chain (network=%s payer=%s)", settleResp.Transaction, settleResp.Network, settleResp.Payer) } - http.Error(w, "Payment settlement failed", http.StatusServiceUnavailable) + reportFailure("settlement_failed") + hint := "transient facilitator error — retry the same request in a few seconds" + if settledOnChain { + hint = "the settle tx in X-PAYMENT-RESPONSE may have landed on-chain — verify against the chain before retrying, or you may pay twice" + } + writePaymentError(w, http.StatusServiceUnavailable, paymentErrorBody{ + Error: "Payment settlement failed", + Reason: "settlement_failed", + Hint: hint, + Retriable: !settledOnChain, + }) return false } if !settleResp.Success { log.Printf("x402: settlement unsuccessful: %s", settleResp.ErrorReason) - send(w, r, requirements, cfg.Extensions) + reportFailure("settlement_rejected") + detail := strings.TrimSpace(strings.TrimSpace(settleResp.ErrorReason) + " " + strings.TrimSpace(settleResp.ErrorMessage)) + send(w, withPaymentFailure(r, paymentFailure{ + Reason: "settlement_rejected", + Detail: detail, + Hint: signatureFailureHint(detail, matchedReq), + }), requirements, cfg.Extensions) return false } @@ -248,9 +378,39 @@ func sendPaymentRequiredJSON(w http.ResponseWriter, r *http.Request, requirement // block (serviceName/iconUrl — see specs/extensions/bazaar.md, soft-drop // rules apply facilitator-side). func buildPaymentRequired(r *http.Request, requirements []x402types.PaymentRequirements, extensions map[string]any) x402types.PaymentRequired { + errMsg := "Payment required for this resource" + + // When the middleware rejected an attempted payment, say WHY in the + // re-issued challenge. The buyer already holds these requirements; the + // only new information that helps them succeed on the retry is the + // rejection reason and the corrective hint. A machine-readable copy + // rides in extensions.paymentFailure for agents. + if failure, ok := paymentFailureFrom(r); ok { + errMsg = "Payment invalid" + if failure.Detail != "" { + errMsg += ": " + failure.Detail + } + if failure.Hint != "" { + errMsg += " — " + failure.Hint + } + failureExt := map[string]any{"reason": failure.Reason} + if failure.Detail != "" { + failureExt["detail"] = failure.Detail + } + if failure.Hint != "" { + failureExt["hint"] = failure.Hint + } + merged := make(map[string]any, len(extensions)+1) + for k, v := range extensions { + merged[k] = v + } + merged["paymentFailure"] = failureExt + extensions = merged + } + return x402types.PaymentRequired{ X402Version: 2, - Error: "Payment required for this resource", + Error: errMsg, Resource: &x402types.ResourceInfo{ URL: buildResourceURL(r), Description: "Payment required for " + r.URL.Path, diff --git a/internal/x402/forwardauth_test.go b/internal/x402/forwardauth_test.go index ae366935..3a52c620 100644 --- a/internal/x402/forwardauth_test.go +++ b/internal/x402/forwardauth_test.go @@ -186,6 +186,189 @@ func TestForwardAuth_InvalidPayment_Returns402(t *testing.T) { } } +// TestForwardAuth_MalformedPaymentHeader_StructuredJSON pins the structured +// error contract on the 400 path: an agent that mangles the base64 must get a +// machine-readable reason and a corrective hint, not an opaque text line. +func TestForwardAuth_MalformedPaymentHeader_StructuredJSON(t *testing.T) { + var verifyCalled, settleCalled atomic.Int32 + fac := mockFacilitatorV1(true, true, &verifyCalled, &settleCalled) + defer fac.Close() + + mw := NewForwardAuthMiddleware(ForwardAuthConfig{ + FacilitatorURL: fac.URL, + VerifyOnly: true, + }, testRequirements()) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("inner handler should not be called with a malformed header") + }) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + req.Header.Set("X-PAYMENT", "%%%not-base64%%%") + rec := httptest.NewRecorder() + mw(inner).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", ct) + } + var body paymentErrorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("body is not JSON: %v (body %q)", err, rec.Body.String()) + } + if body.Error != "Invalid payment header" { + t.Errorf("error = %q, want the stable legacy phrase", body.Error) + } + if body.Reason != "invalid_payment_header" { + t.Errorf("reason = %q, want invalid_payment_header", body.Reason) + } + if body.Hint == "" { + t.Error("hint must tell the buyer what to do next") + } + if body.Retriable { + t.Error("a malformed header is not retriable as-is") + } +} + +// TestForwardAuth_InvalidPayment_402CarriesFailureDetail pins the enriched +// re-issued challenge: when the facilitator rejects a payment, the 402 body +// must say why (error field) and carry a machine-readable copy in +// extensions.paymentFailure — the buyer already has the requirements; the +// rejection reason is the only new information that makes the retry succeed. +func TestForwardAuth_InvalidPayment_402CarriesFailureDetail(t *testing.T) { + var verifyCalled, settleCalled atomic.Int32 + fac := mockFacilitatorV1(false, true, &verifyCalled, &settleCalled) + defer fac.Close() + + mw := NewForwardAuthMiddleware(ForwardAuthConfig{ + FacilitatorURL: fac.URL, + VerifyOnly: true, + }, testRequirements()) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("inner handler should not be called for invalid payment") + }) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + req.Header.Set("X-PAYMENT", validPaymentHeader()) + rec := httptest.NewRecorder() + mw(inner).ServeHTTP(rec, req) + + if rec.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want 402", rec.Code) + } + var parsed x402types.PaymentRequired + if err := json.Unmarshal(rec.Body.Bytes(), &parsed); err != nil { + t.Fatalf("402 body is not PaymentRequired JSON: %v", err) + } + if !strings.Contains(parsed.Error, "test_invalid") { + t.Errorf("402 error = %q, must include the facilitator's invalidReason", parsed.Error) + } + failure, ok := parsed.Extensions["paymentFailure"].(map[string]any) + if !ok { + t.Fatalf("extensions.paymentFailure missing: %#v", parsed.Extensions) + } + if failure["reason"] != "payment_invalid" { + t.Errorf("paymentFailure.reason = %v, want payment_invalid", failure["reason"]) + } + if len(parsed.Accepts) == 0 { + t.Error("the re-issued challenge must still carry accepts[] so the buyer can re-sign") + } +} + +// TestForwardAuth_SignatureRejection_HintsEIP712Domain pins the targeted +// signature hint: when the facilitator rejection mentions "signature", the +// seller must state the EIP-712 domain the buyer should have signed — +// wrong-domain signing is the top silent killer for external buyers and the +// seller is the only party that knows the right answer. +func TestForwardAuth_SignatureRejection_HintsEIP712Domain(t *testing.T) { + fac := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(facilitatorVerifyResponse{ + IsValid: false, + InvalidReason: "invalid_exact_evm_payload_signature", + InvalidMessage: "FiatTokenV2: invalid signature", + }) + })) + defer fac.Close() + + reqs := testRequirements() + mw := NewForwardAuthMiddleware(ForwardAuthConfig{ + FacilitatorURL: fac.URL, + VerifyOnly: true, + }, reqs) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("inner handler should not be called") + }) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + req.Header.Set("X-PAYMENT", validPaymentHeader()) + rec := httptest.NewRecorder() + mw(inner).ServeHTTP(rec, req) + + if rec.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want 402", rec.Code) + } + var parsed x402types.PaymentRequired + if err := json.Unmarshal(rec.Body.Bytes(), &parsed); err != nil { + t.Fatalf("402 body is not PaymentRequired JSON: %v", err) + } + failure, ok := parsed.Extensions["paymentFailure"].(map[string]any) + if !ok { + t.Fatalf("extensions.paymentFailure missing: %#v", parsed.Extensions) + } + hint, _ := failure["hint"].(string) + if !strings.Contains(hint, "EIP-712") { + t.Errorf("hint = %q, must name the EIP-712 domain to sign", hint) + } + if wantName, _ := reqs[0].Extra["name"].(string); wantName != "" && !strings.Contains(hint, wantName) { + t.Errorf("hint = %q, must include the domain name %q", hint, wantName) + } +} + +// TestForwardAuth_FacilitatorDown_StructuredRetriable503 pins the transient +// path: facilitator unreachable must produce a retriable JSON 503 so buying +// agents retry the identical request instead of re-signing (the auth was not +// consumed). +func TestForwardAuth_FacilitatorDown_StructuredRetriable503(t *testing.T) { + fac := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + fac.Close() // deliberately down + + mw := NewForwardAuthMiddleware(ForwardAuthConfig{ + FacilitatorURL: fac.URL, + VerifyOnly: true, + }, testRequirements()) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("inner handler should not be called") + }) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + req.Header.Set("X-PAYMENT", validPaymentHeader()) + rec := httptest.NewRecorder() + mw(inner).ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } + var body paymentErrorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("body is not JSON: %v (body %q)", err, rec.Body.String()) + } + if body.Error != "Payment verification failed" { + t.Errorf("error = %q, want the stable legacy phrase (flows/lib.sh greps for it)", body.Error) + } + if body.Reason != "facilitator_unreachable" { + t.Errorf("reason = %q, want facilitator_unreachable", body.Reason) + } + if !body.Retriable { + t.Error("facilitator-down must be marked retriable") + } +} + func TestForwardAuth_SettleOnSuccess(t *testing.T) { var verifyCalled, settleCalled atomic.Int32 fac := mockFacilitatorV1(true, true, &verifyCalled, &settleCalled) diff --git a/internal/x402/metrics.go b/internal/x402/metrics.go index 2779d148..69c70c9f 100644 --- a/internal/x402/metrics.go +++ b/internal/x402/metrics.go @@ -16,6 +16,19 @@ type verifierMetrics struct { paymentFailed *prometheus.CounterVec chargedRequests *prometheus.CounterVec lastPaymentSuccess *prometheus.GaugeVec + + // paymentFailureReasons splits paymentFailed by WHY (payment_invalid, + // facilitator_unreachable, settlement_failed, ...). paymentFailed alone + // says the funnel leaks; the reason label says where to fix it — the + // difference between "first-try success is 20%" and knowing which stage + // eats the other 80%. + paymentFailureReasons *prometheus.CounterVec + + // upstreamFailedAfterVerify counts paid requests whose payment verified + // but whose upstream then returned an error (no settlement happens on + // this path). High values mean buyers are being bounced by the seller's + // own service, not by payments. + upstreamFailedAfterVerify *prometheus.CounterVec } func newVerifierMetrics() *verifierMetrics { @@ -63,6 +76,20 @@ func newVerifierMetrics() *verifierMetrics { }, []string{"offer_namespace", "offer_name", "chain", "asset_symbol"}, ), + paymentFailureReasons: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "obol_x402_verifier_payment_failure_reasons_total", + Help: "Payment-flow failures split by machine-readable reason (payment_invalid, facilitator_unreachable, settlement_failed, ...).", + }, + []string{"offer_namespace", "offer_name", "chain", "asset_symbol", "reason"}, + ), + upstreamFailedAfterVerify: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "obol_x402_verifier_upstream_failed_after_verify_total", + Help: "Paid requests whose x402 payment verified but whose upstream returned an error (not settled).", + }, + []string{"offer_namespace", "offer_name", "chain", "asset_symbol"}, + ), } m.registry.MustRegister( @@ -72,6 +99,8 @@ func newVerifierMetrics() *verifierMetrics { m.paymentFailed, m.chargedRequests, m.lastPaymentSuccess, + m.paymentFailureReasons, + m.upstreamFailedAfterVerify, ) return m @@ -104,6 +133,10 @@ func (m *verifierMetrics) pruneSeriesNotIn(keep map[string]struct{}) { m.paymentFailed, m.chargedRequests, m.lastPaymentSuccess, + // Partial match on the four shared labels also prunes the + // reason-labelled series. + m.paymentFailureReasons, + m.upstreamFailedAfterVerify, } gathered, err := m.registry.Gather() diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index 258e6524..b4da4958 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -203,6 +203,9 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) { Extensions: mr.extensions, SendPaymentRequired: NewHTMLAwarePaymentRequired(display), OnPaymentMatched: func(req x402types.PaymentRequirements) { matchedLabels = mr.labelsForMatched(req) }, + OnPaymentFailure: func(reason string) { + v.metrics.paymentFailureReasons.With(withReason(matchedLabels, reason)).Inc() + }, }, mr.requirements) upstreamAuth := mr.rule.UpstreamAuth @@ -255,6 +258,7 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { display := buildPaymentDisplay(mr.rule, mr.chain, mr.asset, primary.PayTo, primary.Amount) matchedLabels := primaryLabels + paymentFailed := false middleware := NewForwardAuthMiddleware(ForwardAuthConfig{ FacilitatorURL: cfg.FacilitatorURL, // HandleProxy is the in-process seller gateway: it proxies to the real @@ -266,6 +270,10 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { Extensions: mr.extensions, SendPaymentRequired: NewHTMLAwarePaymentRequired(display), OnPaymentMatched: func(req x402types.PaymentRequirements) { matchedLabels = mr.labelsForMatched(req) }, + OnPaymentFailure: func(reason string) { + paymentFailed = true + v.metrics.paymentFailureReasons.With(withReason(matchedLabels, reason)).Inc() + }, }, mr.requirements) hadPayment := r.Header.Get("X-PAYMENT") != "" @@ -283,6 +291,10 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { v.metrics.chargedRequests.With(matchedLabels).Inc() v.metrics.lastPaymentSuccess.With(matchedLabels).SetToCurrentTime() } + case tracker.status >= http.StatusBadRequest && hadPayment && !paymentFailed: + // Payment verified, upstream errored, no settlement — the buyer was + // bounced by the seller's own service, not by the payment flow. + v.metrics.upstreamFailedAfterVerify.With(matchedLabels).Inc() } } @@ -332,6 +344,17 @@ type matchedRoute struct { labels prometheus.Labels } +// withReason copies a route's metric labels and adds the failure-stage +// reason label for the paymentFailureReasons counter. +func withReason(labels prometheus.Labels, reason string) prometheus.Labels { + out := make(prometheus.Labels, len(labels)+1) + for k, v := range labels { + out[k] = v + } + out["reason"] = reason + return out +} + // labelsForMatched returns the metric labels for the payment option the buyer // actually satisfied, matching by the same fields findMatchingRequirementV1 // uses. Falls back to the primary option's labels if no match is found. @@ -576,6 +599,7 @@ func buildUpstreamProxy(rule *RouteRule) (http.Handler, error) { Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(target) strippedPath := stripRoutePrefix(rule.StripPrefix, pr.In.URL.Path) + strippedPath = normalizeChatCompletionsPath(rule.OfferType, pr.In.Method, strippedPath) pr.Out.URL.Path = singleJoiningSlash(target.Path, strippedPath) pr.Out.URL.RawQuery = pr.In.URL.RawQuery pr.Out.Host = target.Host @@ -591,6 +615,34 @@ func buildUpstreamProxy(rule *RouteRule) (http.Handler, error) { return proxy, nil } +// chatCompletionsPath is the OpenAI-compatible path served by inference and +// agent upstreams (LiteLLM, Hermes). +const chatCompletionsPath = "/v1/chat/completions" + +// normalizeChatCompletionsPath forgives the common wrong-path shapes buyers +// send to chat-completions offers. External x402 clients (and the prompts on +// older 402 pages) frequently POST to the bare service base or to +// /chat/completions; the upstream only serves /v1/chat/completions, so a +// verified, paid request would otherwise 404. For inference/agent offers the +// tolerated shapes are rewritten to the canonical path; every other sub-path +// (e.g. /v1/embeddings) passes through untouched, as do all non-POST methods +// and non-chat offer types. +func normalizeChatCompletionsPath(offerType, method, stripped string) string { + if method != http.MethodPost { + return stripped + } + switch offerType { + case "inference", "agent": + default: + return stripped + } + switch strings.TrimSuffix(stripped, "/") { + case "", "/v1", "/chat/completions": + return chatCompletionsPath + } + return stripped +} + func stripRoutePrefix(prefix, requestPath string) string { prefix = strings.TrimSuffix(prefix, "/") if prefix == "" || prefix == "/" { diff --git a/internal/x402/verifier_test.go b/internal/x402/verifier_test.go index 624f035e..980fe5b6 100644 --- a/internal/x402/verifier_test.go +++ b/internal/x402/verifier_test.go @@ -13,9 +13,9 @@ import ( "testing" "time" - x402types "github.com/x402-foundation/x402/go/v2/types" dto "github.com/prometheus/client_model/go" "github.com/prometheus/common/expfmt" + x402types "github.com/x402-foundation/x402/go/v2/types" ) // ── Mock facilitator ──────────────────────────────────────────────────────── @@ -482,6 +482,93 @@ func TestVerifier_HandleProxy_ValidPayment_SettlesAndStripsPrefix(t *testing.T) } } +// TestVerifier_HandleProxy_TolerantChatPathRewrite covers the forgiving path +// normalization for chat-completions-shaped offers: buyers who POST to the +// bare service base (as older 402-page prompts instructed) or to +// /chat/completions must still land on the upstream's /v1/chat/completions +// instead of paying for a 404. Non-chat offers and non-tolerated sub-paths +// must pass through untouched. +func TestVerifier_HandleProxy_TolerantChatPathRewrite(t *testing.T) { + cases := []struct { + name string + offerType string + requestPath string + wantUpstream string + }{ + {"agent bare base", "agent", "/services/demo", "/v1/chat/completions"}, + {"agent trailing slash", "agent", "/services/demo/", "/v1/chat/completions"}, + {"agent missing v1", "agent", "/services/demo/chat/completions", "/v1/chat/completions"}, + {"agent canonical", "agent", "/services/demo/v1/chat/completions", "/v1/chat/completions"}, + {"inference bare base", "inference", "/services/demo", "/v1/chat/completions"}, + {"inference other v1 route untouched", "inference", "/services/demo/v1/embeddings", "/v1/embeddings"}, + {"http bare base untouched", "http", "/services/demo", "/"}, + {"http sub-path untouched", "http", "/services/demo/run", "/run"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fac := newMockFacilitator(t, mockFacilitatorOpts{}) + var seenPath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer upstream.Close() + + v := newTestVerifier(t, fac.URL, []RouteRule{{ + Pattern: "/services/demo/*", + Price: "0.0001", + UpstreamURL: upstream.URL, + StripPrefix: "/services/demo", + OfferType: tc.offerType, + }}) + + req := httptest.NewRequest(http.MethodPost, tc.requestPath, strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-PAYMENT", testPaymentHeader(t)) + w := httptest.NewRecorder() + v.HandleProxy(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body %q)", w.Code, w.Body.String()) + } + if seenPath != tc.wantUpstream { + t.Fatalf("upstream path = %q, want %q", seenPath, tc.wantUpstream) + } + }) + } +} + +// GET requests must never be rewritten — the tolerant rewrite is only for +// POSTed chat bodies. +func TestVerifier_HandleProxy_TolerantRewrite_SkipsGET(t *testing.T) { + fac := newMockFacilitator(t, mockFacilitatorOpts{}) + var seenPath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + v := newTestVerifier(t, fac.URL, []RouteRule{{ + Pattern: "/services/demo/*", + Price: "0.0001", + UpstreamURL: upstream.URL, + StripPrefix: "/services/demo", + OfferType: "agent", + }}) + + req := httptest.NewRequest(http.MethodGet, "/services/demo", nil) + req.Header.Set("X-PAYMENT", testPaymentHeader(t)) + w := httptest.NewRecorder() + v.HandleProxy(w, req) + + if seenPath != "/" { + t.Fatalf("upstream path = %q, want / (GET must not be rewritten)", seenPath) + } +} + func TestVerifier_HandleProxy_UpstreamFailure_DoesNotSettle(t *testing.T) { fac := newMockFacilitator(t, mockFacilitatorOpts{}) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 3737ebe47264588026e1c5d266b2c9caef842f4e Mon Sep 17 00:00:00 2001 From: bussyjd <145845+bussyjd@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:51:33 +0400 Subject: [PATCH 4/5] fix(x402): accept x402 v2 PAYMENT-SIGNATURE header in verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ForwardAuth verifier advertises x402Version 2 in its 402 challenge but only read the payment from the legacy v1 X-PAYMENT header. Spec-compliant x402 v2 buyers (agentcash, poncho, coinbase SDK >= v2) attach the payment under PAYMENT-SIGNATURE, so their valid payment was silently ignored and the caller re-challenged — no verify, no settle, no log. This blocked every third-party v2 client from paying obol endpoints; only the in-tree buyer (which sends X-PAYMENT) worked. - Read the payment from X-PAYMENT (v1) OR PAYMENT-SIGNATURE (v2). - Decode via the canonical x402types.ToPaymentPayload helper instead of a local json.Unmarshal, keeping the envelope in lockstep with the SDK. - Mirror the settlement receipt onto both X-PAYMENT-RESPONSE and PAYMENT-RESPONSE. - Tests for the v2 header accept + settle + dual response header. Claude-Session: https://claude.ai/code/session_01VquWN9UMaSHH7MHGcG8bw1 --- internal/x402/forwardauth.go | 40 +++++++++++----- internal/x402/forwardauth_test.go | 77 +++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/internal/x402/forwardauth.go b/internal/x402/forwardauth.go index e2ec9170..66cf09b9 100644 --- a/internal/x402/forwardauth.go +++ b/internal/x402/forwardauth.go @@ -99,10 +99,10 @@ var ( facilitatorSettleTimeout = 60 * time.Second ) -// NewForwardAuthMiddleware creates an x402 payment-gating middleware compatible -// with the v1 wire format. It checks the X-PAYMENT header, verifies the payment -// with the facilitator, and optionally settles after a successful downstream -// response. +// NewForwardAuthMiddleware creates an x402 payment-gating middleware that accepts +// both x402 wire versions. It reads the payment from the X-PAYMENT (v1) or +// PAYMENT-SIGNATURE (v2) header, verifies the payment with the facilitator, and +// optionally settles after a successful downstream response. // // When VerifyOnly is true (Traefik ForwardAuth path), settlement is skipped. // When VerifyOnly is false (standalone gateway path), settlement runs only @@ -128,7 +128,15 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // x402 v1 clients send the payment under X-PAYMENT; x402 v2 clients + // (agentcash, poncho, coinbase SDK >= v2) send it under PAYMENT-SIGNATURE. + // Our 402 challenge advertises x402Version 2, so spec-compliant v2 buyers + // use PAYMENT-SIGNATURE. Accept both — otherwise a v2 payment is silently + // ignored and the caller is re-challenged with no way to pay. paymentHeader := r.Header.Get("X-PAYMENT") + if paymentHeader == "" { + paymentHeader = r.Header.Get("PAYMENT-SIGNATURE") + } if paymentHeader == "" { send(w, r, requirements, cfg.Extensions) return @@ -137,18 +145,20 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa // Decode the base64-encoded payment payload. payloadBytes, err := base64.StdEncoding.DecodeString(paymentHeader) if err != nil { - log.Printf("x402: invalid X-PAYMENT base64: %v", err) + log.Printf("x402: invalid payment header base64: %v", err) http.Error(w, "Invalid payment header", http.StatusBadRequest) return } - // Find matching requirement by scheme+network. - var payload x402types.PaymentPayload - if err := json.Unmarshal(payloadBytes, &payload); err != nil { - log.Printf("x402: invalid payment JSON: %v", err) + // Unmarshal via the canonical x402 types helper rather than a local + // json.Unmarshal, so the payload envelope stays in lockstep with the SDK. + payloadPtr, err := x402types.ToPaymentPayload(payloadBytes) + if err != nil { + log.Printf("x402: invalid payment payload: %v", err) http.Error(w, "Invalid payment header", http.StatusBadRequest) return } + payload := *payloadPtr matchedReq, found := findMatchingRequirementV1(payload, requirements) if !found { @@ -195,7 +205,9 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa // before http.Error commits the status code. if settleResp != nil && settleResp.Transaction != "" { settleJSON, _ := json.Marshal(settleResp) - w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(settleJSON)) + encodedSettle := base64.StdEncoding.EncodeToString(settleJSON) + w.Header().Set("X-PAYMENT-RESPONSE", encodedSettle) + w.Header().Set("PAYMENT-RESPONSE", encodedSettle) log.Printf("x402: facilitator returned tx %s with the error — verify on-chain (network=%s payer=%s)", settleResp.Transaction, settleResp.Network, settleResp.Payer) } @@ -209,9 +221,13 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa return false } - // Encode settlement response as X-PAYMENT-RESPONSE header. + // Encode the settlement receipt. v1 clients read it from + // X-PAYMENT-RESPONSE; x402 v2 clients read PAYMENT-RESPONSE. + // Emit both so either wire version can confirm the settle. settleJSON, _ := json.Marshal(settleResp) - w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(settleJSON)) + encodedSettle := base64.StdEncoding.EncodeToString(settleJSON) + w.Header().Set("X-PAYMENT-RESPONSE", encodedSettle) + w.Header().Set("PAYMENT-RESPONSE", encodedSettle) return true }, onFailure: func(statusCode int) { diff --git a/internal/x402/forwardauth_test.go b/internal/x402/forwardauth_test.go index ae366935..af2e4d6d 100644 --- a/internal/x402/forwardauth_test.go +++ b/internal/x402/forwardauth_test.go @@ -159,6 +159,83 @@ func TestForwardAuth_ValidPayment_VerifyOnly(t *testing.T) { } } +// TestForwardAuth_ValidPayment_PaymentSignatureHeader_V2 pins the x402 v2 wire +// fix: our 402 challenge advertises x402Version 2, so spec-compliant v2 buyers +// (agentcash, poncho, coinbase SDK >= v2) attach the payment under the +// PAYMENT-SIGNATURE header, not the legacy X-PAYMENT. Before the fix the verifier +// only read X-PAYMENT, so a v2 payment was silently ignored and re-challenged — +// no verify, no settle, no log. This asserts the v2 header is now honored. +func TestForwardAuth_ValidPayment_PaymentSignatureHeader_V2(t *testing.T) { + var verifyCalled, settleCalled atomic.Int32 + fac := mockFacilitatorV1(true, true, &verifyCalled, &settleCalled) + defer fac.Close() + + mw := NewForwardAuthMiddleware(ForwardAuthConfig{ + FacilitatorURL: fac.URL, + VerifyOnly: true, + }, testRequirements()) + + var innerCalled bool + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + innerCalled = true + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + req.Header.Set("PAYMENT-SIGNATURE", validPaymentHeader()) // v2 header, no X-PAYMENT + rec := httptest.NewRecorder() + mw(inner).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d (v2 PAYMENT-SIGNATURE must be accepted)", rec.Code, http.StatusOK) + } + if !innerCalled { + t.Error("inner handler was not called for a valid v2 PAYMENT-SIGNATURE payment") + } + if verifyCalled.Load() != 1 { + t.Errorf("verify called %d times, want 1 (v2 header should reach the facilitator)", verifyCalled.Load()) + } +} + +// TestForwardAuth_SettleOnSuccess_PaymentSignatureHeader_V2 asserts a v2 payment +// settles end-to-end and that the settlement receipt is mirrored onto BOTH the +// legacy X-PAYMENT-RESPONSE and the v2 PAYMENT-RESPONSE header so either wire +// version can read it. +func TestForwardAuth_SettleOnSuccess_PaymentSignatureHeader_V2(t *testing.T) { + var verifyCalled, settleCalled atomic.Int32 + fac := mockFacilitatorV1(true, true, &verifyCalled, &settleCalled) + defer fac.Close() + + mw := NewForwardAuthMiddleware(ForwardAuthConfig{ + FacilitatorURL: fac.URL, + VerifyOnly: false, + }, testRequirements()) + + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"result":"ok"}`)) + }) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + req.Header.Set("PAYMENT-SIGNATURE", validPaymentHeader()) + rec := httptest.NewRecorder() + mw(inner).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + if settleCalled.Load() != 1 { + t.Errorf("settle called %d times, want 1", settleCalled.Load()) + } + // Both receipt headers must be present for cross-version clients. + if rec.Header().Get("X-PAYMENT-RESPONSE") == "" { + t.Error("X-PAYMENT-RESPONSE header not set after settlement") + } + if rec.Header().Get("PAYMENT-RESPONSE") == "" { + t.Error("PAYMENT-RESPONSE (v2) header not set after settlement") + } +} + func TestForwardAuth_InvalidPayment_Returns402(t *testing.T) { var verifyCalled, settleCalled atomic.Int32 fac := mockFacilitatorV1(false, true, &verifyCalled, &settleCalled) From f8fd4229e4cc1b3190125c87b6be361f89fa7413 Mon Sep 17 00:00:00 2001 From: bussyjd <145845+bussyjd@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:09:02 +0400 Subject: [PATCH 5/5] fix(x402): meter v2 (PAYMENT-SIGNATURE) payments in the funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-#689 a spec-compliant x402 v2 payment arrives under PAYMENT-SIGNATURE, but the funnel gate hadPayment only checked X-PAYMENT — so every successful v2 payment (exactly the cohort #689 unblocked) incremented none of the success/charge/upstream counters. Gate on both headers. Emergent defect of integrating #688 (X-PAYMENT-gated metrics) with #689 (v2 via PAYMENT-SIGNATURE). --- internal/x402/verifier.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index b4da4958..adcbee16 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -276,7 +276,10 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { }, }, mr.requirements) - hadPayment := r.Header.Get("X-PAYMENT") != "" + // A payment can arrive under either wire header: X-PAYMENT (v1) or + // PAYMENT-SIGNATURE (v2). Gate the funnel metrics on both, else every + // successful v2 payment (the cohort #689 unblocked) goes unmetered. + hadPayment := r.Header.Get("X-PAYMENT") != "" || r.Header.Get("PAYMENT-SIGNATURE") != "" tracker := &statusRecorder{ResponseWriter: w, status: http.StatusOK} middleware(proxy).ServeHTTP(tracker, r)