diff --git a/.agents/skills/obol-stack-dev/SKILL.md b/.agents/skills/obol-stack-dev/SKILL.md index 2d5494f..f5d93c9 100644 --- a/.agents/skills/obol-stack-dev/SKILL.md +++ b/.agents/skills/obol-stack-dev/SKILL.md @@ -127,6 +127,18 @@ go test ./cmd/obol ./internal/tunnel ./internal/stack -count=1 go test ./cmd/obol ./internal/stack ./internal/hermes -count=1 ``` +Force a fresh local image build (otherwise `obol stack up` reuses any +locally-tagged `ghcr.io/obolnetwork/:latest` and your source change +won't reach the running pod): + +```bash +OBOL_FORCE_REBUILD_LOCAL_DEV_IMAGES=true obol stack up +``` + +Applies to every image in `baseLocalImages` (x402-verifier, +serviceoffer-controller, x402-buyer, demo-server, public-storefront). +The warm-path summary line surfaces this hint when nothing was rebuilt. + Integration checks: ```bash diff --git a/CLAUDE.md b/CLAUDE.md index 123bb86..c80b924 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -282,7 +282,7 @@ Key code: `cmd/x402-buyer/`, `internal/x402/buyer/`, and `internal/x402/forwarda 1. **Absolute paths required** — Docker volume mounts need absolute paths (resolved at `obol stack init`) 2. **Two-stage templating** — Stage 1 (CLI flags) → Stage 2 (Helmfile) separation is critical 3. **Unique namespaces** — each deployment must have unique namespace -4. **`OBOL_DEVELOPMENT=true`** — required for `obol stack up` to auto-build local images (x402-verifier, serviceoffer-controller, x402-buyer) +4. **`OBOL_DEVELOPMENT=true`** — required for `obol stack up` to auto-build local images (x402-verifier, serviceoffer-controller, x402-buyer, demo-server, public-storefront). The build path reuses any locally-tagged image of the same name to keep warm runs fast; pass `OBOL_FORCE_REBUILD_LOCAL_DEV_IMAGES=true` alongside it to force `docker build` for every image regardless of what's already in the local daemon. The "Local dev images ready" summary line surfaces this hint when nothing was rebuilt this run. 5. **Root-owned PVCs** — `-f` flag required to remove in `obol stack purge` 6. **Narrow review boundaries** — for controller/RBAC/payment changes, spell out exact security and user-journey invariants before editing or delegating; broad review prompts have previously produced noisy findings and missed test drift diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 9d2a48b..2965a5a 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -925,6 +925,12 @@ func buildAndImportLocalImages(cfg *config.Config, u *ui.UI) { u.Successf("Local dev images ready (%d built, %d pulled, %d imported, %d cached) (%s)", built, pulled, imported, cached, elapsed) } + // Surface the rebuild escape hatch on the warm path. When `built == 0` + // the dev may be wondering whether their latest source change actually + // landed in the running pods; the hint tells them how to force it. + if built == 0 && reuseCachedImages { + u.Dim(" Re-run with OBOL_FORCE_REBUILD_LOCAL_DEV_IMAGES=true to rebuild from source.") + } } } diff --git a/internal/x402/forwardauth.go b/internal/x402/forwardauth.go index 9dd018b..107f960 100644 --- a/internal/x402/forwardauth.go +++ b/internal/x402/forwardauth.go @@ -40,6 +40,13 @@ type ForwardAuthConfig struct { // `eip2612GasSponsoring` (gasless Permit2 approve) so buyers take the // matching flow. See BuildExtensionsForAsset for how this is populated. Extensions map[string]any + + // SendPaymentRequired, if non-nil, replaces the default JSON 402 renderer. + // The verifier injects NewHTMLAwarePaymentRequired here so browsers and + // link-preview scrapers receive an HTML page (with OG metadata + copyable + // "ways to pay" prompts) while x402-aware clients keep getting JSON. + // Nil keeps today's behaviour: every 402 is JSON. + SendPaymentRequired SendPaymentRequiredFunc } // facilitatorVerifyRequest is the JSON body sent to POST /verify and /settle. @@ -98,11 +105,16 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa "cluster verifier.") } + send := cfg.SendPaymentRequired + if send == nil { + send = sendPaymentRequiredJSON + } + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { paymentHeader := r.Header.Get("X-PAYMENT") if paymentHeader == "" { - sendPaymentRequired(w, r, requirements, cfg.Extensions) + send(w, r, requirements, cfg.Extensions) return } @@ -124,7 +136,7 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa matchedReq, found := findMatchingRequirementV1(payload, requirements) if !found { - sendPaymentRequired(w, r, requirements, cfg.Extensions) + send(w, r, requirements, cfg.Extensions) return } @@ -138,7 +150,7 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa if !verifyResp.IsValid { log.Printf("x402: payment invalid: %s", verifyResp.InvalidReason) - sendPaymentRequired(w, r, requirements, cfg.Extensions) + send(w, r, requirements, cfg.Extensions) return } @@ -159,7 +171,7 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa if !settleResp.Success { log.Printf("x402: settlement unsuccessful: %s", settleResp.ErrorReason) - sendPaymentRequired(w, r, requirements, cfg.Extensions) + send(w, r, requirements, cfg.Extensions) return false } @@ -178,8 +190,11 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa } } -// sendPaymentRequired writes a 402 response with v2 payment requirements. -func sendPaymentRequired(w http.ResponseWriter, r *http.Request, requirements []x402types.PaymentRequirements, extensions map[string]any) { +// sendPaymentRequiredJSON writes a 402 response with v2 payment requirements +// as a JSON body. This is the wire-level x402 contract that all buyer agents +// understand; it remains the default when ForwardAuthConfig.SendPaymentRequired +// is unset and the fallback when the renderer has nothing else to do. +func sendPaymentRequiredJSON(w http.ResponseWriter, r *http.Request, requirements []x402types.PaymentRequirements, extensions map[string]any) { resource := &x402types.ResourceInfo{ URL: buildResourceURL(r), Description: "Payment required for " + r.URL.Path, diff --git a/internal/x402/paymentrequired.go b/internal/x402/paymentrequired.go new file mode 100644 index 0000000..af16ab4 --- /dev/null +++ b/internal/x402/paymentrequired.go @@ -0,0 +1,312 @@ +package x402 + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "html/template" + "math/big" + "net/http" + "strings" + + x402types "github.com/coinbase/x402/go/types" +) + +//go:embed templates/payment_required.html +var paymentRequiredHTMLSrc string + +var paymentRequiredTmpl = template.Must( + template.New("payment_required").Parse(paymentRequiredHTMLSrc), +) + +// PaymentDisplay is the display-only context the verifier passes to the +// content-negotiated 402 renderer when it wants the HTML page (vs. raw JSON). +// All fields are pre-formatted for direct interpolation into the template; +// the renderer does no number formatting or address truncation itself. +type PaymentDisplay struct { + // Endpoint is the human-friendly path the buyer is hitting (e.g. "/services/agent-quant"). + Endpoint string + + // Network is the chain ID used for matching (e.g. "base-sepolia"). + Network string + + // NetworkLabel is the human-friendly chain name (e.g. "Base Sepolia"). + NetworkLabel string + + // AssetSymbol is the token symbol (e.g. "USDC", "OBOL"). + AssetSymbol string + + // AssetAddress is the token contract address. + AssetAddress string + + // PriceDisplay is the formatted price including symbol (e.g. "0.001 USDC per request"). + PriceDisplay string + + // PriceAtomic is the raw atomic-units amount as a string. + PriceAtomic string + + // PayToFull is the full recipient wallet address (lowercased 0x...). + PayToFull string + + // ExplorerURL is the block-explorer link for PayToFull on the matched + // chain (e.g. https://basescan.org/address/0x...). Empty when the chain + // isn't in the explorer registry. + ExplorerURL string +} + +// SendPaymentRequiredFunc is the renderer signature compatible with the +// existing JSON 402 path. Verifiers that want HTML responses inject a +// content-negotiated wrapper via ForwardAuthConfig. +type SendPaymentRequiredFunc func(w http.ResponseWriter, r *http.Request, requirements []x402types.PaymentRequirements, extensions map[string]any) + +// NewHTMLAwarePaymentRequired returns a renderer that emits HTML when the +// client's Accept header advertises text/html (browsers, social-media link +// preview scrapers), and falls back to the raw JSON 402 body otherwise +// (curl with no Accept, x402 buyer agents, default behaviour). +// +// The HTTP status remains 402 in both branches — only the body shape changes. +// +// display carries pre-formatted, route-specific copy. It must not be nil; pass +// a zero value if no per-route context is available and the template will +// degrade gracefully. +func NewHTMLAwarePaymentRequired(display PaymentDisplay) SendPaymentRequiredFunc { + return func(w http.ResponseWriter, r *http.Request, requirements []x402types.PaymentRequirements, extensions map[string]any) { + if !prefersHTML(r.Header.Get("Accept")) { + sendPaymentRequiredJSON(w, r, requirements, extensions) + return + } + sendPaymentRequiredHTML(w, r, requirements, extensions, display) + } +} + +// prefersHTML returns true when the Accept header advertises text/html as a +// type the client will accept, including */* with text/html present, but NOT +// when Accept is empty (default to JSON for unspecified clients — agents, +// curl, x402-buyer). +func prefersHTML(accept string) bool { + if accept == "" { + return false + } + for _, part := range strings.Split(accept, ",") { + mt := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) + if strings.EqualFold(mt, "text/html") || strings.EqualFold(mt, "application/xhtml+xml") { + return true + } + } + return false +} + +// resolveSiteURL derives the public-facing origin (scheme + host) from the +// incoming request. Mirrors buildResourceURL but keeps just the origin so +// rendered HTML can reference sibling routes (storefront, OG image) on the +// same tunnel host the scraper or browser is currently hitting. +func resolveSiteURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + host := r.Host + if forwarded := r.Header.Get("X-Forwarded-Host"); forwarded != "" { + host = forwarded + } + return scheme + "://" + host +} + +// sendPaymentRequiredHTML writes a 402 status with an HTML body that includes +// full OG metadata, a service-info card, three "ways to pay" prompt cards +// (Obol Agent, other AI agent, raw JSON), and copy buttons. +func sendPaymentRequiredHTML(w http.ResponseWriter, r *http.Request, requirements []x402types.PaymentRequirements, extensions map[string]any, display PaymentDisplay) { + resource := &x402types.ResourceInfo{ + URL: buildResourceURL(r), + Description: "Payment required for " + r.URL.Path, + } + jsonBody := x402types.PaymentRequired{ + X402Version: 2, + Error: "Payment required for this resource", + Resource: resource, + Accepts: requirements, + Extensions: extensions, + } + indented, err := json.MarshalIndent(jsonBody, "", " ") + if err != nil { + // Should not happen — fall back to JSON path. + sendPaymentRequiredJSON(w, r, requirements, extensions) + return + } + + siteURL := resolveSiteURL(r) + pageURL := buildResourceURL(r) + endpoint := display.Endpoint + if endpoint == "" { + endpoint = r.URL.Path + } + networkLabel := display.NetworkLabel + if networkLabel == "" { + networkLabel = display.Network + } + + priceDisplay := display.PriceDisplay + if priceDisplay == "" && len(requirements) > 0 { + priceDisplay = formatAmount(requirements[0].Amount, 0, "") + " (atomic units)" + } + + payToFull := display.PayToFull + if payToFull == "" && len(requirements) > 0 { + payToFull = requirements[0].PayTo + } + payToDisplay := truncateAddress(payToFull) + + promptObol := buildObolPrompt(siteURL, endpoint, display) + promptOther := buildOtherAgentPrompt(siteURL, endpoint, display) + + data := struct { + Title string + Description string + PageURL string + StorefrontURL string + WordmarkURL string + OGImageURL string + Endpoint string + NetworkLabel string + PriceDisplay string + PayToDisplay string + PayToFull string + ExplorerURL string + PromptObol string + PromptOther string + JSONBody string + }{ + Title: "Payment required — Obol Stack", + Description: buildMetaDescription(display), + PageURL: pageURL, + StorefrontURL: siteURL, + WordmarkURL: siteURL + "/obol-stack-logo.png", + OGImageURL: siteURL + "/og-payment-required.png", + Endpoint: endpoint, + NetworkLabel: networkLabel, + PriceDisplay: priceDisplay, + PayToDisplay: payToDisplay, + PayToFull: payToFull, + ExplorerURL: display.ExplorerURL, + PromptObol: promptObol, + PromptOther: promptOther, + JSONBody: string(indented), + } + + var buf bytes.Buffer + if err := paymentRequiredTmpl.Execute(&buf, data); err != nil { + sendPaymentRequiredJSON(w, r, requirements, extensions) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusPaymentRequired) + _, _ = w.Write(buf.Bytes()) +} + +// buildMetaDescription returns the shared og/twitter/description string. Uses +// the dynamic price+asset+network when available; otherwise the static fallback. +func buildMetaDescription(d PaymentDisplay) string { + if d.PriceDisplay != "" && d.NetworkLabel != "" { + return fmt.Sprintf("Unlock this Obol Agent service. Pay %s on %s, settled via x402.", d.PriceDisplay, d.NetworkLabel) + } + return "Unlock this Obol Agent service. Pay per call in USDC or OBOL, settled via x402." +} + +// buildObolPrompt generates the natural-language instruction the user sends +// to their own Obol Agent. The agent already has the buy-x402 skill loaded, +// so the prompt only needs to identify the endpoint, price, asset, network. +func buildObolPrompt(siteURL, endpoint string, d PaymentDisplay) string { + url := siteURL + endpoint + priceClause := "" + if d.PriceDisplay != "" { + priceClause = " Pay " + d.PriceDisplay + "." + } + netClause := "" + if d.NetworkLabel != "" { + netClause = " Network: " + d.NetworkLabel + "." + } + return fmt.Sprintf("Use the buy-x402 skill to buy access to %s.%s%s", url, priceClause, netClause) +} + +// buildOtherAgentPrompt generates a self-contained instruction for any +// generic AI agent (Claude, ChatGPT, Gemini, etc.) that does NOT have the +// Obol skills pre-loaded. It points the agent at obol.org/llms.txt and +// the public skills repo so it can self-orient before signing the payment. +func buildOtherAgentPrompt(siteURL, endpoint string, d PaymentDisplay) string { + url := siteURL + endpoint + priceClause := "the listed price" + if d.PriceDisplay != "" { + priceClause = d.PriceDisplay + } + netClause := "" + if d.NetworkLabel != "" { + netClause = " on " + d.NetworkLabel + } + return fmt.Sprintf( + "Read https://obol.org/llms.txt and skim https://github.com/ObolNetwork/skills "+ + "to learn how Obol Agents pay for x402 services. Then help me buy access to %s "+ + "for %s%s. Sign the EIP-3009 or Permit2 authorisation and call the endpoint "+ + "with the X-PAYMENT header.", + url, priceClause, netClause, + ) +} + +// truncateAddress shortens a hex address for display: 0xa1b2c3...f9c0. +// Returns the input unchanged if it doesn't look like an 0x address. +func truncateAddress(addr string) string { + if !strings.HasPrefix(addr, "0x") || len(addr) < 10 { + return addr + } + return addr[:6] + "…" + addr[len(addr)-4:] +} + +// formatAmount converts an atomic-unit amount string to a decimal-formatted +// string using the asset's decimals and symbol. Empty symbol yields just the +// number. Strips trailing zeros from the fractional part. +func formatAmount(atomic string, decimals int, symbol string) string { + if atomic == "" { + return "" + } + amount, ok := new(big.Int).SetString(atomic, 10) + if !ok { + if symbol != "" { + return atomic + " " + symbol + " (atomic)" + } + return atomic + " (atomic)" + } + if decimals <= 0 { + if symbol != "" { + return amount.String() + " " + symbol + } + return amount.String() + } + pow := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil) + whole := new(big.Int).Quo(amount, pow) + frac := new(big.Int).Mod(amount, pow) + + fracStr := fmt.Sprintf("%0*d", decimals, frac) + fracStr = strings.TrimRight(fracStr, "0") + + num := whole.String() + if fracStr != "" { + num = num + "." + fracStr + } + if symbol != "" { + return num + " " + symbol + } + return num +} + +// FormatPriceDisplay builds the "0.001 USDC per request" display string the +// verifier passes into PaymentDisplay.PriceDisplay. Exposed for tests and +// for callers that want to construct the display struct directly. +func FormatPriceDisplay(atomic string, decimals int, symbol string) string { + formatted := formatAmount(atomic, decimals, symbol) + if formatted == "" { + return "" + } + return formatted + " per request" +} diff --git a/internal/x402/paymentrequired_test.go b/internal/x402/paymentrequired_test.go new file mode 100644 index 0000000..e5e3302 --- /dev/null +++ b/internal/x402/paymentrequired_test.go @@ -0,0 +1,273 @@ +package x402 + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + x402types "github.com/coinbase/x402/go/types" +) + +const ( + testAmount = "1000" + testAsset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + testPayTo = "0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8f9c0" +) + +func sampleRequirement() x402types.PaymentRequirements { + return x402types.PaymentRequirements{ + Scheme: "exact", + Network: "base-sepolia", + Asset: testAsset, + Amount: testAmount, + PayTo: testPayTo, + } +} + +func sampleDisplay() PaymentDisplay { + return PaymentDisplay{ + Endpoint: "/services/agent-quant", + Network: "base-sepolia", + NetworkLabel: "Base Sepolia", + AssetSymbol: "USDC", + AssetAddress: testAsset, + PriceDisplay: "0.001 USDC per request", + PriceAtomic: testAmount, + PayToFull: testPayTo, + ExplorerURL: "https://sepolia.basescan.org/address/" + testPayTo, + } +} + +func TestPrefersHTML(t *testing.T) { + cases := []struct { + accept string + want bool + }{ + // Defaults to JSON when Accept is unset (curl, x402 buyer, agents). + {"", false}, + {"*/*", false}, + {"application/json", false}, + {"application/json, */*", false}, + + // Browsers and link-preview scrapers advertise text/html. + {"text/html", true}, + {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", true}, + {"text/html;q=0.9,application/json;q=0.8", true}, + {"application/xhtml+xml", true}, + + // Whitespace and case insensitivity. + {" TEXT/HTML ", true}, + } + for _, tc := range cases { + if got := prefersHTML(tc.accept); got != tc.want { + t.Errorf("prefersHTML(%q) = %v, want %v", tc.accept, got, tc.want) + } + } +} + +// JSON is the default when Accept is unset — agents and the existing wire +// contract must keep working byte-for-byte. +func TestHTMLAware_DefaultsToJSONWhenAcceptMissing(t *testing.T) { + render := NewHTMLAwarePaymentRequired(sampleDisplay()) + r := httptest.NewRequest("GET", "/services/agent-quant", nil) + w := httptest.NewRecorder() + + render(w, r, []x402types.PaymentRequirements{sampleRequirement()}, nil) + + if w.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want 402", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + var parsed x402types.PaymentRequired + if err := json.Unmarshal(w.Body.Bytes(), &parsed); err != nil { + t.Fatalf("body is not valid JSON: %v\n%s", err, w.Body.String()) + } + if parsed.X402Version != 2 { + t.Errorf("x402Version = %d, want 2", parsed.X402Version) + } + if len(parsed.Accepts) != 1 || parsed.Accepts[0].Amount != testAmount { + t.Errorf("accepts mismatch: %+v", parsed.Accepts) + } +} + +// HTML rendered when Accept advertises text/html — but status is still 402. +func TestHTMLAware_RendersHTMLOnTextHTML(t *testing.T) { + render := NewHTMLAwarePaymentRequired(sampleDisplay()) + r := httptest.NewRequest("GET", "/services/agent-quant", nil) + r.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + r.Header.Set("X-Forwarded-Host", "agent.example.tunnel.dev") + r.Header.Set("X-Forwarded-Proto", "https") + w := httptest.NewRecorder() + + render(w, r, []x402types.PaymentRequirements{sampleRequirement()}, nil) + + if w.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want 402 (status must stay 402 even with HTML body)", w.Code) + } + ct := w.Header().Get("Content-Type") + if !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html...", ct) + } + + body := w.Body.String() + + // OG metadata must be present, with absolute URLs derived from forwarded host. + mustContain(t, body, `Payment required — Obol Stack`) + mustContain(t, body, `property="og:title"`) + mustContain(t, body, `property="og:image" content="https://agent.example.tunnel.dev/og-payment-required.png"`) + mustContain(t, body, `property="og:url" content="https://agent.example.tunnel.dev/services/agent-quant"`) + mustContain(t, body, `name="twitter:card" content="summary_large_image"`) + + // Service-info card must render the dynamic display values. + mustContain(t, body, "/services/agent-quant") + mustContain(t, body, "Base Sepolia") + mustContain(t, body, "0.001 USDC per request") + // The service-info card shows a truncated address; the embedded raw + // JSON necessarily still contains the full one. + mustContain(t, body, "0xa1b2…f9c0") + + // Copy + explorer-link buttons on the Pay-To row, with the full address + // only delivered to the JS clipboard handler (not the visible truncated text). + mustContain(t, body, `data-copy="`+testPayTo+`"`) + mustContain(t, body, `href="https://sepolia.basescan.org/address/`+testPayTo+`"`) + mustContain(t, body, "View on block explorer") + + // All three "ways to pay" prompts must be present, including the agent + // instructions referencing the buy-x402 skill, llms.txt, and the public + // skills repo. + mustContain(t, body, "Pay with your Obol Agent") + mustContain(t, body, "buy-x402 skill") + mustContain(t, body, "Pay with another AI agent") + mustContain(t, body, "https://obol.org/llms.txt") + mustContain(t, body, "https://github.com/ObolNetwork/skills") + mustContain(t, body, "Pay manually (raw HTTP 402)") + + // Footer link back to the same tunnel root (storefront). + mustContain(t, body, `href="https://agent.example.tunnel.dev"`) + // "What is x402?" link. + mustContain(t, body, "https://x402.org") + + // Raw JSON body embedded for x402-aware tools. + mustContain(t, body, `"x402Version": 2`) +} + +// When the rule context is empty (e.g. in-process gateway with no display +// data), the renderer must still produce HTML and not crash. +func TestHTMLAware_DegradeWithoutDisplay(t *testing.T) { + render := NewHTMLAwarePaymentRequired(PaymentDisplay{}) + r := httptest.NewRequest("GET", "/anything", nil) + r.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + + render(w, r, []x402types.PaymentRequirements{sampleRequirement()}, nil) + + if w.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want 402", w.Code) + } + if !strings.HasPrefix(w.Header().Get("Content-Type"), "text/html") { + t.Fatalf("Content-Type = %q, want text/html", w.Header().Get("Content-Type")) + } + body := w.Body.String() + mustContain(t, body, "Payment required") + mustContain(t, body, "/anything") // endpoint falls back to URL.Path + mustContain(t, body, "1000 (atomic units)") // price falls back to atomic units +} + +func TestFormatAmount(t *testing.T) { + cases := []struct { + atomic string + decimals int + symbol string + want string + }{ + {"1000", 6, "USDC", "0.001 USDC"}, + {"1000000", 6, "USDC", "1 USDC"}, + {"1500000", 6, "USDC", "1.5 USDC"}, + {"1234567", 6, "USDC", "1.234567 USDC"}, + {"100", 0, "WEI", "100 WEI"}, + {"", 6, "USDC", ""}, + {"abc", 6, "USDC", "abc USDC (atomic)"}, + } + for _, tc := range cases { + if got := formatAmount(tc.atomic, tc.decimals, tc.symbol); got != tc.want { + t.Errorf("formatAmount(%q, %d, %q) = %q, want %q", tc.atomic, tc.decimals, tc.symbol, got, tc.want) + } + } +} + +func TestTruncateAddress(t *testing.T) { + cases := []struct { + in, want string + }{ + {"0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8f9c0", "0xa1b2…f9c0"}, + {"0xshort", "0xshort"}, // too short, returned as-is + {"not-an-address", "not-an-address"}, + } + for _, tc := range cases { + if got := truncateAddress(tc.in); got != tc.want { + t.Errorf("truncateAddress(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// Regression: previously buildPaymentDisplay passed the decimal rule.Price +// into formatAmount (which expects atomic), so 1 OBOL with 18 decimals +// rendered as "0.000000000000000001 OBOL per request" while the JSON +// correctly showed 1e18 atomic. Display must mirror the wire amount. +func TestFormatPriceDisplay_HighDecimals(t *testing.T) { + got := FormatPriceDisplay("1000000000000000000", 18, "OBOL") + want := "1 OBOL per request" + if got != want { + t.Errorf("FormatPriceDisplay(1e18, 18, OBOL) = %q, want %q", got, want) + } +} + +func TestExplorerAddressURL(t *testing.T) { + addr := "0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8f9c0" + cases := []struct { + network, want string + }{ + {"base", "https://basescan.org/address/" + addr}, + {"base-sepolia", "https://sepolia.basescan.org/address/" + addr}, + {"ethereum", "https://etherscan.io/address/" + addr}, + {"mainnet", "https://etherscan.io/address/" + addr}, + {"polygon", "https://polygonscan.com/address/" + addr}, + {"arbitrum", "https://arbiscan.io/address/" + addr}, + {"unknown-chain", ""}, + } + for _, tc := range cases { + if got := explorerAddressURL(tc.network, addr); got != tc.want { + t.Errorf("explorerAddressURL(%q) = %q, want %q", tc.network, got, tc.want) + } + } + if got := explorerAddressURL("base", "not-an-address"); got != "" { + t.Errorf("explorerAddressURL with bad address = %q, want empty", got) + } +} + +func TestHumanizeNetwork(t *testing.T) { + cases := map[string]string{ + "base": "Base", + "base-sepolia": "Base Sepolia", + "polygon-amoy": "Polygon Amoy", + "arbitrum-sepolia": "Arbitrum Sepolia", + "unknown-chain": "Unknown Chain", // best-effort title-case + "": "", + } + for in, want := range cases { + if got := humanizeNetwork(in); got != want { + t.Errorf("humanizeNetwork(%q) = %q, want %q", in, got, want) + } + } +} + +func mustContain(t *testing.T, haystack, needle string) { + t.Helper() + if !strings.Contains(haystack, needle) { + t.Errorf("body does not contain %q", needle) + } +} diff --git a/internal/x402/templates/payment_required.html b/internal/x402/templates/payment_required.html new file mode 100644 index 0000000..b203928 --- /dev/null +++ b/internal/x402/templates/payment_required.html @@ -0,0 +1,247 @@ + + + + + + {{.Title}} + + + + + + + + + + + + + + + + + + + +
+
+ Obol Stack + HTTP 402 +
+ +

Payment required

+

This Obol Agent service requires a payment to access.

+ +
+

Service

+
+
Endpoint
{{.Endpoint}}
+
Network
{{.NetworkLabel}}
+
Price
{{.PriceDisplay}}
+
Pay to
+
+ {{.PayToDisplay}} + + {{if .ExplorerURL}} + + + + + {{end}} +
+
+
+ +
+

Pay with your Obol Agent

+

Send this to your Obol Agent (it has the buy-x402 skill pre-loaded):

+
+
{{.PromptObol}}
+ +
+
+ +
+

Pay with another AI agent

+

Paste this into Claude, ChatGPT, Gemini, or any agent with internet access:

+
+
{{.PromptOther}}
+ +
+
+ +
+

Pay manually (raw HTTP 402)

+

For x402-aware tools that consume the wire format directly:

+
+
{{.JSONBody}}
+ +
+
+ + +
+ + + + diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index 7ed38d6..57fd1cd 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -88,7 +88,7 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) { cfg := v.config.Load() - rule, requirement, extensions, _, ok := v.matchPaidRoute(cfg, uri) + rule, requirement, extensions, _, chain, asset, ok := v.matchPaidRouteFull(cfg, uri) if !ok { // No pricing rule matches — route is free. w.WriteHeader(http.StatusOK) @@ -115,10 +115,17 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) { labels := prometheusLabels(rule) v.metrics.requestsTotal.With(labels).Inc() + wallet := cfg.Wallet + if rule.PayTo != "" { + wallet = rule.PayTo + } + display := buildPaymentDisplay(rule, chain, asset, wallet, requirement.Amount) + middleware := NewForwardAuthMiddleware(ForwardAuthConfig{ - FacilitatorURL: cfg.FacilitatorURL, - VerifyOnly: cfg.VerifyOnly, - Extensions: extensions, + FacilitatorURL: cfg.FacilitatorURL, + VerifyOnly: cfg.VerifyOnly, + Extensions: extensions, + SendPaymentRequired: NewHTMLAwarePaymentRequired(display), }, []x402types.PaymentRequirements{requirement}) upstreamAuth := rule.UpstreamAuth @@ -150,7 +157,7 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) { func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { cfg := v.config.Load() - rule, requirement, extensions, labels, ok := v.matchPaidRoute(cfg, r.URL.Path) + rule, requirement, extensions, labels, chain, asset, ok := v.matchPaidRouteFull(cfg, r.URL.Path) if !ok { http.NotFound(w, r) return @@ -165,10 +172,17 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { return } + wallet := cfg.Wallet + if rule.PayTo != "" { + wallet = rule.PayTo + } + display := buildPaymentDisplay(rule, chain, asset, wallet, requirement.Amount) + middleware := NewForwardAuthMiddleware(ForwardAuthConfig{ - FacilitatorURL: cfg.FacilitatorURL, - VerifyOnly: false, - Extensions: extensions, + FacilitatorURL: cfg.FacilitatorURL, + VerifyOnly: false, + Extensions: extensions, + SendPaymentRequired: NewHTMLAwarePaymentRequired(display), }, []x402types.PaymentRequirements{requirement}) hadPayment := r.Header.Get("X-PAYMENT") != "" @@ -211,9 +225,16 @@ func (v *Verifier) MetricsHandler() http.Handler { } func (v *Verifier) matchPaidRoute(cfg *PricingConfig, uri string) (*RouteRule, x402types.PaymentRequirements, map[string]any, prometheus.Labels, bool) { + rule, req, ext, labels, _, _, ok := v.matchPaidRouteFull(cfg, uri) + return rule, req, ext, labels, ok +} + +// matchPaidRouteFull is matchPaidRoute plus the resolved chain and asset, +// which the HTML 402 renderer needs for display copy. Internal-only. +func (v *Verifier) matchPaidRouteFull(cfg *PricingConfig, uri string) (*RouteRule, x402types.PaymentRequirements, map[string]any, prometheus.Labels, ChainInfo, AssetInfo, bool) { rule := matchRoute(cfg.Routes, uri) if rule == nil { - return nil, x402types.PaymentRequirements{}, nil, nil, false + return nil, x402types.PaymentRequirements{}, nil, nil, ChainInfo{}, AssetInfo{}, false } wallet := cfg.Wallet @@ -230,13 +251,101 @@ func (v *Verifier) matchPaidRoute(cfg *PricingConfig, uri string) (*RouteRule, x chain, ok := (*chains)[chainName] if !ok { log.Printf("x402-verifier: chain %q not pre-resolved for route %q", chainName, rule.Pattern) - return nil, x402types.PaymentRequirements{}, nil, nil, false + return nil, x402types.PaymentRequirements{}, nil, nil, ChainInfo{}, AssetInfo{}, false } asset := ResolveAssetInfo(chain, rule) requirement := BuildV2RequirementWithAsset(chain, asset, rule.Price, wallet) extensions := BuildExtensionsForAsset(asset) - return rule, requirement, extensions, prometheusLabels(rule), true + return rule, requirement, extensions, prometheusLabels(rule), chain, asset, true +} + +// buildPaymentDisplay turns the matched rule + chain + asset into pre-formatted +// strings for the HTML 402 page. The atomic-amount input is the value already +// computed for the wire requirement (rule.Price * 10^decimals), so passing +// requirement.Amount keeps display and JSON in lockstep — the previous version +// fed the decimal rule.Price into formatAmount and produced the inverse +// (1 OBOL → 0.000000000000000001 OBOL). +func buildPaymentDisplay(rule *RouteRule, chain ChainInfo, asset AssetInfo, payTo, atomicAmount string) PaymentDisplay { + return PaymentDisplay{ + Endpoint: rule.Pattern, + Network: chain.Name, + NetworkLabel: humanizeNetwork(chain.Name), + AssetSymbol: asset.Symbol, + AssetAddress: asset.Address, + PriceDisplay: FormatPriceDisplay(atomicAmount, asset.Decimals, asset.Symbol), + PriceAtomic: atomicAmount, + PayToFull: payTo, + ExplorerURL: explorerAddressURL(chain.Name, payTo), + } +} + +// explorerAddressURL returns the canonical block-explorer URL for the given +// recipient address on the given chain. Empty string when the chain isn't in +// the known set or the address looks malformed — callers must treat empty as +// "no explorer link, just show plain text". +func explorerAddressURL(network, addr string) string { + if !strings.HasPrefix(addr, "0x") || len(addr) < 10 { + return "" + } + base := "" + switch network { + case "base": + base = "https://basescan.org" + case "base-sepolia": + base = "https://sepolia.basescan.org" + case "ethereum", "mainnet": + base = "https://etherscan.io" + case "polygon": + base = "https://polygonscan.com" + case "polygon-amoy": + base = "https://amoy.polygonscan.com" + case "avalanche": + base = "https://snowtrace.io" + case "avalanche-fuji": + base = "https://testnet.snowtrace.io" + case "arbitrum": + base = "https://arbiscan.io" + case "arbitrum-sepolia": + base = "https://sepolia.arbiscan.io" + default: + return "" + } + return base + "/address/" + addr +} + +// humanizeNetwork converts an internal chain name ("base-sepolia") to the +// display label users actually recognise ("Base Sepolia"). Anything not in +// the table is returned title-cased best-effort. +func humanizeNetwork(name string) string { + switch name { + case "base": + return "Base" + case "base-sepolia": + return "Base Sepolia" + case "ethereum", "mainnet": + return "Ethereum" + case "polygon": + return "Polygon" + case "polygon-amoy": + return "Polygon Amoy" + case "avalanche": + return "Avalanche" + case "avalanche-fuji": + return "Avalanche Fuji" + case "arbitrum": + return "Arbitrum One" + case "arbitrum-sepolia": + return "Arbitrum Sepolia" + } + parts := strings.Split(name, "-") + for i, p := range parts { + if p == "" { + continue + } + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + return strings.Join(parts, " ") } func buildUpstreamProxy(rule *RouteRule) (http.Handler, error) { diff --git a/web/public-storefront/src/app/og-payment-required.png/route.tsx b/web/public-storefront/src/app/og-payment-required.png/route.tsx new file mode 100644 index 0000000..1799047 --- /dev/null +++ b/web/public-storefront/src/app/og-payment-required.png/route.tsx @@ -0,0 +1,145 @@ +import { ImageResponse } from "next/og"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +// Static OG image for HTTP 402 responses emitted by x402-verifier. Referenced +// from the verifier's HTML 402 body via absolute URL on the same tunnel host. +// Same Satori/JSX pipeline as src/app/opengraph-image.tsx so styling stays +// consistent across the two surfaces. + +export const runtime = "nodejs"; +export const dynamic = "force-static"; + +const TEXT_LIGHT = "#DFEAED"; +const TEXT_BODY = "#9CC2C9"; +const OBOL_GREEN = "#2FE4AB"; +const BG01 = "#091011"; +const BG_PANEL = "#111F22"; +const STROKE_GREEN = "#1D5249"; +const SIZE = { width: 1200, height: 630 }; + +export async function GET() { + const wordmark = readFileSync( + join(process.cwd(), "public", "obol-stack-logo.png"), + ); + const wordmarkDataUrl = `data:image/png;base64,${wordmark.toString("base64")}`; + + const Chip = ({ label }: { label: string }) => ( +
+ {label} +
+ ); + + return new ImageResponse( + ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Obol Stack +
+ HTTP 402 +
+
+ +
+ Payment required +
+ +
+ Unlock this Obol Agent service. Pay per call in USDC or OBOL. +
+ +
+ + + + +
+
+ ), + { + ...SIZE, + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=3600, immutable", + }, + }, + ); +} diff --git a/web/public-storefront/src/app/opengraph-image.tsx b/web/public-storefront/src/app/opengraph-image.tsx index 5fd6c45..8524069 100644 --- a/web/public-storefront/src/app/opengraph-image.tsx +++ b/web/public-storefront/src/app/opengraph-image.tsx @@ -50,23 +50,9 @@ export default async function OpengraphImage() { display: "flex", flexDirection: "column", padding: 80, - background: `linear-gradient(135deg, ${BG01} 0%, #162A40 100%)`, + background: BG01, }} > - {/* Soft glow, top-right */} -
- {/* Wordmark, top-left */} {/* eslint-disable-next-line @next/next/no-img-element */}