diff --git a/.agents/skills/obol-stack-dev/SKILL.md b/.agents/skills/obol-stack-dev/SKILL.md index c41af37f..cb5f4ffe 100644 --- a/.agents/skills/obol-stack-dev/SKILL.md +++ b/.agents/skills/obol-stack-dev/SKILL.md @@ -79,10 +79,10 @@ All 4 paths use the same OpenClaw config pattern: ### Paid Routing Notes -- The paid path uses **vanilla LiteLLM**. Do not fork LiteLLM for `paid/*`. +- The paid path uses the **Obol LiteLLM fork** because paid-model lifecycle relies on the config-only model management API. - `litellm-config` carries one static route: `paid/* -> openai/* -> http://127.0.0.1:8402`. - `x402-buyer` runs as a **sidecar in the LiteLLM pod**, not as a separate Service. -- `buy.py buy` updates only buyer ConfigMaps; it must not patch LiteLLM `model_list` per purchase. +- `buy.py buy` signs auths locally and creates a `PurchaseRequest`; the controller writes per-upstream buyer files and keeps LiteLLM model entries in sync. - The currently validated local OSS model is `qwen3.5:9b`. Prefer that exact model in live commerce tests. ## Essential Commands @@ -187,6 +187,7 @@ obol kubectl exec -i -n openclaw- deploy/openclaw -c openclaw -- python3 - < ### MUST DO - Always route through `obol` CLI verbs in tests (covers CLI + helmfile + helm chart) +- Preserve failing exit codes when logging or filtering command output. Use `set -o pipefail` or capture `PIPESTATUS` for any pipeline such as `flow.sh | tee log`, `obol stack up 2>&1 | tail`, or `helmfile ... | tee`; otherwise Helm/obol failures can be masked by the final command in the pipe. - Use `obol openclaw token ` to get Bearer token before API calls - Set `Authorization: Bearer ` on all `/v1/chat/completions` requests - Use `obol model setup --provider --api-key ` for cloud provider config @@ -195,6 +196,7 @@ obol kubectl exec -i -n openclaw- deploy/openclaw -c openclaw -- python3 - < - Set env vars for dev mode: `OBOL_DEVELOPMENT=true`, `OBOL_CONFIG_DIR`, `OBOL_BIN_DIR`, `OBOL_DATA_DIR` - Prefer `qwen3.5:9b` when validating the current local paid-inference route - Use unique buy-side names in reused-cluster commerce tests so the sidecar cannot inherit stale in-memory spend counters +- Use narrow review/delegation scopes for x402 changes. Name the exact files and invariants to verify, such as "controller never signs or reads remote-signer", "agent write RBAC is namespace-scoped", "paid route uses real obol CLI/human flow", and "tests support x402 v2 amount fields". ### MUST NOT DO - Call internal Go functions directly when testing the deployment path @@ -203,6 +205,7 @@ obol kubectl exec -i -n openclaw- deploy/openclaw -c openclaw -- python3 - < - Assume TCP connectivity means HTTP is ready (port-forward warmup race) - Use `app.kubernetes.io/instance=openclaw-` for pod labels (Helm uses `openclaw`) - Run multiple integration tests without cleaning up between them (pod sandbox errors) +- Delegate or accept broad "review the architecture" findings without converting them into concrete file-level checks and reproducible tests. ## Sell-Side Monetize Lifecycle @@ -261,3 +264,4 @@ go test -tags integration -v -run TestIntegration_Tunnel_SellDiscoverBuySidecar_ - **ConfigMap propagation**: File watcher takes 60-120s. Force restart verifier for immediate effect. - **Projected ConfigMap refresh**: the LiteLLM pod can take ~60s to reflect updated buyer ConfigMaps in the sidecar. - **eRPC balance lag**: `buy.py balance` uses `eth_call` through eRPC, and the default unfinalized cache TTL is 10s. After a paid request, poll until the reported balance catches up with the on-chain delta. +- **kubectl exec shell quoting**: NEVER use `sh -c` with `fmt.Sprintf` to embed JSON or secrets in shell commands passed via `kubectl exec`. JSON body or auth tokens containing single quotes will break the shell. Instead, pass args directly: `kubectl exec ... -- wget -qO- --post-data= --header=Authorization:\ Bearer\ `. Each argument goes as a separate argv element, bypassing shell interpretation entirely. diff --git a/.agents/skills/obol-stack-dev/references/integration-testing.md b/.agents/skills/obol-stack-dev/references/integration-testing.md index dcc8ae08..d254e390 100644 --- a/.agents/skills/obol-stack-dev/references/integration-testing.md +++ b/.agents/skills/obol-stack-dev/references/integration-testing.md @@ -91,7 +91,7 @@ This means the test suite always passes in CI without infrastructure. Only tests 1. Sell and reconcile a `ServiceOffer` through the LiteLLM gateway 2. Register the offer on ERC-8004 and discover it again from the agent 3. Probe pricing and buy it with `buy.py` -4. Serve the model as `paid/qwen3.5:9b` through vanilla LiteLLM and the in-pod `x402-buyer` sidecar +4. Serve the model as `paid/qwen3.5:9b` through the Obol LiteLLM fork and the in-pod `x402-buyer` sidecar 5. Confirm the sidecar quota moves `remaining 3 -> 2` and `spent 0 -> 1` 6. Confirm USDC moves buyer -> seller and that `buy.py balance` eventually matches the on-chain balance diff --git a/CLAUDE.md b/CLAUDE.md index 3fcc7588..b0716577 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,7 @@ Obol Stack: framework for AI agents to run decentralised infrastructure locally. - **Commits**: Conventional commits — `feat:`, `fix:`, `docs:`, `test:`, `chore:`, `security:` with optional scope - **Branches**: `feat/`, `fix/`, `research/`, `codex/` prefixes - **Detailed architecture reference**: `@.claude/skills/obol-stack-dev/SKILL.md` (invoke with `/obol-stack-dev`) +- **Review scope**: Avoid broad, vague review/delegation boundaries. State the exact files, invariants, and expected evidence before reviewing or spawning agents. Prefer concrete checks such as "controller cannot access signer/Secrets", "agent write RBAC is namespace-scoped", and "flow uses real obol CLI path" over generic "review architecture". ## Build, Test, Run @@ -113,7 +114,7 @@ k3d: 1 server, ports 80:80 + 8080:80 + 443:443 + 8443:443, `rancher/k3s:v1.35.1- ## LLM Routing -**LiteLLM gateway** (`llm` ns, port 4000): OpenAI-compatible proxy routing to Ollama/Anthropic/OpenAI. ConfigMap `litellm-config` (YAML config.yaml with model_list), Secret `litellm-secrets` (master key + API keys). Auto-configured with Ollama models during `obol stack up` (no manual `obol model setup` needed). `ConfigureLiteLLM()` patches config + Secret + restarts. Custom endpoints: `obol model setup custom --name --endpoint --model` (validates before adding). Paid remote inference stays on vanilla LiteLLM with a static route `paid/* -> openai/* -> http://127.0.0.1:8402`; no LiteLLM fork is required. OpenClaw always routes through LiteLLM (openai provider slot), never native providers; `dangerouslyDisableDeviceAuth` is enabled for Traefik-proxied access. +**LiteLLM gateway** (`llm` ns, port 4000): OpenAI-compatible proxy routing to Ollama/Anthropic/OpenAI. ConfigMap `litellm-config` (YAML config.yaml with model_list), Secret `litellm-secrets` (master key + API keys). Auto-configured with Ollama models during `obol stack up` (no manual `obol model setup` needed). `ConfigureLiteLLM()` patches config + Secret + restarts or hot-adds via the LiteLLM model API. Paid remote inference uses the Obol LiteLLM fork plus the `x402-buyer` sidecar, with a static `paid/* -> openai/* -> http://127.0.0.1:8402` route and explicit paid-model entries when needed. OpenClaw always routes through LiteLLM (openai provider slot), never native providers; `dangerouslyDisableDeviceAuth` is enabled for Traefik-proxied access. **Auto-configuration**: During `obol stack up`, `autoConfigureLLM()` detects host Ollama models and patches LiteLLM config so agent chat works immediately without manual `obol model setup`. During install, `obolup.sh` `check_agent_model_api_key()` reads `~/.openclaw/openclaw.json` agent model, resolves API key from environment (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN` for Anthropic; `OPENAI_API_KEY` for OpenAI), and exports it for downstream tools. @@ -133,7 +134,7 @@ Skills = SKILL.md + optional scripts/references, embedded in `obol` binary (`int ## Buyer Sidecar -`x402-buyer` — lean Go sidecar for buy-side x402 payments using pre-signed ERC-3009 authorizations. It runs as a second container in the `litellm` Deployment, not as a separate Service. Agent `buy.py` commands mutate only buyer ConfigMaps; LiteLLM keeps one static public namespace `paid/`. The sidecar exposes `/status`, `/healthz`, and `/metrics`; metrics are scraped via `PodMonitor`. Zero signer access, bounded spending (max loss = N × price). Key code: `cmd/x402-buyer/` and `internal/x402/buyer/`. +`x402-buyer` — lean Go sidecar for buy-side x402 payments using pre-signed ERC-3009 authorizations. It runs as a second container in the `litellm` Deployment, not as a separate Service. Agent `buy.py` signs auths locally and creates a `PurchaseRequest`; the controller writes per-upstream buyer config/auth files into the buyer ConfigMaps and keeps LiteLLM routes in sync. The sidecar exposes `/status`, `/healthz`, `/metrics`, and `/admin/reload`; metrics are scraped via `PodMonitor`. Zero signer access, bounded spending (max loss = N × price). Key code: `cmd/x402-buyer/` and `internal/x402/buyer/`. ## Development Constraints @@ -142,6 +143,7 @@ Skills = SKILL.md + optional scripts/references, embedded in `obol` binary (`int 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) 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 ### OpenClaw Version Management @@ -203,7 +205,7 @@ The Cloudflare tunnel exposes the cluster to the public internet. Only x402-gate **Docs**: `docs/guides/monetize-inference.md` (E2E monetize walkthrough), `README.md`. -**Deps**: Docker 20.10.0+, Go 1.25+. Installed by obolup.sh: kubectl 1.35.3, helm 3.20.1, k3d 5.8.3, helmfile 1.4.3, k9s 0.50.18, helm-diff 3.15.4, ollama 0.20.2. Key Go: `urfave/cli/v3`, `dustinkirkland/golang-petname`, `mark3labs/x402-go`. +**Deps**: Docker 20.10.0+, Go 1.25+. Installed by obolup.sh: kubectl 1.35.3, helm 3.20.1, k3d 5.8.3, helmfile 1.4.3, k9s 0.50.18, helm-diff 3.15.4, ollama 0.20.2. Key Go: `urfave/cli/v3`, `dustinkirkland/golang-petname`, `coinbase/x402/go` (v2 SDK, v1 wire format). ## Related Codebases diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 16dd7efa..d0789fe0 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -35,7 +35,6 @@ import ( "github.com/ObolNetwork/obol-stack/internal/validate" x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" "github.com/ethereum/go-ethereum/crypto" - "github.com/mark3labs/x402-go" "github.com/urfave/cli/v3" ) @@ -105,7 +104,7 @@ Examples: &cli.StringFlag{ Name: "facilitator", Usage: "x402 facilitator URL", - Value: "https://facilitator.x402.rs", + Value: x402verifier.DefaultFacilitatorURL, }, &cli.StringFlag{ Name: "listen", @@ -259,7 +258,7 @@ Examples: } } - chain, err := resolveX402Chain(cmd.String("chain")) + chain, err := x402verifier.ResolveChainInfo(cmd.String("chain")) if err != nil { return err } @@ -1375,15 +1374,19 @@ Examples: } agentURI := endpoint + "/.well-known/agent-registration.json" - // Determine signing method: remote-signer (preferred) or private key file (fallback). + // Determine signing method: private key file (if explicitly provided) + // or remote-signer (default when OpenClaw agent is deployed). useRemoteSigner := false var signerNS string - if _, err := openclaw.ResolveWalletAddress(cfg); err == nil { - ns, nsErr := openclaw.ResolveInstanceNamespace(cfg) - if nsErr == nil { - useRemoteSigner = true - signerNS = ns + // If --private-key-file is explicitly provided, honour user intent. + if !cmd.IsSet("private-key-file") { + if _, err := openclaw.ResolveWalletAddress(cfg); err == nil { + ns, nsErr := openclaw.ResolveInstanceNamespace(cfg) + if nsErr == nil { + useRemoteSigner = true + signerNS = ns + } } } @@ -1430,7 +1433,7 @@ Examples: } } else { // Fallback: direct on-chain with private key file. - if err := registerDirectWithKey(ctx, u, net, agentURI, fallbackKey); err != nil { + if err := registerDirectWithKey(ctx, cfg, u, net, agentURI, fallbackKey); err != nil { u.Warnf("registration failed: %v", err) continue } @@ -1494,7 +1497,8 @@ func registerDirectViaSigner(ctx context.Context, cfg *config.Config, u *ui.UI, u.Printf(" Wallet: %s", addr.Hex()) // Connect to eRPC for this network. - client, err := erc8004.NewClientForNetwork(ctx, "http://localhost/rpc", net) + rpcBaseURL := stack.LocalIngressURL(cfg) + "/rpc" + client, err := erc8004.NewClientForNetwork(ctx, rpcBaseURL, net) if err != nil { return fmt.Errorf("connect to %s via eRPC: %w", net.Name, err) } @@ -1520,7 +1524,7 @@ func registerDirectViaSigner(ctx context.Context, cfg *config.Config, u *ui.UI, } // registerDirectWithKey performs a direct on-chain registration using a raw private key. -func registerDirectWithKey(ctx context.Context, u *ui.UI, net erc8004.NetworkConfig, agentURI, keyHex string) error { +func registerDirectWithKey(ctx context.Context, cfg *config.Config, u *ui.UI, net erc8004.NetworkConfig, agentURI, keyHex string) error { u.Printf(" Using direct on-chain registration with private key...") keyHex = strings.TrimPrefix(keyHex, "0x") @@ -1529,7 +1533,8 @@ func registerDirectWithKey(ctx context.Context, u *ui.UI, net erc8004.NetworkCon return fmt.Errorf("invalid private key: %w", err) } - client, err := erc8004.NewClientForNetwork(ctx, "http://localhost/rpc", net) + rpcBaseURL := stack.LocalIngressURL(cfg) + "/rpc" + client, err := erc8004.NewClientForNetwork(ctx, rpcBaseURL, net) if err != nil { return fmt.Errorf("connect to %s via eRPC: %w", net.Name, err) } @@ -1556,7 +1561,7 @@ func registerDirectWithKey(ctx context.Context, u *ui.UI, net erc8004.NetworkCon // --------------------------------------------------------------------------- // runInferenceGateway starts the x402 inference gateway and blocks until shutdown. -func runInferenceGateway(u *ui.UI, d *inference.Deployment, chain x402.ChainConfig) error { +func runInferenceGateway(u *ui.UI, d *inference.Deployment, chain x402verifier.ChainInfo) error { gw, err := inference.NewGateway(inference.GatewayConfig{ ListenAddr: d.ListenAddr, UpstreamURL: d.UpstreamURL, @@ -1593,27 +1598,6 @@ func runInferenceGateway(u *ui.UI, d *inference.Deployment, chain x402.ChainConf return gw.Start() } -// resolveX402Chain maps a chain name to an x402 ChainConfig. -func resolveX402Chain(name string) (x402.ChainConfig, error) { - switch name { - case "base", "base-mainnet": - return x402.BaseMainnet, nil - case "base-sepolia": - return x402.BaseSepolia, nil - case "ethereum", "ethereum-mainnet", "mainnet": - // Ethereum mainnet USDC: verified 2025-10-28 - return x402.ChainConfig{ - NetworkID: "ethereum", - USDCAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - Decimals: 6, - EIP3009Name: "USD Coin", - EIP3009Version: "2", - }, nil - default: - return x402.ChainConfig{}, fmt.Errorf("unsupported chain: %s (supported: base-sepolia, base, ethereum)", name) - } -} - // startSignerPortForward launches a temporary port-forward to the remote-signer // service in the given namespace. Caller must call pf.Stop() when done. func startSignerPortForward(cfg *config.Config, namespace string) (*signerPortForwarder, error) { diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index 9b8d9edc..0f4b600c 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/ObolNetwork/obol-stack/internal/config" + x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" "github.com/urfave/cli/v3" ) @@ -186,7 +187,7 @@ func TestSellInference_Flags(t *testing.T) { assertStringDefault(t, flags, "chain", "base-sepolia") assertStringDefault(t, flags, "listen", ":8402") assertStringDefault(t, flags, "upstream", "http://localhost:11434") - assertStringDefault(t, flags, "facilitator", "https://facilitator.x402.rs") + assertStringDefault(t, flags, "facilitator", "https://x402.gcp.obol.tech") assertStringDefault(t, flags, "vm-image", "ollama/ollama:latest") assertIntDefault(t, flags, "vm-cpus", 4) assertIntDefault(t, flags, "vm-memory", 8192) @@ -310,9 +311,9 @@ func TestResolveX402Chain(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := resolveX402Chain(tt.name) + _, err := x402verifier.ResolveChainInfo(tt.name) if (err != nil) != tt.wantErr { - t.Errorf("resolveX402Chain(%q) error = %v, wantErr %v", tt.name, err, tt.wantErr) + t.Errorf("ResolveChainInfo(%q) error = %v, wantErr %v", tt.name, err, tt.wantErr) } }) } diff --git a/cmd/x402-buyer/main.go b/cmd/x402-buyer/main.go index a76574a8..545edcdb 100644 --- a/cmd/x402-buyer/main.go +++ b/cmd/x402-buyer/main.go @@ -28,8 +28,10 @@ import ( func main() { var ( - configPath = flag.String("config", "/config/config.json", "path to upstream config JSON") - authsPath = flag.String("auths", "/config/auths.json", "path to pre-signed auths JSON") + configDir = flag.String("config-dir", "", "directory of per-upstream config files (SSA mode)") + authsDir = flag.String("auths-dir", "", "directory of per-upstream auth files (SSA mode)") + configPath = flag.String("config", "/config/config.json", "single config JSON file (legacy)") + authsPath = flag.String("auths", "/config/auths.json", "single auths JSON file (legacy)") statePath = flag.String("state", "/state/consumed.json", "path to persisted consumed-auth state") listen = flag.String("listen", ":8402", "listen address") reloadInterval = flag.Duration("reload-interval", 5*time.Second, "config/auth reload interval") @@ -42,14 +44,9 @@ func main() { log.Fatalf("load state: %v", err) } - cfg, err := buyer.LoadConfig(*configPath) + cfg, auths, err := loadConfigAndAuths(*configDir, *authsDir, *configPath, *authsPath) if err != nil { - log.Fatalf("load config: %v", err) - } - - auths, err := buyer.LoadAuths(*authsPath) - if err != nil { - log.Fatalf("load auths: %v", err) + log.Fatalf("load config/auths: %v", err) } proxy, err := buyer.NewProxy(cfg, auths, state) @@ -89,26 +86,25 @@ func main() { defer ticker.Stop() go func() { + reload := func(reason string) { + newCfg, newAuths, err := loadConfigAndAuths(*configDir, *authsDir, *configPath, *authsPath) + if err != nil { + log.Printf("reload (%s): %v", reason, err) + return + } + if err := proxy.Reload(newCfg, newAuths); err != nil { + log.Printf("reload proxy (%s): %v", reason, err) + } + } + for { select { case <-ctx.Done(): return case <-ticker.C: - cfg, err := buyer.LoadConfig(*configPath) - if err != nil { - log.Printf("reload config: %v", err) - continue - } - - auths, err := buyer.LoadAuths(*authsPath) - if err != nil { - log.Printf("reload auths: %v", err) - continue - } - - if err := proxy.Reload(cfg, auths); err != nil { - log.Printf("reload proxy: %v", err) - } + reload("ticker") + case <-proxy.ReloadCh(): + reload("admin") } } }() @@ -124,3 +120,33 @@ func main() { fmt.Fprintf(os.Stderr, "shutdown: %v\n", err) } } + +// loadConfigAndAuths loads config and auths from either directory mode (SSA, +// one file per upstream) or single-file mode (legacy, all upstreams in one JSON). +func loadConfigAndAuths(configDir, authsDir, configPath, authsPath string) (*buyer.Config, buyer.AuthsFile, error) { + var ( + cfg *buyer.Config + auths buyer.AuthsFile + err error + ) + + if configDir != "" { + cfg, err = buyer.LoadConfigDir(configDir) + } else { + cfg, err = buyer.LoadConfig(configPath) + } + if err != nil { + return nil, nil, fmt.Errorf("config: %w", err) + } + + if authsDir != "" { + auths, err = buyer.LoadAuthsDir(authsDir) + } else { + auths, err = buyer.LoadAuths(authsPath) + } + if err != nil { + return nil, nil, fmt.Errorf("auths: %w", err) + } + + return cfg, auths, nil +} diff --git a/docs/guides/monetize-inference.md b/docs/guides/monetize-inference.md index a0e4d961..dc009d35 100644 --- a/docs/guides/monetize-inference.md +++ b/docs/guides/monetize-inference.md @@ -253,12 +253,12 @@ A **402 Payment Required** response confirms the x402 gate is working. The respo ```json { - "x402Version": 1, + "x402Version": 2, "error": "Payment required for this resource", "accepts": [{ "scheme": "exact", - "network": "base-sepolia", - "maxAmountRequired": "1000", + "network": "eip155:84532", + "amount": "1000", "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "payTo": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "description": "Payment required for /services/my-qwen/v1/chat/completions", @@ -268,7 +268,7 @@ A **402 Payment Required** response confirms the x402 gate is working. The respo } ``` -The `maxAmountRequired` is in USDC micro-units (6 decimals): `1000` = 0.001 USDC. +The `amount` is in USDC micro-units (6 decimals): `1000` = 0.001 USDC. ### 1.7 Monitoring @@ -354,7 +354,7 @@ curl -s -X POST "$TUNNEL_URL/services/my-qwen/v1/chat/completions" \ -d '{"model":"qwen3:0.6b","messages":[{"role":"user","content":"Hello"}]}' # Step 2: Sign the EIP-712 payment (requires SDK or custom code) -# The 402 body contains: payTo, maxAmountRequired, asset, network, extra.name, extra.version +# The 402 body contains: payTo, amount, asset, network, extra.name, extra.version # Sign a TransferWithAuthorization (ERC-3009) message with: # Domain: {name: "USDC", version: "2", chainId: 84532, verifyingContract: } @@ -409,7 +409,7 @@ This proves the full public path: **Internet → Cloudflare → Traefik → x402 ## Part 3: Self-Hosted Facilitator -The x402 facilitator verifies and settles payments on-chain. By default, the stack points at `https://facilitator.x402.rs`. For reliability, sovereignty, or testing, you can run your own. +The x402 facilitator verifies and settles payments on-chain. The stack currently defaults to the Obol-operated facilitator, but the validated Base Sepolia flow in this guide uses `https://facilitator.x402.rs` because that endpoint currently advertises Base Sepolia exact support. ### 3.1 Why Self-Host diff --git a/flows/flow-02-stack-init-up.sh b/flows/flow-02-stack-init-up.sh index 2d54dc9e..607defcd 100755 --- a/flows/flow-02-stack-init-up.sh +++ b/flows/flow-02-stack-init-up.sh @@ -8,6 +8,7 @@ step "Check if cluster exists" if "$OBOL" kubectl cluster-info >/dev/null 2>&1; then pass "Cluster already running — skipping init" else + pass "No running cluster — initializing" run_step "obol stack init" "$OBOL" stack init run_step "obol stack up" "$OBOL" stack up fi diff --git a/flows/flow-04-agent.sh b/flows/flow-04-agent.sh index 945a5a02..1bb029dd 100755 --- a/flows/flow-04-agent.sh +++ b/flows/flow-04-agent.sh @@ -64,6 +64,7 @@ fi NS=$("$OBOL" openclaw list 2>/dev/null | grep -oE 'openclaw-[a-z0-9-]+' | head -1 || echo "openclaw-obol-agent") step "Agent inference via port-forward" +kill $(lsof -ti:18789) 2>/dev/null || true "$OBOL" kubectl port-forward -n "$NS" svc/openclaw 18789:18789 &>/dev/null & PF_PID=$! @@ -78,7 +79,7 @@ done out=$(curl -sf --max-time 120 -X POST http://localhost:18789/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ - -d "{\"model\":\"$FLOW_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 2+2?\"}],\"max_tokens\":50,\"stream\":false}" 2>&1) || true + -d "{\"model\":\"openclaw\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 2+2?\"}],\"max_tokens\":50,\"stream\":false}" 2>&1) || true if echo "$out" | grep -q "choices"; then pass "Agent inference returned response" @@ -159,20 +160,20 @@ else fail "OpenClaw not routing through LiteLLM — base URL: ${litellm_base:-empty}" fi -# §4 RBAC: controller design keeps separate read/write roles for the agent. -step "RBAC: monetize ClusterRoles exist" +# §4 RBAC: controller design keeps read cluster-wide, but write namespace-scoped. +step "RBAC: monetize read ClusterRole and write Role exist" cr_read=$("$OBOL" kubectl get clusterrole openclaw-monetize-read 2>&1) || true -cr_write=$("$OBOL" kubectl get clusterrole openclaw-monetize-write 2>&1) || true +role_write=$("$OBOL" kubectl get role openclaw-monetize-write -n openclaw-obol-agent 2>&1) || true if echo "$cr_read" | grep -q "openclaw-monetize-read" && \ - echo "$cr_write" | grep -q "openclaw-monetize-write"; then - pass "ClusterRoles: openclaw-monetize-read + openclaw-monetize-write" + echo "$role_write" | grep -q "openclaw-monetize-write"; then + pass "RBAC: read ClusterRole + write Role" else - fail "Missing monetize ClusterRole(s)" + fail "Missing monetize RBAC — read: ${cr_read:0:80} write: ${role_write:0:80}" fi -# §4 RBAC: write ClusterRole allows CRUD on ServiceOffers (obol.org) +# §4 RBAC: write Role allows CRUD on ServiceOffers (obol.org) only in the agent namespace. step "RBAC: openclaw-monetize-write can CRUD ServiceOffers" -write_rules=$("$OBOL" kubectl get clusterrole openclaw-monetize-write \ +write_rules=$("$OBOL" kubectl get role openclaw-monetize-write -n openclaw-obol-agent \ -o jsonpath='{.rules}' 2>&1) || true if echo "$write_rules" | python3 -c " import sys, json @@ -191,16 +192,16 @@ else fail "RBAC write rule missing ServiceOffer CRUD — ${write_rules:0:100}" fi -# §4: Both monetize ClusterRoleBindings must include openclaw SA as a subject. +# §4: Read ClusterRoleBinding and write RoleBinding must include openclaw SA as subject. step "RBAC: openclaw-monetize bindings have openclaw SA as subject" rbac_out=$("$OBOL" kubectl get clusterrolebinding openclaw-monetize-read-binding \ -o jsonpath='{.subjects}' 2>&1) || true -rbac_write=$("$OBOL" kubectl get clusterrolebinding openclaw-monetize-write-binding \ +rbac_write=$("$OBOL" kubectl get rolebinding openclaw-monetize-write-binding -n openclaw-obol-agent \ -o jsonpath='{.subjects}' 2>&1) || true if echo "$rbac_out" | grep -q "openclaw" && echo "$rbac_write" | grep -q "openclaw"; then - pass "Both monetize ClusterRoleBindings have openclaw SA" + pass "Read ClusterRoleBinding and write RoleBinding have openclaw SA" else - fail "ClusterRoleBinding missing openclaw SA — read: ${rbac_out:0:50} write: ${rbac_write:0:50}" + fail "RBAC binding missing openclaw SA — read: ${rbac_out:0:50} write: ${rbac_write:0:50}" fi # §2 component table: Remote Signer running (getting-started §2 lists it as a component) diff --git a/flows/flow-08-buy.sh b/flows/flow-08-buy.sh index 5949923d..9d4b198e 100755 --- a/flows/flow-08-buy.sh +++ b/flows/flow-08-buy.sh @@ -64,8 +64,9 @@ assert d.get('x402Version') is not None, 'missing x402Version' a = d['accepts'][0] assert a['payTo'], 'missing payTo' assert a['network'], 'missing network' -assert a['maxAmountRequired'], 'missing maxAmountRequired' -print('OK: payTo=%s network=%s amount=%s' % (a['payTo'], a['network'], a['maxAmountRequired'])) +amount = a.get('amount') or a.get('maxAmountRequired') +assert amount, 'missing amount/maxAmountRequired' +print('OK: payTo=%s network=%s amount=%s' % (a['payTo'], a['network'], amount)) " 2>&1; then pass "402 body validated" else @@ -116,8 +117,11 @@ if resp.status_code != 402: req_data = resp.json() accept = req_data["accepts"][0] pay_to = accept["payTo"] -amount = accept["maxAmountRequired"] # micro-USDC string e.g. "1000" +amount = accept.get("amount") or accept.get("maxAmountRequired") # micro-USDC string e.g. "1000" network = accept["network"] +asset = accept.get("asset") or USDC_ADDRESS +domain_name = "USDC" +domain_version = "2" # 2. Sign EIP-712 TransferWithAuthorization (ERC-3009) nonce = "0x" + secrets.token_hex(32) @@ -142,7 +146,7 @@ structured = { }, "primaryType": "TransferWithAuthorization", "domain": { - "name": "USDC", "version": "2", + "name": domain_name, "version": domain_version, "chainId": CHAIN_ID, "verifyingContract": USDC_ADDRESS, }, "message": { @@ -157,11 +161,14 @@ structured = { signed = acct.sign_message(encode_typed_data(full_message=structured)) sig_hex = "0x" + signed.signature.hex() -# 3. Build x402 payment envelope +# 3. Build x402 v2 payment envelope. The accepted requirement must round-trip +# exactly enough for strict facilitators to deserialize the EIP-3009 variant. +accepted = dict(accept) +accepted["amount"] = amount +accepted["asset"] = asset envelope = { - "x402Version": 1, - "scheme": "exact", - "network": network, + "x402Version": 2, + "accepted": accepted, "payload": { "signature": sig_hex, "authorization": { @@ -173,10 +180,6 @@ envelope = { "nonce": nonce, }, }, - "resource": { - "payTo": pay_to, "maxAmountRequired": amount, - "asset": USDC_ADDRESS, "network": network, - }, } payment_header = base64.b64encode(json.dumps(envelope).encode()).decode() diff --git a/flows/flow-11-dual-stack.sh b/flows/flow-11-dual-stack.sh new file mode 100755 index 00000000..6038efe5 --- /dev/null +++ b/flows/flow-11-dual-stack.sh @@ -0,0 +1,696 @@ +#!/bin/bash +# Flow 11: Dual-Stack — Alice sells, Bob discovers via ERC-8004 and buys. +# +# Two independent obol stacks on the same machine. Alice registers her +# inference service on the ERC-8004 Identity Registry (Base Sepolia). +# Bob's agent discovers her by scanning the registry, buys inference +# tokens via x402, and uses the paid/* sidecar route. +# +# This is the most human-like integration test: every interaction with +# Bob is through natural language prompts to his OpenClaw agent. +# +# Requires: +# - .env with REMOTE_SIGNER_PRIVATE_KEY (funded on Base Sepolia with ETH + USDC) +# - Docker running, with the configured Alice/Bob ingress ports free +# - Ollama running (Alice serves local model inference) +# - cast (Foundry) for balance checks +# +# Usage: +# ./flows/flow-11-dual-stack.sh +# +# Approximate runtime: 15-20 minutes (first run, image pulls) +# 8-12 minutes (subsequent, cached images) +# +# Facilitator defaults to the Obol-operated service. +# Override if needed: +# FLOW11_FACILITATOR_URL=https://... +# +# Optional port overrides for isolated worktrees: +# FLOW11_ALICE_HTTP_PORT FLOW11_ALICE_HTTP_ALT_PORT +# FLOW11_ALICE_HTTPS_PORT FLOW11_ALICE_HTTPS_ALT_PORT +# FLOW11_BOB_HTTP_PORT FLOW11_BOB_HTTP_ALT_PORT +# FLOW11_BOB_HTTPS_PORT FLOW11_BOB_HTTPS_ALT_PORT +source "$(dirname "$0")/lib.sh" + +# ═════════════════════════════════════════════════════════════════ +# PREFLIGHT +# ═════════════════════════════════════════════════════════════════ + +ALICE_DIR="$OBOL_ROOT/.workspace-alice" +BOB_DIR="$OBOL_ROOT/.workspace-bob" + +# Host port overrides for running multiple isolated worktrees at once. +ALICE_HTTP_PORT="${FLOW11_ALICE_HTTP_PORT:-80}" +ALICE_HTTP_ALT_PORT="${FLOW11_ALICE_HTTP_ALT_PORT:-8080}" +ALICE_HTTPS_PORT="${FLOW11_ALICE_HTTPS_PORT:-443}" +ALICE_HTTPS_ALT_PORT="${FLOW11_ALICE_HTTPS_ALT_PORT:-8443}" + +BOB_HTTP_PORT="${FLOW11_BOB_HTTP_PORT:-9080}" +BOB_HTTP_ALT_PORT="${FLOW11_BOB_HTTP_ALT_PORT:-9180}" +BOB_HTTPS_PORT="${FLOW11_BOB_HTTPS_PORT:-9443}" +BOB_HTTPS_ALT_PORT="${FLOW11_BOB_HTTPS_ALT_PORT:-9543}" +FACILITATOR_URL="${FLOW11_FACILITATOR_URL:-https://x402.gcp.obol.tech}" + +is_port_listening() { + lsof -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1 +} + +require_ports_free() { + local busy=() + local port + for port in "$@"; do + if is_port_listening "$port"; then + busy+=("$port") + fi + done + if [ "${#busy[@]}" -gt 0 ]; then + echo "${busy[*]}" + return 1 + fi +} + +pick_free_port() { + python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + +rewrite_k3d_ports() { + local config_path="$1" + local http_port="$2" + local http_alt_port="$3" + local https_port="$4" + local https_alt_port="$5" + + if [ ! -f "$config_path" ]; then + echo "missing k3d config: $config_path" >&2 + return 1 + fi + + sed -i.bak \ + -e "s/port: 80:80/port: ${http_port}:80/" \ + -e "s/port: 8080:80/port: ${http_alt_port}:80/" \ + -e "s/port: 443:443/port: ${https_port}:443/" \ + -e "s/port: 8443:443/port: ${https_alt_port}:443/" \ + "$config_path" +} + +extract_assistant_content() { + FLOW11_RESPONSE="$1" python3 - <<'PY' +import json +import os +import sys + +try: + data = json.loads(os.environ["FLOW11_RESPONSE"]) + content = data["choices"][0]["message"].get("content", "") + if isinstance(content, list): + content = json.dumps(content) + sys.stdout.write(content) +except Exception: + sys.exit(1) +PY +} + +# Helper to run obol as Alice or Bob +alice() { + OBOL_DEVELOPMENT=true \ + OBOL_NONINTERACTIVE=true \ + OBOL_CONFIG_DIR="$ALICE_DIR/config" \ + OBOL_BIN_DIR="$ALICE_DIR/bin" \ + OBOL_DATA_DIR="$ALICE_DIR/data" \ + "$ALICE_DIR/bin/obol" "$@" +} +bob() { + OBOL_DEVELOPMENT=true \ + OBOL_NONINTERACTIVE=true \ + OBOL_CONFIG_DIR="$BOB_DIR/config" \ + OBOL_BIN_DIR="$BOB_DIR/bin" \ + OBOL_DATA_DIR="$BOB_DIR/data" \ + "$BOB_DIR/bin/obol" "$@" +} + +purchase_request_status() { + bob kubectl get purchaserequests.obol.org -n openclaw-obol-agent --no-headers 2>&1 || true +} + +buyer_sidecar_status() { + bob kubectl exec -n llm deployment/litellm -c litellm -- \ + python3 -c " +import urllib.request, json +try: + resp = urllib.request.urlopen('http://localhost:8402/status', timeout=5) + d = json.loads(resp.read()) + for name, info in d.items(): + print('%s: remaining=%d spent=%d model=%s' % (name, info['remaining'], info['spent'], info['public_model'])) +except Exception as e: + print('error: %s' % e) +" 2>&1 || true +} + +run_tail_or_fail() { + local desc="$1" + local success="$2" + local success_lines="${3:-3}" + shift 3 + + step "$desc" + local out rc + set +e + out=$("$@" 2>&1) + rc=$? + set -e + + if [ "$rc" -ne 0 ]; then + printf '%s\n' "$out" | tail -120 + fail "$desc failed (exit $rc)" + emit_metrics + exit "$rc" + fi + + printf '%s\n' "$out" | tail -"$success_lines" + pass "$success" +} + +litellm_paid_inference() { + bob kubectl exec -n llm deployment/litellm -c litellm -- \ + python3 -c " +import urllib.request, urllib.error, json, time +t0 = time.time() +req = urllib.request.Request('http://localhost:4000/v1/chat/completions', + data=json.dumps({ + 'model': '$PAID_MODEL', + 'messages': [{'role': 'user', 'content': 'What is the meaning of life? Answer in one sentence.'}], + 'max_tokens': 100, 'stream': False + }).encode(), + headers={'Content-Type': 'application/json', 'Authorization': 'Bearer $BOB_MASTER_KEY'}) +try: + resp = urllib.request.urlopen(req, timeout=180) + elapsed = time.time() - t0 + body = json.loads(resp.read()) + c = body['choices'][0]['message'] + content = c.get('content', '') or c.get('reasoning_content', '') + print('STATUS=%d TIME=%.1fs' % (resp.status, elapsed)) + print('MODEL=%s' % body.get('model', '?')) + print('CONTENT=%s' % content[:300]) +except urllib.error.HTTPError as e: + print('ERROR=%d %s' % (e.code, e.read().decode()[:300])) +" 2>&1 || true +} + +step "Preflight: .env key" +SIGNER_KEY=$(grep REMOTE_SIGNER_PRIVATE_KEY "$OBOL_ROOT/.env" 2>/dev/null | cut -d= -f2) +if [ -z "$SIGNER_KEY" ]; then + fail "REMOTE_SIGNER_PRIVATE_KEY not found in .env" + emit_metrics; exit 1 +fi +# Derive Alice (index 1) and Bob (index 2) +ALICE_WALLET=$(env -u CHAIN cast wallet address --private-key "$(env -u CHAIN cast keccak "$(env -u CHAIN cast abi-encode 'f(bytes32,uint256)' "$SIGNER_KEY" 1)")" 2>/dev/null) +BOB_WALLET=$(env -u CHAIN cast wallet address --private-key "$(env -u CHAIN cast keccak "$(env -u CHAIN cast abi-encode 'f(bytes32,uint256)' "$SIGNER_KEY" 2)")" 2>/dev/null) +# Use the .env key directly as Alice's seller wallet (it has ETH for registration gas) +ALICE_WALLET=$(env -u CHAIN cast wallet address --private-key "$SIGNER_KEY" 2>/dev/null) +pass "Alice=$ALICE_WALLET, Bob=$BOB_WALLET" + +step "Preflight: wallets are EOAs" +alice_code=$(env -u CHAIN cast code "$ALICE_WALLET" --rpc-url https://sepolia.base.org 2>/dev/null) +bob_code=$(env -u CHAIN cast code "$BOB_WALLET" --rpc-url https://sepolia.base.org 2>/dev/null) +if [ "$alice_code" != "0x" ] || [ "$bob_code" != "0x" ]; then + fail "Wallet has contract code (EIP-7702?) — Alice=$alice_code Bob=$bob_code" + emit_metrics; exit 1 +fi +pass "Both wallets are regular EOAs" + +step "Preflight: Bob has USDC" +bob_usdc_raw=$(env -u CHAIN cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ + "balanceOf(address)(uint256)" "$BOB_WALLET" --rpc-url https://sepolia.base.org 2>/dev/null) +bob_usdc=$(echo "$bob_usdc_raw" | grep -oE '^[0-9]+' | head -1) +if [ -z "$bob_usdc" ] || [ "$bob_usdc" = "0" ]; then + fail "Bob ($BOB_WALLET) has 0 USDC on Base Sepolia — fund first" + emit_metrics; exit 1 +fi +pass "Bob has $bob_usdc micro-USDC" + +step "Preflight: Alice has ETH for registration gas" +alice_eth=$(env -u CHAIN cast balance "$ALICE_WALLET" --rpc-url https://sepolia.base.org --ether 2>/dev/null | grep -oE '^[0-9.]+' | head -1) +pass "Alice has $alice_eth ETH" + +step "Preflight: clean stale ethereum network deployments" +# Ethereum full nodes (execution+consensus) use 50-200 GB of disk per network. +# This test only needs eRPC (lightweight proxy) for Base Sepolia RPC access. +# Delete any stale network namespaces to free disk. +for ns in $(kubectl get ns --no-headers 2>/dev/null | awk '{print $1}' | grep "^ethereum-"); do + echo " Deleting stale network namespace: $ns" + kubectl delete ns "$ns" --timeout=60s 2>/dev/null || true +done +pass "No ethereum full nodes deployed (using eRPC proxy for RPC)" + +step "Preflight: facilitator reachable" +if curl -sf --max-time 5 "$FACILITATOR_URL/supported" >/dev/null 2>&1; then + pass "$FACILITATOR_URL reachable" +else + fail "$FACILITATOR_URL unreachable" + emit_metrics; exit 1 +fi + +step "Preflight: facilitator supports Base Sepolia exact" +supported_json=$(curl -sf --max-time 10 "$FACILITATOR_URL/supported" 2>/dev/null || true) +if SUPPORTED_JSON="$supported_json" python3 -c ' +import json, os, sys +try: + data = json.loads(os.environ["SUPPORTED_JSON"]) +except Exception: + sys.exit(1) +for kind in data.get("kinds", []): + if kind.get("scheme") != "exact": + continue + network = kind.get("network") + if network in ("base-sepolia", "eip155:84532"): + sys.exit(0) +sys.exit(1) +' +then + pass "$FACILITATOR_URL supports Base Sepolia exact" +else + fail "$FACILITATOR_URL does not advertise Base Sepolia exact in /supported" + echo " Supported kinds:" + echo "$supported_json" | python3 -m json.tool 2>/dev/null | sed 's/^/ /' + emit_metrics; exit 1 +fi + +step "Preflight: Alice/Bob ingress ports free" +busy_ports=$(require_ports_free \ + "$ALICE_HTTP_PORT" "$ALICE_HTTP_ALT_PORT" "$ALICE_HTTPS_PORT" "$ALICE_HTTPS_ALT_PORT" \ + "$BOB_HTTP_PORT" "$BOB_HTTP_ALT_PORT" "$BOB_HTTPS_PORT" "$BOB_HTTPS_ALT_PORT") || { + fail "Ports in use (LISTEN): $busy_ports — set FLOW11_*_PORT overrides or cleanup existing clusters first" + emit_metrics; exit 1 +} +pass "Ports free: Alice=$ALICE_HTTP_PORT/$ALICE_HTTP_ALT_PORT/$ALICE_HTTPS_PORT/$ALICE_HTTPS_ALT_PORT Bob=$BOB_HTTP_PORT/$BOB_HTTP_ALT_PORT/$BOB_HTTPS_PORT/$BOB_HTTPS_ALT_PORT" + +# Record pre-test balances (strip cast's scientific notation suffix) +PRE_ALICE_USDC=$(env -u CHAIN cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ + "balanceOf(address)(uint256)" "$ALICE_WALLET" --rpc-url https://sepolia.base.org 2>/dev/null | grep -oE '^[0-9]+' | head -1) +PRE_BOB_USDC=$bob_usdc + +# ═════════════════════════════════════════════════════════════════ +# BOOTSTRAP ALICE (seller, configurable ports) +# ═════════════════════════════════════════════════════════════════ + +step "Alice: build obol binary" +go build -o "$OBOL_ROOT/.build/obol" ./cmd/obol 2>&1 || { fail "build failed"; emit_metrics; exit 1; } +pass "Binary built" + +step "Alice: bootstrap workspace" +mkdir -p "$ALICE_DIR"/{bin,config,data} +cp "$OBOL_ROOT/.build/obol" "$ALICE_DIR/bin/obol" +chmod +x "$ALICE_DIR/bin/obol" +# Copy deps from obolup (assumes obolup was run previously for the shared tools) +for tool in kubectl helm helmfile k3d k9s openclaw; do + src=$(which "$tool" 2>/dev/null || echo "$OBOL_ROOT/.workspace/bin/$tool") + [ -f "$src" ] && ln -sf "$src" "$ALICE_DIR/bin/$tool" 2>/dev/null +done +pass "Alice workspace ready" + +step "Alice: stack init" +alice stack init 2>&1 | tail -1 +rewrite_k3d_ports "$ALICE_DIR/config/k3d.yaml" \ + "$ALICE_HTTP_PORT" "$ALICE_HTTP_ALT_PORT" "$ALICE_HTTPS_PORT" "$ALICE_HTTPS_ALT_PORT" +pass "Alice ports set to $ALICE_HTTP_PORT/$ALICE_HTTP_ALT_PORT/$ALICE_HTTPS_PORT/$ALICE_HTTPS_ALT_PORT" + +run_tail_or_fail "Alice: stack up" "Alice stack up completed" 3 alice stack up + +poll_step_grep "Alice: x402 pods running" "Running" 30 10 \ + alice kubectl get pods -n x402 --no-headers + +# ═════════════════════════════════════════════════════════════════ +# ALICE: SELL INFERENCE + REGISTER ON-CHAIN +# ═════════════════════════════════════════════════════════════════ + +step "Alice: configure x402 pricing" +alice sell pricing \ + --wallet "$ALICE_WALLET" \ + --chain base-sepolia \ + --facilitator-url "$FACILITATOR_URL" 2>&1 | tail -1 +pass "Pricing configured" + +step "Alice: CA bundle populated" +ca_size=$(alice kubectl get cm ca-certificates -n x402 -o jsonpath='{.data}' 2>/dev/null | wc -c | tr -d ' ') +if [ "$ca_size" -gt 1000 ]; then + pass "CA bundle: $ca_size bytes" +else + fail "CA bundle empty or too small: $ca_size bytes" +fi + +step "Alice: create ServiceOffer" +alice sell http alice-inference \ + --wallet "$ALICE_WALLET" \ + --chain base-sepolia \ + --per-request 0.001 \ + --namespace llm \ + --upstream litellm \ + --port 4000 \ + --health-path /health/readiness \ + --register \ + --register-name "Dual-Stack Test Inference" \ + --register-description "Integration test: local model inference via x402" \ + --register-skills natural_language_processing/text_generation \ + --register-domains technology/artificial_intelligence 2>&1 | tail -3 +pass "ServiceOffer created" + +poll_step_grep "Alice: ServiceOffer Ready" "True" 24 5 \ + alice kubectl get serviceoffers.obol.org alice-inference -n llm \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' + +step "Alice: tunnel URL" +TUNNEL_URL=$(alice tunnel status 2>&1 | grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' | head -1) +if [ -z "$TUNNEL_URL" ]; then + fail "No tunnel URL" + emit_metrics; exit 1 +fi +pass "Tunnel: $TUNNEL_URL" + +poll_step_grep "Alice: 402 gate works" "402" 12 5 \ + curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST \ + "$TUNNEL_URL/services/alice-inference/v1/chat/completions" \ + -H "Content-Type: application/json" \ + -d '{"model":"qwen3.5:9b","messages":[{"role":"user","content":"hi"}],"max_tokens":5}' + +step "Alice: add Base Sepolia RPC to eRPC (for on-chain registration)" +alice network add base-sepolia --endpoint https://sepolia.base.org --allow-writes 2>&1 | tail -2 +# eRPC needs a restart to pick up the new chain config +alice kubectl rollout restart deployment/erpc -n erpc 2>/dev/null || true +alice kubectl rollout status deployment/erpc -n erpc --timeout=60s 2>/dev/null || true +pass "Base Sepolia RPC added to eRPC (with write access)" + +step "Alice: register on ERC-8004 (Base Sepolia)" +# Use the .env private key for on-chain registration (has ETH for gas) +KEY_FILE=$(mktemp) +echo "$SIGNER_KEY" > "$KEY_FILE" +set +e +register_out=$(alice sell register \ + --chain base-sepolia \ + --name "Dual-Stack Test Inference" \ + --description "Integration test: local model inference via x402" \ + --private-key-file "$KEY_FILE" 2>&1) +register_rc=$? +set -e +rm -f "$KEY_FILE" +echo "$register_out" | tail -5 +if [ "$register_rc" -eq 0 ] && echo "$register_out" | grep -q "Agent ID:\|registered"; then + AGENT_ID=$(echo "$register_out" | grep -o 'Agent ID: [0-9]*' | grep -o '[0-9]*' | head -1) + pass "ERC-8004 registered: Agent ID $AGENT_ID" +else + fail "Registration failed: ${register_out:0:200}" +fi + +# ═════════════════════════════════════════════════════════════════ +# BOOTSTRAP BOB (buyer, configurable ports) +# ═════════════════════════════════════════════════════════════════ + +step "Bob: bootstrap workspace" +mkdir -p "$BOB_DIR"/{bin,config,data} +cp "$OBOL_ROOT/.build/obol" "$BOB_DIR/bin/obol" +chmod +x "$BOB_DIR/bin/obol" +for tool in kubectl helm helmfile k3d k9s openclaw; do + src=$(which "$tool" 2>/dev/null || echo "$OBOL_ROOT/.workspace/bin/$tool") + [ -f "$src" ] && ln -sf "$src" "$BOB_DIR/bin/$tool" 2>/dev/null +done +pass "Bob workspace ready" + +step "Bob: stack init" +bob stack init 2>&1 | tail -1 +rewrite_k3d_ports "$BOB_DIR/config/k3d.yaml" \ + "$BOB_HTTP_PORT" "$BOB_HTTP_ALT_PORT" "$BOB_HTTPS_PORT" "$BOB_HTTPS_ALT_PORT" +pass "Bob ports set to $BOB_HTTP_PORT/$BOB_HTTP_ALT_PORT/$BOB_HTTPS_PORT/$BOB_HTTPS_ALT_PORT" + +run_tail_or_fail "Bob: stack up" "Bob stack up completed" 3 bob stack up + +poll_step_grep "Bob: x402 pods running" "Running" 30 10 \ + bob kubectl get pods -n x402 --no-headers + +step "Bob: add Base Sepolia RPC to eRPC" +bob network add base-sepolia --endpoint https://sepolia.base.org 2>&1 | tail -2 +bob kubectl rollout restart deployment/erpc -n erpc 2>/dev/null || true +bob kubectl rollout status deployment/erpc -n erpc --timeout=60s 2>/dev/null || true +pass "Bob eRPC configured for Base Sepolia" + +# Wait for Bob's OpenClaw agent to be ready +poll_step_grep "Bob: OpenClaw agent ready" "Running" 24 5 \ + bob kubectl get pods -n openclaw-obol-agent -l app.kubernetes.io/name=openclaw --no-headers + +# ═════════════════════════════════════════════════════════════════ +# BOB: FUND REMOTE-SIGNER WALLET (shortcut — see #331 for obol wallet import) +# ═════════════════════════════════════════════════════════════════ + +step "Bob: fund remote-signer wallet with USDC" +# The remote-signer auto-generates a wallet during stack up. +# We need to fund it from the .env key so buy.py can sign auths. +# Read wallet address from wallet.json (most reliable source) +BOB_SIGNER_ADDR=$(python3 -c " +import json, sys +try: + d = json.load(open('$BOB_DIR/config/applications/openclaw/obol-agent/wallet.json')) + print(d.get('address','')) +except: pass +" 2>&1) +if [ -n "$BOB_SIGNER_ADDR" ]; then + echo " Remote-signer wallet: $BOB_SIGNER_ADDR" + # Send USDC (0.05 USDC = 50000 micro-units) from .env key + env -u CHAIN cast send --private-key "$SIGNER_KEY" \ + 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ + "transfer(address,uint256)" "$BOB_SIGNER_ADDR" 50000 \ + --rpc-url https://sepolia.base.org 2>&1 | grep -E "status" || true + POST_FUND_ALICE_USDC=$(env -u CHAIN cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ + "balanceOf(address)(uint256)" "$ALICE_WALLET" --rpc-url https://sepolia.base.org 2>/dev/null | grep -oE '^[0-9]+' | head -1) + POST_FUND_BOB_SIGNER_USDC=$(env -u CHAIN cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ + "balanceOf(address)(uint256)" "$BOB_SIGNER_ADDR" --rpc-url https://sepolia.base.org 2>/dev/null | grep -oE '^[0-9]+' | head -1) + pass "Funded $BOB_SIGNER_ADDR with 0.05 USDC" +else + fail "Could not determine Bob's remote-signer address" +fi + +# ═════════════════════════════════════════════════════════════════ +# BOB'S AGENT: DISCOVER ALICE VIA ERC-8004 + BUY + USE +# ═════════════════════════════════════════════════════════════════ + +step "Bob: get OpenClaw gateway token" +BOB_TOKEN=$(bob openclaw token obol-agent 2>/dev/null) +if [ -z "$BOB_TOKEN" ]; then + fail "Could not get Bob's gateway token" + emit_metrics; exit 1 +fi +pass "Token: ${BOB_TOKEN:0:10}..." + +# Port-forward to Bob's OpenClaw for chat API access. +BOB_AGENT_PORT=$(pick_free_port) +PF_AGENT_LOG=$(mktemp) +bob kubectl port-forward -n openclaw-obol-agent svc/openclaw "${BOB_AGENT_PORT}:18789" >"$PF_AGENT_LOG" 2>&1 & +PF_AGENT=$! + +step "Bob: OpenClaw API port-forward ready" +pf_ready=0 +for i in $(seq 1 20); do + if python3 - "$BOB_AGENT_PORT" <<'PY' +import socket +import sys + +sock = socket.socket() +sock.settimeout(1) +try: + sock.connect(("127.0.0.1", int(sys.argv[1]))) +except OSError: + sys.exit(1) +finally: + sock.close() +PY + then + pf_ready=1 + break + fi + if ! kill -0 "$PF_AGENT" 2>/dev/null; then + break + fi + sleep 1 +done +if [ "$pf_ready" = "1" ]; then + pass "OpenClaw API available on localhost:$BOB_AGENT_PORT" +else + fail "OpenClaw port-forward failed: $(tail -n 10 "$PF_AGENT_LOG" 2>/dev/null | tr '\n' ' ')" + cleanup_pid "$PF_AGENT" + rm -f "$PF_AGENT_LOG" + emit_metrics; exit 1 +fi + +step "Bob's agent: discover Alice via ERC-8004 registry" +discover_response=$(curl -sf --max-time 300 \ + -X POST "http://localhost:${BOB_AGENT_PORT}/v1/chat/completions" \ + -H "Authorization: Bearer $BOB_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"model\": \"openclaw\", + \"messages\": [{ + \"role\": \"user\", + \"content\": \"Search the ERC-8004 agent identity registry on Base Sepolia for recently registered AI inference services that support x402 payments. Use the discovery skill to scan for agents. Look for one named 'Dual-Stack Test Inference' or similar with natural_language_processing skills. Report what you find — the agent ID, name, endpoint URL, and whether it supports x402.\" + }], + \"max_tokens\": 4000, + \"stream\": false + }" 2>&1) + +discover_content=$(extract_assistant_content "$discover_response" 2>/dev/null || true) +echo "${discover_content:0:500}" +if [ -n "$discover_content" ] && [ "${#discover_content}" -gt 100 ]; then + pass "Agent discovered Alice's service" +else + fail "Discovery response: ${discover_response:0:300}" +fi + +step "Bob's agent: buy inference from Alice" +buy_response=$(curl -sf --max-time 300 \ + -X POST "http://localhost:${BOB_AGENT_PORT}/v1/chat/completions" \ + -H "Authorization: Bearer $BOB_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"model\": \"openclaw\", + \"messages\": [ + {\"role\": \"user\", \"content\": \"Search the ERC-8004 registry on Base Sepolia for the agent named 'Dual-Stack Test Inference'. Report its endpoint.\"}, + {\"role\": \"assistant\", \"content\": \"I found the agent. Its endpoint is $TUNNEL_URL/services/alice-inference\"}, + {\"role\": \"user\", \"content\": \"Now use the buy-inference skill to buy 5 inference tokens from Alice. Run exactly: python3 scripts/buy.py buy alice-inference --endpoint $TUNNEL_URL/services/alice-inference/v1/chat/completions --model qwen3.5:9b --count 5\"} + ], + \"max_tokens\": 4000, + \"stream\": false + }" 2>&1) + +buy_content=$(extract_assistant_content "$buy_response" 2>/dev/null || true) +echo "${buy_content:0:500}" +if [ -n "$buy_content" ] && [ "${#buy_content}" -gt 100 ]; then + pass "Agent bought Alice's inference" +else + fail "Buy response: ${buy_response:0:300}" +fi + +poll_step_grep "Bob: PurchaseRequest Ready" "True" 24 5 purchase_request_status +pr_status=$(purchase_request_status) +if echo "$pr_status" | grep -q "True"; then + pass "PurchaseRequest CR ready: $pr_status" +else + fail "PurchaseRequest CR not ready: $pr_status" + cleanup_pid "$PF_AGENT" + rm -f "$PF_AGENT_LOG" + emit_metrics; exit 1 +fi + +step "Bob: LiteLLM rollout settled" +bob kubectl rollout status deployment/litellm -n llm --timeout=180s 2>&1 | tail -2 +pass "LiteLLM rollout settled" + +poll_step_grep "Bob: verify buyer sidecar has auths" "remaining=[1-9]" 24 5 buyer_sidecar_status +buyer_status=$(buyer_sidecar_status) +pass "Sidecar has auths: $buyer_status" + +# Extract the paid model name from sidecar status +PAID_MODEL=$(echo "$buyer_status" | grep -o 'model=[^ ]*' | sed 's/model=//' | head -1) +if [ -z "$PAID_MODEL" ]; then + PAID_MODEL="paid/qwen3.5:9b" # fallback +fi + +step "Bob's agent: use paid model for inference" +BOB_MASTER_KEY=$(bob kubectl get secret litellm-secrets -n llm \ + -o jsonpath='{.data.LITELLM_MASTER_KEY}' 2>/dev/null | base64 -d) +BUY_START_BLOCK=$(env -u CHAIN cast block-number --rpc-url https://sepolia.base.org 2>/dev/null | tr -d ' ') + +inference_response=$(litellm_paid_inference) +if echo "$inference_response" | grep -q "STATUS=200"; then + pass "Paid inference succeeded" + echo "$inference_response" +else + fail "Paid inference failed: $inference_response" + cleanup_pid "$PF_AGENT" + rm -f "$PF_AGENT_LOG" + emit_metrics; exit 1 +fi + +cleanup_pid $PF_AGENT +rm -f "$PF_AGENT_LOG" + +# ═════════════════════════════════════════════════════════════════ +# VERIFY ON-CHAIN SETTLEMENT +# ═════════════════════════════════════════════════════════════════ + +step "On-chain: balance changes" +POST_ALICE_USDC=$(env -u CHAIN cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ + "balanceOf(address)(uint256)" "$ALICE_WALLET" --rpc-url https://sepolia.base.org 2>/dev/null | grep -oE '^[0-9]+' | head -1) +POST_BOB_SIGNER_USDC=$(env -u CHAIN cast call 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ + "balanceOf(address)(uint256)" "$BOB_SIGNER_ADDR" --rpc-url https://sepolia.base.org 2>/dev/null | grep -oE '^[0-9]+' | head -1) +ALICE_AFTER_FUND_ONLY=$((PRE_ALICE_USDC - 50000)) +echo " Alice (pre-run): $PRE_ALICE_USDC" +echo " Alice (expected after funding only): $ALICE_AFTER_FUND_ONLY" +echo " Alice (final): $POST_ALICE_USDC" +echo " Bob signer (final): $POST_BOB_SIGNER_USDC" +if [ -n "$POST_ALICE_USDC" ] && [ "$POST_ALICE_USDC" -gt "$ALICE_AFTER_FUND_ONLY" ] 2>/dev/null; then + pass "Alice received USDC settlement" +else + fail "Alice balance did not recover above funding-only expectation (expected > $ALICE_AFTER_FUND_ONLY, got $POST_ALICE_USDC)" +fi +if [ -n "$POST_BOB_SIGNER_USDC" ] && [ "$POST_BOB_SIGNER_USDC" -lt 50000 ] 2>/dev/null; then + pass "Bob remote-signer spent USDC" +else + fail "Bob remote-signer balance did not drop below funded amount (expected < 50000, got $POST_BOB_SIGNER_USDC)" +fi + +step "On-chain: settlement tx hash" +transfer_logs=$(env -u CHAIN cast logs --json --rpc-url https://sepolia.base.org \ + --address 0x036CbD53842c5426634e7929541eC2318f3dCF7e \ + --from-block "$BUY_START_BLOCK" --to-block latest \ + "Transfer(address,address,uint256)" 2>/dev/null || true) +if FLOW11_TRANSFER_LOGS="$transfer_logs" FLOW11_ALICE="$ALICE_WALLET" FLOW11_BOB_SIGNER="$BOB_SIGNER_ADDR" python3 - <<'PY' +import json, os, sys + +logs = json.loads(os.environ["FLOW11_TRANSFER_LOGS"] or "[]") +alice = os.environ["FLOW11_ALICE"].lower().replace("0x", "") +bob = os.environ["FLOW11_BOB_SIGNER"].lower().replace("0x", "") +matches = [] +for log in logs: + topics = log.get("topics", []) + if len(topics) < 3: + continue + src = topics[1][-40:].lower() + dst = topics[2][-40:].lower() + if src != bob or dst != alice: + continue + amount = int(log.get("data", "0x0"), 16) + matches.append((log.get("transactionHash"), amount)) +if not matches: + sys.exit(1) +for tx, amount in matches: + print(f" tx={tx} amount={amount}") +PY +then + pass "Settlement tx hashes printed above" +else + fail "No Bob-signer -> Alice USDC transfer logs found after block $BUY_START_BLOCK" +fi + +# ═════════════════════════════════════════════════════════════════ +# CLEANUP +# ═════════════════════════════════════════════════════════════════ + +step "Cleanup: delete Alice's ServiceOffer" +alice sell delete alice-inference -n llm -f 2>&1 | tail -1 + +step "Cleanup: Alice stack down" +alice stack down 2>&1 | tail -1 + +step "Cleanup: Bob stack down" +bob stack down 2>&1 | tail -1 + +emit_metrics +echo "" +echo "════════════════════════════════════════════════════════════" +echo " Dual-stack test complete: $PASS_COUNT/$STEP_COUNT passed" +echo " Alice: $ALICE_WALLET" +echo " Bob: $BOB_WALLET" +echo " Tunnel: $TUNNEL_URL" +echo "════════════════════════════════════════════════════════════" diff --git a/go.mod b/go.mod index c5522404..8f759dcf 100644 --- a/go.mod +++ b/go.mod @@ -4,16 +4,16 @@ go 1.25.1 require ( github.com/charmbracelet/lipgloss v1.1.0 + github.com/coinbase/x402/go v0.0.0-20260331075907-bff876de232a github.com/cucumber/godog v0.15.1 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 - github.com/ethereum/go-ethereum v1.16.5 + github.com/ethereum/go-ethereum v1.16.7 github.com/google/go-sev-guest v0.14.1 github.com/google/go-tdx-guest v0.3.1 github.com/google/uuid v1.6.0 github.com/hf/nitrite v0.0.0-20241225144000-c2d5d3c4f303 github.com/hf/nsm v0.0.0-20220930140112-cd181bd646b9 - github.com/mark3labs/x402-go v0.13.0 github.com/mattn/go-isatty v0.0.20 github.com/prometheus/client_golang v1.15.0 github.com/prometheus/client_model v0.3.0 @@ -21,33 +21,25 @@ require ( github.com/shopspring/decimal v1.3.1 github.com/urfave/cli/v2 v2.27.5 github.com/urfave/cli/v3 v3.6.2 - golang.org/x/crypto v0.45.0 + golang.org/x/crypto v0.46.0 golang.org/x/sys v0.39.0 - golang.org/x/term v0.37.0 + golang.org/x/term v0.38.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.34.1 k8s.io/client-go v0.34.1 ) require ( - filippo.io/edwards25519 v1.1.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect github.com/StackExchange/wmi v1.2.1 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.2 // indirect - github.com/blendle/zapdriver v1.3.1 // indirect - github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/huh v1.0.0 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/consensys/gnark-crypto v0.19.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect @@ -57,17 +49,11 @@ require ( github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect github.com/ethereum/go-verkle v0.2.2 // indirect - github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/gagliardetto/binary v0.8.0 // indirect - github.com/gagliardetto/solana-go v1.14.0 // indirect - github.com/gagliardetto/treeout v0.1.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -88,21 +74,12 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.1 // indirect - github.com/logrusorgru/aurora v2.0.3+incompatible // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect - github.com/mitchellh/go-testing-interface v1.14.1 // indirect - github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect - github.com/mr-tron/base58 v1.2.0 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -113,23 +90,20 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/streamingfast/logging v0.0.0-20250918142248-ac5a1e292845 // indirect github.com/supranational/blst v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect - go.mongodb.org/mongo-driver v1.17.6 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/ratelimit v0.3.1 // indirect - go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect diff --git a/go.sum b/go.sum index 5415acaf..0c833da6 100644 --- a/go.sum +++ b/go.sum @@ -1,54 +1,31 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/AlekSi/pointer v1.1.0 h1:SSDMPcXD9jSl8FPy9cRzoRaMJtm9g9ggGTxecRUbQoI= -github.com/AlekSi/pointer v1.1.0/go.mod h1:y7BvfRI3wXPWKXEBhU71nbnIEEZX0QTSB2Bj48UJIZE= github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= -github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0= github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE= -github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc= -github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= -github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= -github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= -github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= @@ -63,6 +40,8 @@ github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwP github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/coinbase/x402/go v0.0.0-20260331075907-bff876de232a h1:L8ZxbOqBxB7LYXdypWYA7Qq0iQtFaS6PCwjnhij4Oks= +github.com/coinbase/x402/go v0.0.0-20260331075907-bff876de232a/go.mod h1:8xt63HO8mECoUwoI8E9xOjPiOzgFUvUx+Svrok+Wkss= github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80= github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -93,26 +72,20 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvw github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0= github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk= github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= -github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qvQDQpe0= -github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok= +github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo20U59UQ= +github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk= github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -120,14 +93,6 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8 github.com/fxamacker/cbor/v2 v2.2.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg= -github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c= -github.com/gagliardetto/gofuzz v1.2.2 h1:XL/8qDMzcgvR4+CyRQW9UGdwPRPMHVJfqQ/uMvSUuQw= -github.com/gagliardetto/gofuzz v1.2.2/go.mod h1:bkH/3hYLZrMLbfYWA0pWzXmi5TTRZnu4pMGZBkqMKvY= -github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw= -github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k= -github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw= -github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= @@ -225,12 +190,10 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -242,30 +205,20 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= -github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= -github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mark3labs/x402-go v0.13.0 h1:Ppm3GXZx2ZCLJM511mFYeMOw/605h9+M6UT630GdRG0= -github.com/mark3labs/x402-go v0.13.0/go.mod h1:srAvV9FosjBiqrclF15thrQbz0fVVfNXtMcqD0e1hKU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= -github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= -github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= -github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= @@ -276,14 +229,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk= -github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE= -github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= -github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -308,7 +253,6 @@ github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -339,16 +283,12 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU= -github.com/streamingfast/logging v0.0.0-20250918142248-ac5a1e292845 h1:VMA0pZ3MI8BErRA3kh8dKJThP5d0Xh5vZVk5yFIgH/A= -github.com/streamingfast/logging v0.0.0-20250918142248-ac5a1e292845/go.mod h1:BtDq81Tyc7H8up5aXNi/I95nPmG3C0PLEqGWY/iWQ2E= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -359,8 +299,6 @@ github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jq github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= -github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE= -github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= @@ -377,26 +315,10 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGC github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= -go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= -go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0= -go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= -go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -404,74 +326,55 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210105210202-9ed45478a130/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -479,7 +382,6 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= @@ -488,11 +390,9 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= diff --git a/internal/dns/resolver.go b/internal/dns/resolver.go index 5c2855fb..74dc3f23 100644 --- a/internal/dns/resolver.go +++ b/internal/dns/resolver.go @@ -219,6 +219,9 @@ func ensureSudoCached() error { if exec.Command("sudo", "-n", "true").Run() == nil { return nil } + if os.Getenv("OBOL_NONINTERACTIVE") == "true" { + return fmt.Errorf("sudo credentials not cached") + } // Credentials not cached — prompt the user interactively. cmd := exec.Command("sudo", "-v") cmd.Stdin = os.Stdin diff --git a/internal/embed/embed_crd_test.go b/internal/embed/embed_crd_test.go index 0cfa18a1..7e294f9f 100644 --- a/internal/embed/embed_crd_test.go +++ b/internal/embed/embed_crd_test.go @@ -246,6 +246,109 @@ func TestRegistrationRequestCRD_Parses(t *testing.T) { } } +// TestPurchaseRequestCRD_Parses guards the CRD schema shape against indentation +// regressions. Every printer-column path must resolve, so this test exercises +// the same fields k3s relies on at apply time. A broken indentation like the +// one introduced in commit f57498e (status.properties siblinged instead of +// nested) will fail here, before it can reach `obol stack up`. +func TestPurchaseRequestCRD_Parses(t *testing.T) { + data, err := ReadInfrastructureFile("base/templates/purchaserequest-crd.yaml") + if err != nil { + t.Fatalf("ReadInfrastructureFile: %v", err) + } + + // Parse through the project-wide yaml.v3 unmarshaler first — catches any + // malformed YAML before the nested path lookups below silently return nil. + var raw map[string]any + if err := yaml.Unmarshal(data, &raw); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + + docs := multiDoc(data) + crd := findDoc(docs, "CustomResourceDefinition") + if crd == nil { + t.Fatal("no PurchaseRequest CRD found") + } + + if name := nested(crd, "metadata", "name"); name != "purchaserequests.obol.org" { + t.Errorf("metadata.name = %v, want purchaserequests.obol.org", name) + } + if kind := nested(crd, "spec", "names", "kind"); kind != "PurchaseRequest" { + t.Errorf("spec.names.kind = %v, want PurchaseRequest", kind) + } + + versions, ok := nested(crd, "spec", "versions").([]any) + if !ok || len(versions) == 0 { + t.Fatal("spec.versions is empty or wrong type") + } + v0, ok := versions[0].(map[string]any) + if !ok { + t.Fatal("versions[0] is not a map") + } + + // spec.properties must carry the round-2 required fields. + specProps, ok := nested(v0, "schema", "openAPIV3Schema", "properties", "spec", "properties").(map[string]any) + if !ok { + t.Fatal("spec.properties not a map") + } + for _, field := range []string{"endpoint", "model", "count", "preSignedAuths", "payment"} { + if _, exists := specProps[field]; !exists { + t.Errorf("spec.properties missing %q", field) + } + } + + // status.properties is the path that f57498e broke. Every printer-column + // jsonPath below must land on a real property, otherwise the CRD applies + // with an empty status schema and all status fields get silently dropped. + statusProps, ok := nested(v0, "schema", "openAPIV3Schema", "properties", "status", "properties").(map[string]any) + if !ok { + t.Fatal("status.properties not a map — is the schema indentation broken?") + } + for _, field := range []string{"observedGeneration", "conditions", "publicModel", "remaining", "spent", "totalSigned", "totalSpent"} { + if _, exists := statusProps[field]; !exists { + t.Errorf("status.properties missing %q", field) + } + } + + // status.conditions.items must be a map with a properties block. + condItems, ok := nested(statusProps, "conditions", "items").(map[string]any) + if !ok { + t.Fatal("status.conditions.items not a map") + } + condProps, ok := condItems["properties"].(map[string]any) + if !ok { + t.Fatal("status.conditions.items.properties not a map") + } + for _, field := range []string{"type", "status", "reason", "message", "lastTransitionTime"} { + if _, exists := condProps[field]; !exists { + t.Errorf("status.conditions.items.properties missing %q", field) + } + } + + // Printer columns must resolve — if status.properties is wrong, the + // .status.remaining / .status.spent columns would show empty in kubectl. + cols, ok := v0["additionalPrinterColumns"].([]any) + if !ok || len(cols) == 0 { + t.Fatal("additionalPrinterColumns missing") + } + for _, c := range cols { + col := c.(map[string]any) + path, _ := col["jsonPath"].(string) + if strings.HasPrefix(path, ".status.") { + field := strings.TrimPrefix(path, ".status.") + // Strip JSONPath filter expressions like conditions[?(@.type=="Ready")].status + if idx := strings.Index(field, "["); idx > 0 { + field = field[:idx] + } + if _, exists := statusProps[field]; !exists { + t.Errorf("printer column %q references .status.%s which is not in schema", col["name"], field) + } + } + } + + _ = raw +} + // ───────────────────────────────────────────────────────────────────────────── // Monetize RBAC tests // ───────────────────────────────────────────────────────────────────────────── @@ -258,6 +361,11 @@ func TestMonetizeRBAC_Parses(t *testing.T) { docs := multiDoc(data) + ns := findDocByName(docs, "Namespace", "openclaw-obol-agent") + if ns == nil { + t.Fatal("no Namespace 'openclaw-obol-agent' found") + } + // ── Read ClusterRole ──────────────────────────────────────────────── readCR := findDocByName(docs, "ClusterRole", "openclaw-monetize-read") if readCR == nil { @@ -296,21 +404,27 @@ func TestMonetizeRBAC_Parses(t *testing.T) { t.Error("read ClusterRole missing core API group") } - // ── Write ClusterRole ─────────────────────────────────────────────── - writeCR := findDocByName(docs, "ClusterRole", "openclaw-monetize-write") - if writeCR == nil { - t.Fatal("no ClusterRole 'openclaw-monetize-write' found") + // ── Write Role ────────────────────────────────────────────────────── + writeRole := findDocByName(docs, "Role", "openclaw-monetize-write") + if writeRole == nil { + t.Fatal("no Role 'openclaw-monetize-write' found") } - writeRules, ok := writeCR["rules"].([]interface{}) + if ns := nested(writeRole, "metadata", "namespace"); ns != "openclaw-obol-agent" { + t.Errorf("write Role namespace = %v, want openclaw-obol-agent", ns) + } + writeRules, ok := writeRole["rules"].([]interface{}) if !ok || len(writeRules) == 0 { - t.Fatal("write ClusterRole has no rules") + t.Fatal("write Role has no rules") } if !hasVerbOnResource(writeRules, "obol.org", "serviceoffers", "create") { - t.Error("write ClusterRole missing 'create' on obol.org/serviceoffers") + t.Error("write Role missing 'create' on obol.org/serviceoffers") } if hasVerbOnResource(writeRules, "traefik.io", "middlewares", "create") { - t.Error("write ClusterRole should not grant child-resource access") + t.Error("write Role should not grant child-resource access") + } + if hasVerbOnResource(writeRules, "", "secrets", "create") { + t.Error("write Role should not grant Secret writes") } // ── ClusterRoleBindings ───────────────────────────────────────────── @@ -323,13 +437,16 @@ func TestMonetizeRBAC_Parses(t *testing.T) { t.Errorf("read binding roleRef.name = %v, want openclaw-monetize-read", ref) } - writeCRB := findDocByName(docs, "ClusterRoleBinding", "openclaw-monetize-write-binding") - if writeCRB == nil { - t.Fatal("no ClusterRoleBinding 'openclaw-monetize-write-binding' found") + writeRB := findDocByName(docs, "RoleBinding", "openclaw-monetize-write-binding") + if writeRB == nil { + t.Fatal("no RoleBinding 'openclaw-monetize-write-binding' found") } - if ref := nested(writeCRB, "roleRef", "name"); ref != "openclaw-monetize-write" { + if ref := nested(writeRB, "roleRef", "name"); ref != "openclaw-monetize-write" { t.Errorf("write binding roleRef.name = %v, want openclaw-monetize-write", ref) } + if ref := nested(writeRB, "roleRef", "kind"); ref != "Role" { + t.Errorf("write binding roleRef.kind = %v, want Role", ref) + } } // collectAPIGroups extracts all unique apiGroup strings from a list of rules. diff --git a/internal/embed/infrastructure/base/templates/llm.yaml b/internal/embed/infrastructure/base/templates/llm.yaml index cd8a1ff0..6c7b8ae1 100644 --- a/internal/embed/infrastructure/base/templates/llm.yaml +++ b/internal/embed/infrastructure/base/templates/llm.yaml @@ -76,16 +76,15 @@ data: drop_params: true --- +# Buyer ConfigMaps — the serviceoffer-controller writes one .json +# entry per PurchaseRequest. This matches x402-buyer --config-dir/--auths-dir +# loading and avoids the old single shared config.json/auths.json merge point. apiVersion: v1 kind: ConfigMap metadata: name: x402-buyer-config namespace: llm -data: - config.json: | - { - "upstreams": {} - } +data: {} --- apiVersion: v1 @@ -93,9 +92,7 @@ kind: ConfigMap metadata: name: x402-buyer-auths namespace: llm -data: - auths.json: | - {} +data: {} --- # Secret for LiteLLM master key and cloud provider API keys. @@ -121,7 +118,15 @@ metadata: labels: app: litellm spec: + # Keep a single LiteLLM + x402-buyer pod while buyer auth consumption state + # is local to the sidecar pod. Scale this back out only after consumed auth + # state is shared or auth pools are sharded per replica. replicas: 1 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 selector: matchLabels: app: litellm @@ -129,13 +134,17 @@ spec: metadata: labels: app: litellm + annotations: + secret.reloader.stakater.com/reload: "litellm-secrets" spec: + terminationGracePeriodSeconds: 60 containers: - name: litellm - # Pinned to v1.82.3 — main-stable is a floating tag vulnerable to - # supply-chain attacks (see BerriAI/litellm#24512: PyPI 1.82.7-1.82.8 - # contained credential-stealing malware). Always pin to a versioned tag. - image: ghcr.io/berriai/litellm:main-v1.82.3 + # Obol fork of LiteLLM with config-only model management API. + # No Postgres required — /model/new and /model/delete work via + # in-memory router + config.yaml persistence. + # Source: https://github.com/ObolNetwork/litellm + image: ghcr.io/obolnetwork/litellm:sha-c16b156 imagePullPolicy: IfNotPresent args: - --config @@ -178,6 +187,10 @@ spec: initialDelaySeconds: 30 periodSeconds: 15 timeoutSeconds: 3 + lifecycle: + preStop: + exec: + command: ["sleep", "10"] resources: requests: cpu: 100m @@ -189,8 +202,8 @@ spec: image: ghcr.io/obolnetwork/x402-buyer:latest imagePullPolicy: IfNotPresent args: - - --config=/config/config.json - - --auths=/config/auths.json + - --config-dir=/config/buyer-config + - --auths-dir=/config/buyer-auths - --state=/state/consumed.json - --listen=:8402 ports: @@ -217,8 +230,11 @@ spec: cpu: 500m memory: 256Mi volumeMounts: - - name: x402-buyer-config - mountPath: /config + - name: buyer-config + mountPath: /config/buyer-config + readOnly: true + - name: buyer-auths + mountPath: /config/buyer-auths readOnly: true - name: x402-buyer-state mountPath: /state @@ -229,16 +245,29 @@ spec: items: - key: config.yaml path: config.yaml - - name: x402-buyer-config - projected: - sources: - - configMap: - name: x402-buyer-config - - configMap: - name: x402-buyer-auths + - name: buyer-config + configMap: + name: x402-buyer-config + optional: true + - name: buyer-auths + configMap: + name: x402-buyer-auths + optional: true - name: x402-buyer-state emptyDir: {} +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: litellm + namespace: llm +spec: + minAvailable: 1 + selector: + matchLabels: + app: litellm + --- apiVersion: v1 kind: Service diff --git a/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml b/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml index bdb0ccc3..88b86048 100644 --- a/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml +++ b/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml @@ -5,6 +5,17 @@ # ServiceOffer objects. The serviceoffer-controller owns all child resources, # verifier route state, and registration side effects. +--- +#------------------------------------------------------------------------------ +# Namespace - pre-create the default obol-agent namespace so base-stack RBAC can +# use namespace-scoped Role/RoleBinding resources without widening writes back +# to a ClusterRole. +#------------------------------------------------------------------------------ +apiVersion: v1 +kind: Namespace +metadata: + name: openclaw-obol-agent + --- #------------------------------------------------------------------------------ # ClusterRole - Read-only permissions @@ -29,16 +40,23 @@ rules: --- #------------------------------------------------------------------------------ -# ClusterRole - Minimal ServiceOffer write permissions +# Role - Minimal write permissions in the agent namespace only #------------------------------------------------------------------------------ apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole +kind: Role metadata: name: openclaw-monetize-write + namespace: openclaw-obol-agent rules: - apiGroups: ["obol.org"] resources: ["serviceoffers"] verbs: ["create", "update", "patch", "delete"] + - apiGroups: ["obol.org"] + resources: ["purchaserequests"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["obol.org"] + resources: ["purchaserequests/status"] + verbs: ["get"] --- #------------------------------------------------------------------------------ @@ -59,15 +77,16 @@ subjects: --- #------------------------------------------------------------------------------ -# ClusterRoleBinding - Minimal write permissions +# RoleBinding - Minimal write permissions in the agent namespace #------------------------------------------------------------------------------ apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding +kind: RoleBinding metadata: name: openclaw-monetize-write-binding + namespace: openclaw-obol-agent roleRef: apiGroup: rbac.authorization.k8s.io - kind: ClusterRole + kind: Role name: openclaw-monetize-write subjects: - kind: ServiceAccount diff --git a/internal/embed/infrastructure/base/templates/purchaserequest-crd.yaml b/internal/embed/infrastructure/base/templates/purchaserequest-crd.yaml new file mode 100644 index 00000000..a60e0519 --- /dev/null +++ b/internal/embed/infrastructure/base/templates/purchaserequest-crd.yaml @@ -0,0 +1,150 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: purchaserequests.obol.org +spec: + group: obol.org + names: + kind: PurchaseRequest + listKind: PurchaseRequestList + plural: purchaserequests + singular: purchaserequest + shortNames: + - pr + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + additionalPrinterColumns: + - name: Endpoint + type: string + jsonPath: .spec.endpoint + - name: Model + type: string + jsonPath: .spec.model + - name: Price + type: string + jsonPath: .spec.payment.price + - name: Remaining + type: integer + jsonPath: .status.remaining + - name: Spent + type: integer + jsonPath: .status.spent + - name: Ready + type: string + jsonPath: .status.conditions[?(@.type=="Ready")].status + - name: Age + type: date + jsonPath: .metadata.creationTimestamp + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + required: [endpoint, model, count, payment] + properties: + endpoint: + type: string + description: "Full URL to the x402-gated inference endpoint" + model: + type: string + description: "Remote model ID (used as paid/ in LiteLLM)" + count: + type: integer + minimum: 1 + maximum: 2500 + description: "Number of pre-signed auths to create" + preSignedAuths: + type: array + description: "Pre-signed ERC-3009 auths (created by buy.py, consumed by controller)" + items: + type: object + properties: + signature: { type: string } + from: { type: string } + to: { type: string } + value: { type: string } + validAfter: { type: string } + validBefore: { type: string } + nonce: { type: string } + autoRefill: + type: object + properties: + enabled: + type: boolean + default: false + threshold: + type: integer + minimum: 0 + description: "Refill when remaining < threshold" + count: + type: integer + minimum: 1 + description: "Number of auths to sign on refill" + maxTotal: + type: integer + description: "Cap total auths ever signed" + maxSpendPerDay: + type: string + description: "Max micro-USDC spend per day" + payment: + type: object + required: [network, payTo, price, asset] + properties: + network: + type: string + payTo: + type: string + price: + type: string + description: "Micro-USDC per request" + asset: + type: string + description: "USDC contract address" + status: + type: object + properties: + observedGeneration: + type: integer + format: int64 + conditions: + type: array + items: + type: object + properties: + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time + publicModel: + type: string + description: "LiteLLM model name (paid/)" + remaining: + type: integer + spent: + type: integer + totalSigned: + type: integer + totalSpent: + type: string + probedAt: + type: string + format: date-time + probedPrice: + type: string + walletBalance: + type: string + signerAddress: + type: string diff --git a/internal/embed/infrastructure/base/templates/x402.yaml b/internal/embed/infrastructure/base/templates/x402.yaml index c7cb0ec8..c82ada96 100644 --- a/internal/embed/infrastructure/base/templates/x402.yaml +++ b/internal/embed/infrastructure/base/templates/x402.yaml @@ -19,12 +19,12 @@ data: pricing.yaml: | wallet: "" chain: "base-sepolia" - facilitatorURL: "https://facilitator.x402.rs" + facilitatorURL: "https://x402.gcp.obol.tech" verifyOnly: false routes: [] --- -# CA certificates for outbound TLS (e.g. facilitator.x402.rs). +# CA certificates for outbound TLS (e.g. x402.gcp.obol.tech). # The verifier image is minimal and lacks a system CA bundle; this # ConfigMap is populated at deploy time from the host cert store. # Created by: kubectl create configmap ca-certificates -n x402 \ @@ -106,6 +106,15 @@ rules: - apiGroups: ["obol.org"] resources: ["registrationrequests/status"] verbs: ["get", "update", "patch"] + - apiGroups: ["obol.org"] + resources: ["purchaserequests"] + verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: ["obol.org"] + resources: ["purchaserequests/status"] + verbs: ["get", "update", "patch"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] - apiGroups: ["traefik.io"] resources: ["middlewares"] verbs: ["get", "create", "update", "patch", "delete"] @@ -118,6 +127,13 @@ rules: - apiGroups: [""] resources: ["configmaps"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Scoped to litellm-secrets only. Needed to read LITELLM_MASTER_KEY for + # hot-add/hot-delete via the LiteLLM HTTP API (no pod restart — preserves + # the x402-buyer sidecar's pod-local consumed-auth state). + - apiGroups: [""] + resources: ["secrets"] + resourceNames: ["litellm-secrets"] + verbs: ["get"] - apiGroups: ["apps"] resources: ["deployments"] verbs: ["get", "create", "update", "patch", "delete"] diff --git a/internal/embed/skills/autoresearch-coordinator/SKILL.md b/internal/embed/skills/autoresearch-coordinator/SKILL.md index 0a6db9f4..30dd95b2 100644 --- a/internal/embed/skills/autoresearch-coordinator/SKILL.md +++ b/internal/embed/skills/autoresearch-coordinator/SKILL.md @@ -103,7 +103,7 @@ Experiment submission uses the same x402 payment flow as `buy-inference`: 3. **Submit** -- Re-send the POST with the `X-PAYMENT` header containing the signed voucher 4. **Settle** -- Worker's x402 verifier validates payment via the facilitator, forwards request to GPU -Payment is per-experiment (not per-token). The 402 response includes `maxAmountRequired` which is the cost for one experiment run. +Payment is per-experiment (not per-token). The 402 response includes `amount` in the v2 wire format; legacy sellers may still return `maxAmountRequired`. ## How Results are Published diff --git a/internal/embed/skills/buy-inference/SKILL.md b/internal/embed/skills/buy-inference/SKILL.md index 169030d5..09c1ac3f 100644 --- a/internal/embed/skills/buy-inference/SKILL.md +++ b/internal/embed/skills/buy-inference/SKILL.md @@ -68,7 +68,7 @@ python3 scripts/buy.py remove remote-qwen | Command | Description | |---------|-------------| | `probe [--model ]` | Send request without payment, parse 402 response for pricing | -| `buy --endpoint --model [--budget N] [--count N]` | Pre-sign auths, update buyer ConfigMaps, expose `paid/` | +| `buy --endpoint --model [--budget N] [--count N]` | Pre-sign auths, create/update `PurchaseRequest`, expose `paid/` | | `refill [--count ]` | Sign more authorizations for an existing upstream | | `maintain` | Refill mappings at or below the low watermark, warn on low balance, remove exhausted mappings | | `list` | List purchased providers + remaining auth counts | @@ -78,17 +78,19 @@ python3 scripts/buy.py remove remote-qwen ## How It Works -1. **Probe**: Sends a request without payment. The x402 gate returns `402 Payment Required` with pricing info (`payTo`, `network`, `maxAmountRequired`). +1. **Probe**: Sends a request without payment. The x402 gate returns `402 Payment Required` with pricing info (`payTo`, `network`, `amount`; legacy sellers may still use `maxAmountRequired`). 2. **Pre-sign**: The agent signs N ERC-3009 `TransferWithAuthorization` vouchers via the remote-signer. Each voucher has a random nonce and is single-use (consumed on-chain when the facilitator settles). -3. **Store**: Pre-signed authorizations are stored in the `x402-buyer-auths` ConfigMap. Upstream config is stored in `x402-buyer-config`. Both are in the `llm` namespace. +3. **Declare**: `buy.py` creates or updates a `PurchaseRequest` in the agent namespace with the pre-signed authorizations embedded in `spec.preSignedAuths`. -4. **Deploy**: A lean Go sidecar (`x402-buyer`) runs inside the existing `litellm` pod in the `llm` namespace. It mounts both ConfigMaps and serves as an OpenAI-compatible reverse proxy on `127.0.0.1:8402`. +4. **Reconcile**: The controller validates pricing, writes per-upstream buyer config/auth files into the `x402-buyer-config` and `x402-buyer-auths` ConfigMaps in `llm`, and keeps the paid model route available in LiteLLM. -5. **Wire**: LiteLLM keeps one static wildcard route: `paid/* -> openai/* -> 127.0.0.1:8402`. LiteLLM expands the wildcard to the concrete requested model and the buyer sidecar routes that model to the purchased upstream. Buying a model updates only buyer ConfigMaps; the public model name is always `paid/`. +5. **Deploy**: A lean Go sidecar (`x402-buyer`) runs inside the existing `litellm` pod in the `llm` namespace. It mounts both ConfigMaps and serves as an OpenAI-compatible reverse proxy on `127.0.0.1:8402`. -6. **Runtime**: On each request through the sidecar: +6. **Wire**: LiteLLM keeps one static wildcard route: `paid/* -> openai/* -> 127.0.0.1:8402`. The controller also adds explicit paid-model entries when required so models with colons resolve reliably. The public model name is always `paid/`. + +7. **Runtime**: On each request through the sidecar: - Sidecar forwards to upstream seller - If 402 → pops one pre-signed auth from pool → builds X-PAYMENT header → retries - Seller verifies payment via facilitator → returns 200 + inference result @@ -102,7 +104,8 @@ Agent (buy.py) Runtime +-- probe seller → 402 pricing OpenClaw → LiteLLM:8000 +-- sign N auths via remote-signer | +-- store in ConfigMaps v - +-- update buyer ConfigMaps litellm pod + +-- create PurchaseRequest CR litellm pod + |- controller writes buyer files |- LiteLLM paid/* route |- x402-buyer:8402 +-- pop pre-signed auth @@ -114,7 +117,7 @@ Agent (buy.py) Runtime ## Security Properties -- **Zero signer access**: The sidecar reads pre-signed auths from a ConfigMap — no remote-signer access +- **Zero signer access**: The sidecar reads pre-signed auths from ConfigMaps — no remote-signer access - **Bounded spending**: Max loss = N x price (where N = number of pre-signed auths) - **Risk isolation**: If sidecar crashes, LiteLLM routes to other providers (Ollama, etc.) — inference unaffected - **Single-use vouchers**: Each auth is consumed on-chain when settled — no replay diff --git a/internal/embed/skills/buy-inference/references/x402-buyer-api.md b/internal/embed/skills/buy-inference/references/x402-buyer-api.md index d3d3931f..99e54ca4 100644 --- a/internal/embed/skills/buy-inference/references/x402-buyer-api.md +++ b/internal/embed/skills/buy-inference/references/x402-buyer-api.md @@ -9,12 +9,12 @@ HTTP/1.1 402 Payment Required Content-Type: application/json { - "x402Version": 1, + "x402Version": 2, "accepts": [ { "scheme": "exact", - "network": "base-sepolia", - "maxAmountRequired": "1000", + "network": "eip155:84532", + "amount": "1000", "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "payTo": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" } @@ -26,11 +26,11 @@ Content-Type: application/json | Field | Type | Description | |-------|------|-------------| -| `x402Version` | int | Protocol version (currently 1) | +| `x402Version` | int | Protocol version (currently 2) | | `accepts` | array | List of payment options (usually one) | | `accepts[].scheme` | string | Payment scheme (always "exact") | -| `accepts[].network` | string | Chain: `base-sepolia`, `base`, `ethereum` | -| `accepts[].maxAmountRequired` | string | Price in USDC micro-units (6 decimals). `"1000000"` = 1.0 USDC | +| `accepts[].network` | string | CAIP-2 chain id, e.g. `eip155:84532` for Base Sepolia | +| `accepts[].amount` | string | Price in USDC micro-units (6 decimals). `"1000000"` = 1.0 USDC | | `accepts[].asset` | address | USDC contract address on the chain | | `accepts[].payTo` | address | Seller's USDC receiving address | @@ -38,34 +38,29 @@ Content-Type: application/json ```json { - "upstreams": { - "remote-qwen": { - "url": "https://seller.example.com/services/qwen", - "network": "base-sepolia", - "payTo": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - "price": "1000" - } - } + "url": "https://seller.example.com/services/qwen", + "network": "base-sepolia", + "payTo": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "price": "1000", + "remoteModel": "qwen3.5:9b" } ``` ## Pre-Signed Auths Format (`x402-buyer-auths` ConfigMap) ```json -{ - "remote-qwen": [ - { - "signature": "0xabc...", - "from": "0xBuyerAddr", - "to": "0xSellerAddr", - "value": "1000", - "validAfter": "0", - "validBefore": "4294967295", - "nonce": "0xdeadbeef..." - } - ] -} +[ + { + "signature": "0xabc...", + "from": "0xBuyerAddr", + "to": "0xSellerAddr", + "value": "1000", + "validAfter": "0", + "validBefore": "4294967295", + "nonce": "0xdeadbeef..." + } +] ``` Each auth is a single-use ERC-3009 `TransferWithAuthorization` voucher: @@ -79,16 +74,21 @@ Each auth is a single-use ERC-3009 `TransferWithAuthorization` voucher: The sidecar builds this automatically from the pre-signed auth pool: ``` -X-PAYMENT: eyJ4NDAyVmVyc2lvbiI6MSwic2NoZW1lIjoiZXhhY3QiLC4uLn0= +X-PAYMENT: eyJ4NDAyVmVyc2lvbiI6MiwgImFjY2VwdGVkIjp7Li4ufX0= ``` ### Decoded envelope ```json { - "x402Version": 1, - "scheme": "exact", - "network": "base-sepolia", + "x402Version": 2, + "accepted": { + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "payTo": "0xSellerAddr" + }, "payload": { "signature": "0xabc123...", "authorization": { diff --git a/internal/embed/skills/buy-inference/scripts/buy.py b/internal/embed/skills/buy-inference/scripts/buy.py index ab3e0e0f..bdeef3e6 100644 --- a/internal/embed/skills/buy-inference/scripts/buy.py +++ b/internal/embed/skills/buy-inference/scripts/buy.py @@ -70,6 +70,13 @@ "sepolia": 11155111, } +CAIP2_TO_CHAIN = { + "eip155:84532": "base-sepolia", + "eip155:8453": "base", + "eip155:1": "ethereum", + "eip155:11155111": "sepolia", +} + # EIP-712 domain for USDC TransferWithAuthorization USDC_DOMAIN_NAME = "USDC" USDC_DOMAIN_VERSION = "2" @@ -97,6 +104,24 @@ def _normalize_endpoint(url): return base +def _normalize_signature_recovery(sig): + """Convert 65-byte signatures from v=0/1 to Ethereum v=27/28.""" + if not isinstance(sig, str) or not sig.startswith("0x") or len(sig) != 132: + return sig + try: + v = int(sig[-2:], 16) + except ValueError: + return sig + if v in (0, 1): + return sig[:-2] + f"{v + 27:02x}" + return sig + + +def _normalize_chain_name(network): + """Map facilitator/network identifiers to the local eRPC network name.""" + return CAIP2_TO_CHAIN.get(network, network) + + # --------------------------------------------------------------------------- # Buyer sidecar status helpers # --------------------------------------------------------------------------- @@ -222,6 +247,101 @@ def _kube_json(method, path, token, ssl_ctx, body=None, content_type="applicatio return json.loads(raw) if raw else {} +# --------------------------------------------------------------------------- +# PurchaseRequest CR helpers +# --------------------------------------------------------------------------- + +PR_GROUP = "obol.org" +PR_VERSION = "v1alpha1" +PR_RESOURCE = "purchaserequests" + + +def _get_agent_namespace(): + """Read the agent's namespace from the mounted ServiceAccount.""" + try: + with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace") as f: + return f.read().strip() + except FileNotFoundError: + return os.environ.get("AGENT_NAMESPACE", "openclaw-obol-agent") + + +def _create_purchase_request(name, endpoint, model, count, network, pay_to, price, asset, auths=None): + """Create or update a PurchaseRequest CR in the agent's namespace. + + When auths are provided, they are embedded in spec.preSignedAuths so the + controller can read them directly from the CR — no cross-namespace Secret + read required. + """ + token, _ = load_sa() + ssl_ctx = make_ssl_context() + ns = _get_agent_namespace() + + pr = { + "apiVersion": f"{PR_GROUP}/{PR_VERSION}", + "kind": "PurchaseRequest", + "metadata": {"name": name, "namespace": ns}, + "spec": { + "endpoint": endpoint + "/v1/chat/completions", + "model": model, + "count": count, + "payment": { + "network": network, + "payTo": pay_to, + "price": price, + "asset": asset, + }, + }, + } + if auths: + pr["spec"]["preSignedAuths"] = auths + + path = f"/apis/{PR_GROUP}/{PR_VERSION}/namespaces/{ns}/{PR_RESOURCE}" + try: + result = _kube_json("POST", path, token, ssl_ctx, pr) + print(f" Created PurchaseRequest {ns}/{name}") + except urllib.error.HTTPError as e: + if e.code == 409: + # Already exists — read current to get resourceVersion, then replace. + existing = _kube_json("GET", f"{path}/{name}", token, ssl_ctx) + pr["metadata"]["resourceVersion"] = existing["metadata"]["resourceVersion"] + result = _kube_json("PUT", f"{path}/{name}", token, ssl_ctx, pr) + print(f" Updated PurchaseRequest {ns}/{name}") + else: + raise + return result + + +def _wait_for_purchase_ready(name, timeout=180): + """Wait for the PurchaseRequest to reach Ready=True.""" + token, _ = load_sa() + ssl_ctx = make_ssl_context() + ns = _get_agent_namespace() + path = f"/apis/{PR_GROUP}/{PR_VERSION}/namespaces/{ns}/{PR_RESOURCE}/{name}" + + deadline = time.time() + timeout + while time.time() < deadline: + try: + pr = _kube_json("GET", path, token, ssl_ctx) + conditions = pr.get("status", {}).get("conditions", []) + for cond in conditions: + if cond.get("type") == "Ready" and cond.get("status") == "True": + remaining = pr.get("status", {}).get("remaining", 0) + public_model = pr.get("status", {}).get("publicModel", "") + print(f" Ready: {remaining} auths, model={public_model}") + return True + if cond.get("type") == "Ready" and cond.get("status") == "False": + print(f" Not ready: {cond.get('message', '?')}") + # Print latest condition for progress feedback. + if conditions: + latest = conditions[-1] + print(f" [{latest.get('type')}] {latest.get('message', '')}") + except Exception as e: + print(f" Waiting... ({e})") + time.sleep(5) + + return False + + # --------------------------------------------------------------------------- # USDC balance helper # --------------------------------------------------------------------------- @@ -299,6 +419,7 @@ def _presign_auths(signer_address, pay_to, price, chain, usdc_addr, count): print(f"Error: remote-signer returned no signature for auth {i+1}", file=sys.stderr) sys.exit(1) + sig = _normalize_signature_recovery(sig) auths.append({ "signature": sig, @@ -396,10 +517,11 @@ def cmd_probe(endpoint_url, model_id=None): print(f"x402 Version: {pricing.get('x402Version', '?')}") print() for i, acc in enumerate(pricing.get("accepts", [])): + amount = acc.get("amount", acc.get("maxAmountRequired", "?")) print(f" Payment option {i + 1}:") print(f" payTo: {acc.get('payTo', '?')}") print(f" network: {acc.get('network', '?')}") - print(f" price: {acc.get('maxAmountRequired', '?')} USDC micro-units") + print(f" price: {amount} USDC micro-units") asset = acc.get("asset") if asset: print(f" asset: {asset}") @@ -428,8 +550,8 @@ def cmd_buy(name, endpoint, model_id, budget=None, count=None): payment = accepts[0] pay_to = payment.get("payTo", "") - chain = payment.get("network", DEFAULT_CHAIN) - price = str(payment.get("maxAmountRequired", "0")) + chain = _normalize_chain_name(payment.get("network", DEFAULT_CHAIN)) + price = str(payment.get("amount", payment.get("maxAmountRequired", "0"))) asset = payment.get("asset", USDC_CONTRACTS.get(chain, "")) if not pay_to: @@ -476,45 +598,33 @@ def cmd_buy(name, endpoint, model_id, budget=None, count=None): print(f" Warning: balance ({balance}) < total cost ({total_cost}). " "Proceeding with --force — some auths may fail on-chain.", file=sys.stderr) - # 5. Pre-sign authorizations. + # 5. Pre-sign authorizations locally (via remote-signer in same namespace). auths = _presign_auths(signer_address, pay_to, price, chain, usdc_addr, n) - # 6. Write ConfigMaps. - token, _ = load_sa() - ssl_ctx = make_ssl_context() - - print("Writing buyer ConfigMaps ...") - buyer_config = _read_buyer_config(token, ssl_ctx) + # 6. Create PurchaseRequest CR with auths embedded in spec. + # Controller reads auths from the CR itself — no cross-NS Secret read. ep = _normalize_endpoint(endpoint) - existing_auths = _read_buyer_auths(token, ssl_ctx) - replaced = _remove_conflicting_model_mappings( - buyer_config, existing_auths, model_id, keep_name=name - ) - buyer_config["upstreams"][name] = { - "url": ep, - "remoteModel": model_id, - "network": chain, - "payTo": pay_to, - "asset": usdc_addr, - "price": price, - } - _write_buyer_configmap(BUYER_CM_CONFIG, "config.json", buyer_config, token, ssl_ctx) + _create_purchase_request(name, ep, model_id, n, chain, pay_to, price, usdc_addr, auths) - existing_auths[name] = auths - _write_buyer_configmap(BUYER_CM_AUTHS, "auths.json", existing_auths, token, ssl_ctx) + # 6. Wait for controller to reconcile. + print("Waiting for controller to reconcile PurchaseRequest ...") + ready = _wait_for_purchase_ready(name, timeout=180) print() - print(f"Purchased upstream '{name}' configured via x402-buyer sidecar.") + if ready: + print(f"Purchased upstream '{name}' configured via x402-buyer sidecar.") + else: + print(f"Warning: PurchaseRequest '{name}' created but not yet Ready.") + print(" The controller may still be reconciling. Check status with:") + print(f" python3 scripts/buy.py status {name}") print(f" Alias: paid/{model_id}") print(f" Endpoint: {ep}") print(f" Price: {price} micro-units per request") print(f" Chain: {chain}") - print(f" Auths: {n} pre-signed (max spend: {total_cost} micro-units)") - if replaced: - print(f" Replaced: {', '.join(replaced)}") + print(f" Count: {n} auths requested") print() print(f"The model is now available as: paid/{model_id}") - print(f"Use 'refill {name}' or 'maintain' to top up authorizations.") + print(f"Use 'buy {name} --endpoint ... --model ... --count N' again to replace the purchase with a fresh auth pool.") # --------------------------------------------------------------------------- @@ -522,45 +632,10 @@ def cmd_buy(name, endpoint, model_id, budget=None, count=None): # --------------------------------------------------------------------------- def cmd_refill(name, count=None): - """Sign more authorizations for an existing upstream.""" - token, _ = load_sa() - ssl_ctx = make_ssl_context() - - buyer_config = _read_buyer_config(token, ssl_ctx) - if name not in buyer_config.get("upstreams", {}): - print(f"Error: upstream '{name}' not found in sidecar config.", - file=sys.stderr) - sys.exit(1) - - upstream = buyer_config["upstreams"][name] - pay_to = upstream["payTo"] - chain = upstream["network"] - price = upstream["price"] - asset = upstream["asset"] - - # Get signer address. - keys_data = _signer_get("/api/v1/keys") - keys = keys_data.get("keys", []) - if not keys: - print("Error: no signing keys.", file=sys.stderr) - sys.exit(1) - signer_address = keys[0] - - n = min(int(count), MAX_AUTH_COUNT) if count else REFILL_BATCH - n = max(n, 1) - - # Pre-sign. - new_auths = _presign_auths(signer_address, pay_to, price, chain, asset, n) - - # Merge with existing auths. - existing_auths = _read_buyer_auths(token, ssl_ctx) - existing = existing_auths.get(name, []) - existing.extend(new_auths) - existing_auths[name] = existing - _write_buyer_configmap(BUYER_CM_AUTHS, "auths.json", existing_auths, token, ssl_ctx) - - total = len(existing) - print(f"Refilled '{name}': added {n} auths (total pool: {total})") + """Refill is disabled until it is implemented via PurchaseRequest reconciliation.""" + print("refill is not available in the controller-based buy path.", file=sys.stderr) + print("Run the buy command again with a new --count to replace the PurchaseRequest auth pool.", file=sys.stderr) + sys.exit(1) # --------------------------------------------------------------------------- @@ -568,28 +643,19 @@ def cmd_refill(name, count=None): # --------------------------------------------------------------------------- def cmd_list(): - """List purchased providers from buyer config and sidecar status.""" - token, _ = load_sa() - ssl_ctx = make_ssl_context() - - buyer_config = _read_buyer_config(token, ssl_ctx) - upstreams = buyer_config.get("upstreams", {}) - - if not upstreams: + """List purchased providers from live sidecar status.""" + live_status = _buyer_status() or {} + if not live_status: print("No purchased x402 providers.") return - live_status = _buyer_status() or {} - auths = _read_buyer_auths(token, ssl_ctx) - print(f"{'NAME':<20} {'ALIAS':<32} {'PRICE':<12} {'CHAIN':<15} {'REMAINING'}") print("-" * 120) - for name, cfg in upstreams.items(): - status = live_status.get(name, {}) - remaining = status.get("remaining", len(auths.get(name, []))) - alias = f"paid/{cfg.get('remoteModel', name)}" + for name, status in live_status.items(): + remaining = status.get("remaining", 0) + alias = status.get("public_model", f"paid/{status.get('remote_model', name)}") print(f"{name:<20} {alias:<32} " - f"{cfg.get('price', '?'):<12} {cfg.get('network', '?'):<15} " + f"{'?':<12} {status.get('network', '?'):<15} " f"{remaining}") @@ -602,25 +668,18 @@ def cmd_status(name): token, _ = load_sa() ssl_ctx = make_ssl_context() - buyer_config = _read_buyer_config(token, ssl_ctx) - if name not in buyer_config.get("upstreams", {}): + live_status = (_buyer_status() or {}).get(name, {}) + if not live_status: print(f"Upstream '{name}' not found.", file=sys.stderr) sys.exit(1) - cfg = buyer_config["upstreams"][name] - live_status = (_buyer_status() or {}).get(name, {}) - auths = _read_buyer_auths(token, ssl_ctx) - auth_count = live_status.get("remaining", len(auths.get(name, []))) - print(f"Upstream: {name}") - print(f"Alias: paid/{cfg.get('remoteModel', name)}") - print(f"Endpoint: {cfg.get('url', '?')}") - print(f"Model: {cfg.get('remoteModel', '?')}") - print(f"Chain: {cfg.get('network', '?')}") - print(f"Price: {cfg.get('price', '?')} USDC micro-units") - print(f"Asset: {cfg.get('asset', '?')}") - print(f"PayTo: {cfg.get('payTo', '?')}") - print(f"Auths remaining: {auth_count}") + print(f"Alias: {live_status.get('public_model', '?')}") + print(f"Endpoint: {live_status.get('url', '?')}") + print(f"Model: {live_status.get('remote_model', '?')}") + print(f"Chain: {live_status.get('network', '?')}") + print(f"Auths remaining: {live_status.get('remaining', 0)}") + print(f"Auths spent: {live_status.get('spent', 0)}") print() pod = _get_litellm_pod(token, ssl_ctx) @@ -659,83 +718,17 @@ def cmd_balance(chain=None): # --------------------------------------------------------------------------- def cmd_remove(name): - """Remove a purchased upstream from the sidecar config.""" - token, _ = load_sa() - ssl_ctx = make_ssl_context() - - # Remove from sidecar config. - buyer_config = _read_buyer_config(token, ssl_ctx) - if name in buyer_config.get("upstreams", {}): - del buyer_config["upstreams"][name] - _write_buyer_configmap(BUYER_CM_CONFIG, "config.json", buyer_config, - token, ssl_ctx) - print(f"Removed '{name}' from sidecar config.") - - # Remove auths. - auths = _read_buyer_auths(token, ssl_ctx) - if name in auths: - del auths[name] - _write_buyer_configmap(BUYER_CM_AUTHS, "auths.json", auths, token, ssl_ctx) - print(f"Removed '{name}' auths.") - - print("Done.") + """Remove is disabled until it is implemented via PurchaseRequest deletion.""" + print("remove is not available in the controller-based buy path.", file=sys.stderr) + print("Delete the PurchaseRequest through the controller-owned API instead.", file=sys.stderr) + sys.exit(1) def cmd_maintain(): - """Refill low pools, warn on low balance, and remove exhausted mappings.""" - token, _ = load_sa() - ssl_ctx = make_ssl_context() - - buyer_config = _read_buyer_config(token, ssl_ctx) - upstreams = buyer_config.get("upstreams", {}) - if not upstreams: - print("No purchased x402 providers.") - return - - auths = _read_buyer_auths(token, ssl_ctx) - status = _buyer_status() or {} - - keys_data = _signer_get("/api/v1/keys") - keys = keys_data.get("keys", []) - if not keys: - print("Error: no signing keys in remote-signer.", file=sys.stderr) - sys.exit(1) - signer_address = keys[0] - - changed = False - for name, upstream in list(upstreams.items()): - remaining = status.get(name, {}).get("remaining", len(auths.get(name, []))) - if remaining > LOW_WATERMARK: - continue - - price = int(upstream["price"]) - target_cost = REFILL_BATCH * price - balance = int(_get_usdc_balance(signer_address, upstream["asset"], upstream["network"])) - - if balance < target_cost: - print(f"WARNING {name}: balance {balance} < refill cost {target_cost}") - if remaining == 0: - del upstreams[name] - auths.pop(name, None) - changed = True - print(f"REMOVED {name}: exhausted and unable to refill") - continue - - new_auths = _presign_auths( - signer_address, - upstream["payTo"], - upstream["price"], - upstream["network"], - upstream["asset"], - REFILL_BATCH, - ) - auths.setdefault(name, []).extend(new_auths) - changed = True - print(f"REFILLED {name}: added {REFILL_BATCH} auths (remaining was {remaining})") - - if changed: - _write_buyer_configmap(BUYER_CM_CONFIG, "config.json", buyer_config, token, ssl_ctx) - _write_buyer_configmap(BUYER_CM_AUTHS, "auths.json", auths, token, ssl_ctx) + """Maintain is disabled until it is implemented via PurchaseRequest reconciliation.""" + print("maintain is not available in the controller-based buy path.", file=sys.stderr) + print("Use status/list to inspect purchases and rerun buy with a new --count when needed.", file=sys.stderr) + sys.exit(1) # --------------------------------------------------------------------------- @@ -775,12 +768,10 @@ def usage(): print(" probe [--model ] Probe x402 pricing") print(" buy --endpoint --model Pre-sign + configure paid/") print(" [--budget ] [--count ]") - print(" refill [--count ] Sign more auths") - print(" maintain Refill low pools and remove exhausted mappings") print(" list List purchased providers") print(" status Check sidecar + auths") print(" balance [--chain ] Check USDC balance") - print(" remove Remove provider") + print(" refill|maintain|remove Not available in controller mode") if __name__ == "__main__": diff --git a/internal/embed/skills/sell/references/x402-pricing.md b/internal/embed/skills/sell/references/x402-pricing.md index 2e746f7a..092b4d25 100644 --- a/internal/embed/skills/sell/references/x402-pricing.md +++ b/internal/embed/skills/sell/references/x402-pricing.md @@ -57,12 +57,11 @@ Upstream Service (e.g., Ollama) 2. Verifier returns `402 Payment Required` with JSON body: ```json { - "x402Version": 1, + "x402Version": 2, "accepts": [{ "scheme": "exact", - "network": "base-sepolia", - "maxAmountRequired": "500000", - "resource": "/services/my-model", + "network": "eip155:84532", + "amount": "500000", "payTo": "0x...", "extra": {} }] diff --git a/internal/erc8004/client_test.go b/internal/erc8004/client_test.go index 9fb9077d..4413e146 100644 --- a/internal/erc8004/client_test.go +++ b/internal/erc8004/client_test.go @@ -486,6 +486,37 @@ func TestSetMetadata(t *testing.T) { } } +func TestSetMetadata_TransactRevert(t *testing.T) { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + + handlers := txMockHandlers(common.HexToHash("0x2222")) + handlers["eth_estimateGas"] = func(_ []json.RawMessage) (json.RawMessage, error) { + return nil, errors.New("execution reverted") + } + + srv := mockRPC(t, handlers) + defer srv.Close() + + ctx := context.Background() + + client, err := NewClient(ctx, srv.URL) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer client.Close() + + err = client.SetMetadata(ctx, key, big.NewInt(42), "x402", []byte(`{"payment":"info"}`)) + if err == nil { + t.Fatal("expected setMetadata revert error, got nil") + } + if !strings.Contains(err.Error(), "erc8004: setMetadata tx: execution reverted") { + t.Fatalf("error = %q, want setMetadata tx execution reverted", err) + } +} + func TestNewClient_DialError(t *testing.T) { ctx := context.Background() // Use an unreachable address to trigger a dial/chain-id error. diff --git a/internal/inference/gateway.go b/internal/inference/gateway.go index 1c854b84..268b1667 100644 --- a/internal/inference/gateway.go +++ b/internal/inference/gateway.go @@ -15,9 +15,8 @@ import ( "github.com/ObolNetwork/obol-stack/internal/enclave" "github.com/ObolNetwork/obol-stack/internal/tee" - x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" - "github.com/mark3labs/x402-go" - x402http "github.com/mark3labs/x402-go/http" + x402pkg "github.com/ObolNetwork/obol-stack/internal/x402" + x402types "github.com/coinbase/x402/go/types" ) // GatewayConfig holds configuration for the x402 inference gateway. @@ -34,8 +33,8 @@ type GatewayConfig struct { // PricePerRequest is the USDC amount charged per inference request (e.g., "0.001"). PricePerRequest string - // Chain is the x402 chain configuration (e.g., x402.BaseMainnet). - Chain x402.ChainConfig + // Chain is the x402 chain configuration (e.g., x402pkg.ChainBaseSepolia). + Chain x402pkg.ChainInfo // FacilitatorURL is the x402 facilitator service URL. FacilitatorURL string @@ -122,15 +121,15 @@ func NewGateway(cfg GatewayConfig) (*Gateway, error) { } if cfg.FacilitatorURL == "" { - cfg.FacilitatorURL = "https://facilitator.x402.rs" + cfg.FacilitatorURL = x402pkg.DefaultFacilitatorURL } - if err := x402verifier.ValidateFacilitatorURL(cfg.FacilitatorURL); err != nil { + if err := x402pkg.ValidateFacilitatorURL(cfg.FacilitatorURL); err != nil { return nil, err } if cfg.Chain.NetworkID == "" { - cfg.Chain = x402.BaseSepolia + cfg.Chain = x402pkg.ChainBaseSepolia } if cfg.PricePerRequest == "" { @@ -159,22 +158,13 @@ func (g *Gateway) buildHandler(upstreamURL string) (http.Handler, error) { } // Create x402 payment requirement. - requirement, err := x402.NewUSDCPaymentRequirement(x402.USDCRequirementConfig{ - Chain: g.config.Chain, - Amount: g.config.PricePerRequest, - RecipientAddress: g.config.WalletAddress, - }) - if err != nil { - return nil, fmt.Errorf("failed to create payment requirement: %w", err) - } + requirement := x402pkg.BuildV2Requirement(g.config.Chain, g.config.PricePerRequest, g.config.WalletAddress) - // Configure x402 middleware. - x402Config := &x402http.Config{ - FacilitatorURL: g.config.FacilitatorURL, - PaymentRequirements: []x402.PaymentRequirement{requirement}, - VerifyOnly: g.config.VerifyOnly, - } - paymentMiddleware := x402http.NewX402Middleware(x402Config) + // Configure x402 ForwardAuth middleware. + paymentMiddleware := x402pkg.NewForwardAuthMiddleware(x402pkg.ForwardAuthConfig{ + FacilitatorURL: g.config.FacilitatorURL, + VerifyOnly: g.config.VerifyOnly, + }, []x402types.PaymentRequirements{requirement}) // Initialise key backend: TEE (Linux) or SE (macOS), mutually exclusive. var em *enclaveMiddleware diff --git a/internal/inference/gateway_test.go b/internal/inference/gateway_test.go index ea7a6a19..5c5dd5ac 100644 --- a/internal/inference/gateway_test.go +++ b/internal/inference/gateway_test.go @@ -10,7 +10,8 @@ import ( "sync/atomic" "testing" - x402 "github.com/mark3labs/x402-go" + x402pkg "github.com/ObolNetwork/obol-stack/internal/x402" + x402types "github.com/coinbase/x402/go/types" ) // ── Mock facilitator ────────────────────────────────────────────────────────── @@ -37,7 +38,7 @@ func newMockFacilitator(t *testing.T, opts mockFacilitatorOpts) *mockFacilitator mux.HandleFunc("/supported", func(w http.ResponseWriter, r *http.Request) { mf.supportCalls.Add(1) w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"kinds":[{"x402Version":1,"scheme":"exact","network":"base-sepolia"}]}`) + fmt.Fprintf(w, `{"kinds":[{"x402Version":2,"scheme":"exact","network":"eip155:84532"}]}`) }) mux.HandleFunc("/verify", func(w http.ResponseWriter, r *http.Request) { @@ -94,10 +95,15 @@ func newMockOllama(t *testing.T) *httptest.Server { func testPaymentHeader(t *testing.T) string { t.Helper() - p := x402.PaymentPayload{ - X402Version: 1, - Scheme: "exact", - Network: x402.BaseSepolia.NetworkID, + p := x402types.PaymentPayload{ + X402Version: 2, + Accepted: x402types.PaymentRequirements{ + Scheme: "exact", + Network: x402pkg.ChainBaseSepolia.CAIP2Network, + Amount: "1000", + Asset: x402pkg.ChainBaseSepolia.USDCAddress, + PayTo: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + }, Payload: map[string]any{ "signature": "0xmocksignature", "authorization": map[string]any{ @@ -128,7 +134,7 @@ func newTestGateway(t *testing.T, facilitatorURL, upstreamURL string, verifyOnly UpstreamURL: upstreamURL, WalletAddress: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", PricePerRequest: "0.001", - Chain: x402.BaseSepolia, + Chain: x402pkg.ChainBaseSepolia, FacilitatorURL: facilitatorURL, VerifyOnly: verifyOnly, }) @@ -341,7 +347,7 @@ func newTestGatewayTEE(t *testing.T, facilitatorURL, upstreamURL string) *httpte UpstreamURL: upstreamURL, WalletAddress: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", PricePerRequest: "0.001", - Chain: x402.BaseSepolia, + Chain: x402pkg.ChainBaseSepolia, FacilitatorURL: facilitatorURL, VerifyOnly: true, TEEType: "stub", diff --git a/internal/inference/store.go b/internal/inference/store.go index 667fce61..7a2b5926 100644 --- a/internal/inference/store.go +++ b/internal/inference/store.go @@ -8,6 +8,8 @@ import ( "path/filepath" "regexp" "time" + + x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" ) // ErrDeploymentNotFound is returned when a named inference deployment does @@ -181,7 +183,7 @@ func (s *Store) Create(d *Deployment, force bool) error { } if d.FacilitatorURL == "" { - d.FacilitatorURL = "https://facilitator.x402.rs" + d.FacilitatorURL = x402verifier.DefaultFacilitatorURL } now := time.Now().UTC().Format(time.RFC3339) diff --git a/internal/kubectl/kubectl.go b/internal/kubectl/kubectl.go index 16480759..d0c440a3 100644 --- a/internal/kubectl/kubectl.go +++ b/internal/kubectl/kubectl.go @@ -136,3 +136,47 @@ func ApplyOutput(binary, kubeconfig string, data []byte) (string, error) { return out, nil } + +// PipeCommands pipes the stdout of the first kubectl command into the stdin +// of the second. Both commands run with the correct KUBECONFIG. This is useful +// for patterns like "kubectl create --dry-run -o yaml | kubectl replace -f -" +// which avoid the 262KB annotation limit that kubectl apply imposes. +func PipeCommands(binary, kubeconfig string, args1, args2 []string) error { + env := append(os.Environ(), "KUBECONFIG="+kubeconfig) + + cmd1 := exec.Command(binary, args1...) + cmd1.Env = env + + cmd2 := exec.Command(binary, args2...) + cmd2.Env = env + + pipe, err := cmd1.StdoutPipe() + if err != nil { + return fmt.Errorf("pipe: %w", err) + } + cmd2.Stdin = pipe + + var stderr1, stderr2 bytes.Buffer + cmd1.Stderr = &stderr1 + cmd2.Stderr = &stderr2 + + if err := cmd1.Start(); err != nil { + return fmt.Errorf("cmd1 start: %w", err) + } + if err := cmd2.Start(); err != nil { + _ = cmd1.Process.Kill() + return fmt.Errorf("cmd2 start: %w", err) + } + + err1 := cmd1.Wait() + err2 := cmd2.Wait() + + if err1 != nil { + return fmt.Errorf("cmd1: %w: %s", err1, strings.TrimSpace(stderr1.String())) + } + if err2 != nil { + return fmt.Errorf("cmd2: %w: %s", err2, strings.TrimSpace(stderr2.String())) + } + + return nil +} diff --git a/internal/model/model.go b/internal/model/model.go index e1c3aa18..74d5ac0d 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -174,14 +174,30 @@ func LoadDotEnv(path string) map[string]string { // ConfigureLiteLLM adds a provider to the LiteLLM gateway. // For cloud providers, it patches the Secret with the API key and adds // the model to config.yaml. For Ollama, it discovers local models and adds them. -// Restarts the deployment after patching. Use PatchLiteLLMProvider + -// RestartLiteLLM to batch multiple providers with a single restart. +// +// When only models change (no API key), models are hot-added via the +// /model/new API — no restart required. When API keys change, a rolling +// restart is triggered so the new Secret values are picked up. func ConfigureLiteLLM(cfg *config.Config, u *ui.UI, provider, apiKey string, models []string) error { if err := PatchLiteLLMProvider(cfg, u, provider, apiKey, models); err != nil { return err } - return RestartLiteLLM(cfg, u, provider) + // API key changes require a restart (Secret mounted as envFrom). + // Model-only changes can be hot-added via the /model/new API. + needsRestart := apiKey != "" + if needsRestart { + return RestartLiteLLM(cfg, u, provider) + } + + entries := buildModelEntries(provider, models) + if err := hotAddModels(cfg, u, entries); err != nil { + u.Warnf("Hot-add failed, falling back to restart: %v", err) + return RestartLiteLLM(cfg, u, provider) + } + + u.Successf("LiteLLM configured with %s provider", provider) + return nil } // PatchLiteLLMProvider patches the LiteLLM Secret (API key) and ConfigMap @@ -248,7 +264,141 @@ func RestartLiteLLM(cfg *config.Config, u *ui.UI, provider string) error { return nil } -// RemoveModel removes a model entry from the LiteLLM ConfigMap and restarts the deployment. +// litellmAPIViaExec calls a LiteLLM HTTP endpoint on every running litellm +// pod via kubectl exec. With replicas>1, this fans out to all pods so every +// router is updated immediately. The CLI runs on the host and cannot reach +// in-cluster services directly; kubectl exec is the bridge. +func litellmAPIViaExec(kubectlBinary, kubeconfigPath, masterKey, path string, body []byte) error { + // List running litellm pod names. + raw, err := kubectl.Output(kubectlBinary, kubeconfigPath, + "get", "pods", "-n", namespace, "-l", "app=litellm", + "--field-selector=status.phase=Running", + "-o", "jsonpath={.items[*].metadata.name}") + if err != nil { + return fmt.Errorf("list litellm pods: %w", err) + } + + podNames := strings.Fields(strings.TrimSpace(raw)) + if len(podNames) == 0 { + return fmt.Errorf("no running litellm pods in %s namespace", namespace) + } + + var firstErr error + for _, pod := range podNames { + // Pass arguments directly to wget without sh -c to avoid + // shell-quoting issues with JSON body or auth tokens. + args := []string{ + "exec", "-n", namespace, pod, "-c", "litellm", + "--", "wget", "-qO-", + "--header=Content-Type: application/json", + "--header=Authorization: Bearer " + masterKey, + } + if len(body) > 0 { + args = append(args, "--post-data="+string(body)) + } + args = append(args, "http://localhost:4000"+path) + + _, err := kubectl.Output(kubectlBinary, kubeconfigPath, args...) + if err != nil && firstErr == nil { + firstErr = fmt.Errorf("pod %s: %w", pod, err) + } + } + + return firstErr +} + +// hotAddModels uses the LiteLLM /model/new API to add models to the running +// router without a restart. The ConfigMap is already patched by +// PatchLiteLLMProvider for persistence across restarts. +func hotAddModels(cfg *config.Config, u *ui.UI, entries []ModelEntry) error { + masterKey, err := GetMasterKey(cfg) + if err != nil { + return fmt.Errorf("get master key: %w", err) + } + + kubectlBinary := filepath.Join(cfg.BinDir, "kubectl") + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + + for _, entry := range entries { + body := map[string]any{ + "model_name": entry.ModelName, + "litellm_params": map[string]any{ + "model": entry.LiteLLMParams.Model, + "api_base": entry.LiteLLMParams.APIBase, + "api_key": entry.LiteLLMParams.APIKey, + }, + } + bodyJSON, err := json.Marshal(body) + if err != nil { + continue + } + + if err := litellmAPIViaExec(kubectlBinary, kubeconfigPath, masterKey, "/model/new", bodyJSON); err != nil { + u.Warnf("Hot-add %s failed: %v", entry.ModelName, err) + return fmt.Errorf("hot-add %s: %w", entry.ModelName, err) + } + } + + return nil +} + +// hotDeleteModel removes a model from the running LiteLLM router(s) via the +// /model/delete API. It first queries /model/info to resolve model IDs. +func hotDeleteModel(cfg *config.Config, u *ui.UI, modelName string) error { + masterKey, err := GetMasterKey(cfg) + if err != nil { + return fmt.Errorf("get master key: %w", err) + } + + kubectlBinary := filepath.Join(cfg.BinDir, "kubectl") + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + + // Query /model/info on one pod to get model IDs. + raw, err := kubectl.Output(kubectlBinary, kubeconfigPath, + "exec", "-n", namespace, "deployment/"+deployName, "-c", "litellm", + "--", "wget", "-qO-", + "--header=Authorization: Bearer "+masterKey, + "http://localhost:4000/model/info") + if err != nil { + return fmt.Errorf("query /model/info: %w", err) + } + + var infoResp struct { + Data []struct { + ModelName string `json:"model_name"` + ModelInfo struct { + ID string `json:"id"` + } `json:"model_info"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(raw), &infoResp); err != nil { + return fmt.Errorf("parse /model/info: %w", err) + } + + deleted := 0 + for _, m := range infoResp.Data { + if m.ModelName != modelName || m.ModelInfo.ID == "" { + continue + } + + deleteBody, _ := json.Marshal(map[string]any{"id": m.ModelInfo.ID}) + if err := litellmAPIViaExec(kubectlBinary, kubeconfigPath, masterKey, "/model/delete", deleteBody); err != nil { + u.Warnf("Hot-delete model %s (id=%s) failed: %v", modelName, m.ModelInfo.ID, err) + } else { + deleted++ + } + } + + if deleted == 0 { + return fmt.Errorf("model %q not found in LiteLLM router", modelName) + } + + return nil +} + +// RemoveModel removes a model entry from the LiteLLM ConfigMap (persistence) +// and hot-deletes it from the running router via the API (immediate effect). +// No pod restart is required. func RemoveModel(cfg *config.Config, u *ui.UI, modelName string) error { kubectlBinary := filepath.Join(cfg.BinDir, "kubectl") kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") @@ -257,7 +407,7 @@ func RemoveModel(cfg *config.Config, u *ui.UI, modelName string) error { return errors.New("cluster not running. Run 'obol stack up' first") } - // Read current config + // 1. Patch ConfigMap for persistence (survives pod restarts). raw, err := kubectl.Output(kubectlBinary, kubeconfigPath, "get", "configmap", configMapName, "-n", namespace, "-o", "jsonpath={.data.config\\.yaml}") if err != nil { @@ -269,7 +419,6 @@ func RemoveModel(cfg *config.Config, u *ui.UI, modelName string) error { return fmt.Errorf("failed to parse config.yaml: %w", err) } - // Find and remove matching entries var kept []ModelEntry removed := 0 @@ -289,13 +438,11 @@ func RemoveModel(cfg *config.Config, u *ui.UI, modelName string) error { litellmConfig.ModelList = kept - // Marshal back to YAML updated, err := yaml.Marshal(&litellmConfig) if err != nil { return fmt.Errorf("failed to marshal config: %w", err) } - // Build ConfigMap patch escapedYAML, err := json.Marshal(string(updated)) if err != nil { return fmt.Errorf("failed to escape YAML: %w", err) @@ -311,20 +458,12 @@ func RemoveModel(cfg *config.Config, u *ui.UI, modelName string) error { return fmt.Errorf("failed to patch ConfigMap: %w", err) } - // Restart deployment - u.Info("Restarting LiteLLM") - - if err := kubectl.Run(kubectlBinary, kubeconfigPath, - "rollout", "restart", "deployment/"+deployName, "-n", namespace); err != nil { - return fmt.Errorf("failed to restart LiteLLM: %w", err) - } - - if err := kubectl.Run(kubectlBinary, kubeconfigPath, - "rollout", "status", "deployment/"+deployName, "-n", namespace, - "--timeout=90s"); err != nil { - u.Warnf("LiteLLM rollout not confirmed: %v", err) + // 2. Hot-delete from running router via API (immediate, no restart). + if err := hotDeleteModel(cfg, u, modelName); err != nil { + u.Warnf("Hot-remove from LiteLLM router failed: %v", err) + u.Dim(" The model is removed from config; it will disappear after next restart.") } else { - u.Successf("Model %q removed", modelName) + u.Successf("Model %q removed (live + config)", modelName) } return nil @@ -376,28 +515,20 @@ func AddCustomEndpoint(cfg *config.Config, u *ui.UI, name, endpoint, modelName, entry.LiteLLMParams.APIKey = "none" } - // Patch config + // Patch ConfigMap for persistence. u.Infof("Adding custom endpoint %q to LiteLLM config", name) if err := patchLiteLLMConfig(kubectlBinary, kubeconfigPath, []ModelEntry{entry}); err != nil { return fmt.Errorf("failed to update LiteLLM config: %w", err) } - // Restart - u.Info("Restarting LiteLLM") - - if err := kubectl.Run(kubectlBinary, kubeconfigPath, - "rollout", "restart", "deployment/"+deployName, "-n", namespace); err != nil { - return fmt.Errorf("failed to restart LiteLLM: %w", err) + // Hot-add via API (no restart needed). + if err := hotAddModels(cfg, u, []ModelEntry{entry}); err != nil { + u.Warnf("Hot-add failed, falling back to restart: %v", err) + return RestartLiteLLM(cfg, u, name) } - if err := kubectl.Run(kubectlBinary, kubeconfigPath, - "rollout", "status", "deployment/"+deployName, "-n", namespace, - "--timeout=90s"); err != nil { - u.Warnf("LiteLLM rollout not confirmed: %v", err) - } else { - u.Successf("Custom endpoint %q added (model: %s)", name, modelID) - } + u.Successf("Custom endpoint %q added (model: %s)", name, modelID) return nil } diff --git a/internal/monetizeapi/types.go b/internal/monetizeapi/types.go index 4c7e9602..cfba72ed 100644 --- a/internal/monetizeapi/types.go +++ b/internal/monetizeapi/types.go @@ -13,9 +13,11 @@ const ( ServiceOfferKind = "ServiceOffer" RegistrationRequestKind = "RegistrationRequest" + PurchaseRequestKind = "PurchaseRequest" ServiceOfferResource = "serviceoffers" RegistrationRequestResource = "registrationrequests" + PurchaseRequestResource = "purchaserequests" PausedAnnotation = "obol.org/paused" ) @@ -23,6 +25,7 @@ const ( var ( ServiceOfferGVR = schema.GroupVersionResource{Group: Group, Version: Version, Resource: ServiceOfferResource} RegistrationRequestGVR = schema.GroupVersionResource{Group: Group, Version: Version, Resource: RegistrationRequestResource} + PurchaseRequestGVR = schema.GroupVersionResource{Group: Group, Version: Version, Resource: PurchaseRequestResource} ServiceGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "services"} SecretGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"} @@ -170,3 +173,64 @@ func (o *ServiceOffer) IsInference() bool { func (o *ServiceOffer) IsPaused() bool { return o.Annotations != nil && o.Annotations[PausedAnnotation] == "true" } + +// ── PurchaseRequest ───────────────────────────────────────────────────────── + +type PurchaseRequest struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec PurchaseRequestSpec `json:"spec,omitempty"` + Status PurchaseRequestStatus `json:"status,omitempty"` +} + +type PurchaseRequestSpec struct { + Endpoint string `json:"endpoint"` + Model string `json:"model"` + Count int `json:"count"` + PreSignedAuths []PreSignedAuth `json:"preSignedAuths,omitempty"` + AutoRefill PurchaseAutoRefill `json:"autoRefill,omitempty"` + Payment PurchasePayment `json:"payment"` +} + +type PreSignedAuth struct { + Signature string `json:"signature"` + From string `json:"from"` + To string `json:"to"` + Value string `json:"value"` + ValidAfter string `json:"validAfter"` + ValidBefore string `json:"validBefore"` + Nonce string `json:"nonce"` +} + +type PurchaseAutoRefill struct { + Enabled bool `json:"enabled,omitempty"` + Threshold int `json:"threshold,omitempty"` + Count int `json:"count,omitempty"` + MaxTotal int `json:"maxTotal,omitempty"` + MaxSpendPerDay string `json:"maxSpendPerDay,omitempty"` +} + +type PurchasePayment struct { + Network string `json:"network"` + PayTo string `json:"payTo"` + Price string `json:"price"` + Asset string `json:"asset"` +} + +type PurchaseRequestStatus struct { + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + Conditions []Condition `json:"conditions,omitempty"` + PublicModel string `json:"publicModel,omitempty"` + Remaining int `json:"remaining,omitempty"` + Spent int `json:"spent,omitempty"` + TotalSigned int `json:"totalSigned,omitempty"` + TotalSpent string `json:"totalSpent,omitempty"` + ProbedAt string `json:"probedAt,omitempty"` + ProbedPrice string `json:"probedPrice,omitempty"` + WalletBalance string `json:"walletBalance,omitempty"` + SignerAddress string `json:"signerAddress,omitempty"` +} + +func (pr *PurchaseRequest) EffectiveBuyerNamespace() string { + return "llm" +} diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index aa3b55c5..4bbcb7bd 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -28,6 +28,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic/dynamicinformer" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/cache" "k8s.io/client-go/util/workqueue" @@ -48,6 +49,8 @@ const ( ) type Controller struct { + kubeClient kubernetes.Interface + dynClient dynamic.Interface client dynamic.Interface offers dynamic.NamespaceableResourceInterface registrationRequests dynamic.NamespaceableResourceInterface @@ -59,13 +62,21 @@ type Controller struct { offerInformer cache.SharedIndexInformer registrationInformer cache.SharedIndexInformer + purchaseInformer cache.SharedIndexInformer configMapInformer cache.SharedIndexInformer offerQueue workqueue.TypedRateLimitingInterface[string] registrationQueue workqueue.TypedRateLimitingInterface[string] + purchaseQueue workqueue.TypedRateLimitingInterface[string] catalogMu sync.Mutex + pendingAuths sync.Map // key: "ns/name" → []map[string]string + httpClient *http.Client + // litellmURLOverride is used in tests to point at a local httptest server + // instead of the in-cluster litellm Service DNS. Empty in production. + litellmURLOverride string + registrationKey *ecdsa.PrivateKey registrationOwnerAddress string registrationRPCURL string @@ -89,9 +100,15 @@ func New(cfg *rest.Config) (*Controller, error) { log.Printf("serviceoffer-controller: no ERC-8004 signing key configured; on-chain registration disabled") } + kubeClient, err := kubernetes.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("create kube client: %w", err) + } + factory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(client, 0, metav1.NamespaceAll, nil) offerInformer := factory.ForResource(monetizeapi.ServiceOfferGVR).Informer() registrationInformer := factory.ForResource(monetizeapi.RegistrationRequestGVR).Informer() + purchaseInformer := factory.ForResource(monetizeapi.PurchaseRequestGVR).Informer() configMapFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(client, 0, "obol-frontend", func(options *metav1.ListOptions) { options.FieldSelector = fields.OneTermEqualSelector("metadata.name", "obol-stack-config").String() }) @@ -103,6 +120,8 @@ func New(cfg *rest.Config) (*Controller, error) { } controller := &Controller{ + kubeClient: kubeClient, + dynClient: client, client: client, offers: client.Resource(monetizeapi.ServiceOfferGVR), registrationRequests: client.Resource(monetizeapi.RegistrationRequestGVR), @@ -113,9 +132,11 @@ func New(cfg *rest.Config) (*Controller, error) { httpRoutes: client.Resource(monetizeapi.HTTPRouteGVR), offerInformer: offerInformer, registrationInformer: registrationInformer, + purchaseInformer: purchaseInformer, configMapInformer: configMapInformer, offerQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), registrationQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), + purchaseQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), httpClient: &http.Client{Timeout: 3 * time.Second}, registrationKey: registrationKey, registrationOwnerAddress: registrationOwnerAddress, @@ -139,6 +160,11 @@ func New(cfg *rest.Config) (*Controller, error) { UpdateFunc: func(_, newObj any) { controller.enqueueOfferFromRegistration(newObj) }, DeleteFunc: controller.enqueueOfferFromRegistration, }) + purchaseInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: controller.enqueuePurchase, + UpdateFunc: func(_, newObj any) { controller.enqueuePurchase(newObj) }, + DeleteFunc: controller.enqueuePurchase, + }) configMapInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: controller.enqueueDiscoveryRefresh, UpdateFunc: func(_, newObj any) { controller.enqueueDiscoveryRefresh(newObj) }, @@ -151,11 +177,13 @@ func New(cfg *rest.Config) (*Controller, error) { func (c *Controller) Run(ctx context.Context, workers int) error { defer c.offerQueue.ShutDown() defer c.registrationQueue.ShutDown() + defer c.purchaseQueue.ShutDown() go c.offerInformer.Run(ctx.Done()) go c.registrationInformer.Run(ctx.Done()) + go c.purchaseInformer.Run(ctx.Done()) go c.configMapInformer.Run(ctx.Done()) - if !cache.WaitForCacheSync(ctx.Done(), c.offerInformer.HasSynced, c.registrationInformer.HasSynced, c.configMapInformer.HasSynced) { + if !cache.WaitForCacheSync(ctx.Done(), c.offerInformer.HasSynced, c.registrationInformer.HasSynced, c.purchaseInformer.HasSynced, c.configMapInformer.HasSynced) { return fmt.Errorf("wait for informer sync") } @@ -171,6 +199,10 @@ func (c *Controller) Run(ctx context.Context, workers int) error { for c.processNextRegistration(ctx) { } }() + go func() { + for c.processNextPurchase(ctx) { + } + }() } <-ctx.Done() @@ -262,6 +294,32 @@ func (c *Controller) processNextRegistration(ctx context.Context) bool { return true } +func (c *Controller) enqueuePurchase(obj any) { + key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) + if err != nil { + log.Printf("serviceoffer-controller: build purchase queue key: %v", err) + return + } + c.purchaseQueue.Add(key) +} + +func (c *Controller) processNextPurchase(ctx context.Context) bool { + key, shutdown := c.purchaseQueue.Get() + if shutdown { + return false + } + defer c.purchaseQueue.Done(key) + + if err := c.reconcilePurchase(ctx, key); err != nil { + log.Printf("serviceoffer-controller: reconcile purchase %s: %v", key, err) + c.purchaseQueue.AddRateLimited(key) + return true + } + + c.purchaseQueue.Forget(key) + return true +} + func (c *Controller) reconcileOffer(ctx context.Context, key string) error { namespace, name, err := cache.SplitMetaNamespaceKey(key) if err != nil { @@ -349,6 +407,13 @@ func (c *Controller) reconcileOffer(ctx context.Context, key string) error { if err := c.updateOfferStatus(ctx, raw, status); err != nil { return err } + if !ready { + // Dependent resources like the upstream Deployment, Middleware, HTTPRoute, + // and RegistrationRequest can become ready after this reconcile completes. + // Requeue offers that are still converging so status can advance without + // requiring a spec mutation or unrelated ConfigMap update. + c.offerQueue.AddAfter(offer.Namespace+"/"+offer.Name, 5*time.Second) + } if !c.shouldRefreshSkillCatalog(offer, status) { return nil } diff --git a/internal/serviceoffercontroller/purchase.go b/internal/serviceoffercontroller/purchase.go new file mode 100644 index 00000000..f623b0c4 --- /dev/null +++ b/internal/serviceoffercontroller/purchase.go @@ -0,0 +1,326 @@ +package serviceoffercontroller + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +const purchaseRequestFinalizer = "obol.org/purchase-finalizer" + +// ── PurchaseRequest reconciler ────────────────────────────────────────────── + +func (c *Controller) reconcilePurchase(ctx context.Context, key string) error { + ns, name, _ := strings.Cut(key, "/") + + raw, err := c.dynClient.Resource(monetizeapi.PurchaseRequestGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil // deleted + } + + var pr monetizeapi.PurchaseRequest + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, &pr); err != nil { + return fmt.Errorf("unmarshal PurchaseRequest: %w", err) + } + + // Add finalizer if missing. + if !hasStringInSlice(raw.GetFinalizers(), purchaseRequestFinalizer) { + patched := raw.DeepCopy() + patched.SetFinalizers(append(patched.GetFinalizers(), purchaseRequestFinalizer)) + if _, err := c.dynClient.Resource(monetizeapi.PurchaseRequestGVR).Namespace(ns).Update(ctx, patched, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("add finalizer: %w", err) + } + return nil + } + + // Handle deletion. + if raw.GetDeletionTimestamp() != nil { + return c.reconcileDeletingPurchase(ctx, &pr, raw) + } + + status := pr.Status + if pr.Status.ObservedGeneration != pr.Generation { + status = monetizeapi.PurchaseRequestStatus{} + } + status.ObservedGeneration = pr.Generation + status.Conditions = append([]monetizeapi.Condition{}, status.Conditions...) + + // Stage 1: Probe + if !purchaseConditionIsTrue(status.Conditions, "Probed") { + if err := c.reconcilePurchaseProbe(ctx, &status, &pr); err != nil { + log.Printf("purchase %s/%s: probe failed: %v", ns, name, err) + } + } + + // Stage 2: Sign auths + if purchaseConditionIsTrue(status.Conditions, "Probed") && !purchaseConditionIsTrue(status.Conditions, "AuthsSigned") { + if err := c.reconcilePurchaseSign(ctx, &status, &pr); err != nil { + log.Printf("purchase %s/%s: sign failed: %v", ns, name, err) + } + } + + // Stage 3: Configure sidecar + if purchaseConditionIsTrue(status.Conditions, "AuthsSigned") && !purchaseConditionIsTrue(status.Conditions, "Configured") { + if err := c.reconcilePurchaseConfigure(ctx, &status, &pr); err != nil { + log.Printf("purchase %s/%s: configure failed: %v", ns, name, err) + } + } + + // Stage 4: Verify sidecar loaded + if purchaseConditionIsTrue(status.Conditions, "Configured") { + c.reconcilePurchaseReady(ctx, &status, &pr) + } + + ready := purchaseConditionIsTrue(status.Conditions, "Ready") + if err := c.updatePurchaseStatus(ctx, raw, &status); err != nil { + return err + } + if !ready { + // ConfigMap projection and sidecar reload are asynchronous; requeue so + // readiness can advance without requiring a CR spec/status mutation. + c.purchaseQueue.AddAfter(key, 5*time.Second) + } + return nil +} + +func (c *Controller) reconcileDeletingPurchase(ctx context.Context, pr *monetizeapi.PurchaseRequest, raw *unstructured.Unstructured) error { + buyerNS := pr.EffectiveBuyerNamespace() + c.removeLiteLLMModelEntry(ctx, buyerNS, "paid/"+pr.Spec.Model) + c.removeBuyerUpstream(ctx, buyerNS, pr.Name) + + patched := raw.DeepCopy() + fins := patched.GetFinalizers() + filtered := fins[:0] + for _, f := range fins { + if f != purchaseRequestFinalizer { + filtered = append(filtered, f) + } + } + patched.SetFinalizers(filtered) + _, err := c.dynClient.Resource(monetizeapi.PurchaseRequestGVR).Namespace(pr.Namespace).Update(ctx, patched, metav1.UpdateOptions{}) + return err +} + +// ── Stage 1: Probe ────────────────────────────────────────────────────────── + +func (c *Controller) reconcilePurchaseProbe(ctx context.Context, status *monetizeapi.PurchaseRequestStatus, pr *monetizeapi.PurchaseRequest) error { + client := &http.Client{Timeout: 15 * time.Second} + + body := `{"model":"probe","messages":[{"role":"user","content":"probe"}],"max_tokens":1}` + req, err := http.NewRequestWithContext(ctx, "POST", pr.Spec.Endpoint, strings.NewReader(body)) + if err != nil { + setPurchaseCondition(&status.Conditions, "Probed", "False", "ProbeError", err.Error()) + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + setPurchaseCondition(&status.Conditions, "Probed", "False", "ProbeError", err.Error()) + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusPaymentRequired { + setPurchaseCondition(&status.Conditions, "Probed", "False", "NotPaymentGated", + fmt.Sprintf("expected 402, got %d", resp.StatusCode)) + return fmt.Errorf("expected 402, got %d", resp.StatusCode) + } + + var pricing struct { + Accepts []struct { + PayTo string `json:"payTo"` + MaxAmountRequired string `json:"maxAmountRequired"` + Amount string `json:"amount"` + Network string `json:"network"` + } `json:"accepts"` + } + if err := json.Unmarshal(respBody, &pricing); err != nil || len(pricing.Accepts) == 0 { + setPurchaseCondition(&status.Conditions, "Probed", "False", "InvalidPricing", "402 body missing accepts") + return fmt.Errorf("invalid 402 response") + } + + accept := pricing.Accepts[0] + price := accept.Amount + if price == "" { + price = accept.MaxAmountRequired + } + if price != pr.Spec.Payment.Price { + setPurchaseCondition(&status.Conditions, "Probed", "False", "PricingMismatch", + fmt.Sprintf("spec.price=%s but endpoint wants %s", pr.Spec.Payment.Price, price)) + return fmt.Errorf("pricing mismatch") + } + + status.ProbedAt = time.Now().UTC().Format(time.RFC3339) + status.ProbedPrice = price + setPurchaseCondition(&status.Conditions, "Probed", "True", "Validated", + fmt.Sprintf("402: %s micro-USDC on %s", price, accept.Network)) + return nil +} + +// ── Stage 2: Read pre-signed auths from spec ──────────────────────────────── +// +// buy.py signs the auths locally (it has remote-signer access in the same +// namespace) and embeds them in spec.preSignedAuths. The controller reads +// them directly from the CR — no cross-namespace Secret read needed. + +func (c *Controller) reconcilePurchaseSign(ctx context.Context, status *monetizeapi.PurchaseRequestStatus, pr *monetizeapi.PurchaseRequest) error { + auths, err := preSignedAuthMaps(pr) + if err != nil { + setPurchaseCondition(&status.Conditions, "AuthsSigned", "False", "NoAuths", + "spec.preSignedAuths is empty — buy.py should embed auths in the CR") + return err + } + + if pr.Spec.PreSignedAuths[0].From != "" { + status.SignerAddress = pr.Spec.PreSignedAuths[0].From + } + + c.pendingAuths.Store(pr.Namespace+"/"+pr.Name, auths) + status.TotalSigned = len(auths) + setPurchaseCondition(&status.Conditions, "AuthsSigned", "True", "Loaded", + fmt.Sprintf("Loaded %d pre-signed auths from spec", len(auths))) + return nil +} + +// ── Stage 3: Configure sidecar ────────────────────────────────────────────── + +func (c *Controller) reconcilePurchaseConfigure(ctx context.Context, status *monetizeapi.PurchaseRequestStatus, pr *monetizeapi.PurchaseRequest) error { + key := pr.Namespace + "/" + pr.Name + authsRaw, ok := c.pendingAuths.Load(key) + var auths []map[string]string + var err error + if ok { + auths = authsRaw.([]map[string]string) + c.pendingAuths.Delete(key) + } else { + // Rebuild from spec so crash-restart does not wedge the request. + auths, err = preSignedAuthMaps(pr) + if err != nil { + setPurchaseCondition(&status.Conditions, "Configured", "False", "NoAuths", "No auths available to write") + return err + } + } + + buyerNS := pr.EffectiveBuyerNamespace() + + upstream := map[string]any{ + "url": normalizePurchasedUpstreamURL(pr.Spec.Endpoint), + "network": pr.Spec.Payment.Network, + "payTo": pr.Spec.Payment.PayTo, + "price": pr.Spec.Payment.Price, + "asset": pr.Spec.Payment.Asset, + "remoteModel": pr.Spec.Model, + } + + if err := c.mergeBuyerConfig(ctx, buyerNS, pr.Name, upstream); err != nil { + setPurchaseCondition(&status.Conditions, "Configured", "False", "ConfigWriteError", err.Error()) + return err + } + + if err := c.mergeBuyerAuths(ctx, buyerNS, pr.Name, auths); err != nil { + setPurchaseCondition(&status.Conditions, "Configured", "False", "AuthsWriteError", err.Error()) + return err + } + + // Trigger immediate sidecar reload so it picks up the new config/auths + // without waiting for the 5-second ticker. + c.triggerBuyerReload(ctx, buyerNS) + + // Hot-add via /model/new API — no pod restart needed. + paidModel := "paid/" + pr.Spec.Model + c.addLiteLLMModelEntry(ctx, buyerNS, paidModel) + + status.Remaining = len(auths) + status.PublicModel = paidModel + setPurchaseCondition(&status.Conditions, "Configured", "True", "Written", + fmt.Sprintf("Wrote %d auths to %s/x402-buyer-auths", len(auths), buyerNS)) + return nil +} + +// ── Stage 4: Ready ────────────────────────────────────────────────────────── + +func (c *Controller) reconcilePurchaseReady(ctx context.Context, status *monetizeapi.PurchaseRequestStatus, pr *monetizeapi.PurchaseRequest) { + buyerNS := pr.EffectiveBuyerNamespace() + + remaining, spent, err := c.checkBuyerStatus(ctx, buyerNS, pr.Name) + if err != nil { + setPurchaseCondition(&status.Conditions, "Ready", "False", "SidecarNotReady", err.Error()) + return + } + + status.Remaining = remaining + status.Spent = spent + setPurchaseCondition(&status.Conditions, "Ready", "True", "Reconciled", + fmt.Sprintf("Sidecar: %d remaining, %d spent", remaining, spent)) +} + +// ── Status helpers ────────────────────────────────────────────────────────── + +func (c *Controller) updatePurchaseStatus(ctx context.Context, raw *unstructured.Unstructured, status *monetizeapi.PurchaseRequestStatus) error { + patched := raw.DeepCopy() + statusObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(status) + if err != nil { + return err + } + if existing, found := patched.Object["status"]; found && equality.Semantic.DeepEqual(existing, statusObj) { + return nil + } + patched.Object["status"] = statusObj + _, err = c.dynClient.Resource(monetizeapi.PurchaseRequestGVR). + Namespace(patched.GetNamespace()). + UpdateStatus(ctx, patched, metav1.UpdateOptions{}) + return err +} + +func hasStringInSlice(slice []string, target string) bool { + for _, s := range slice { + if s == target { + return true + } + } + return false +} + +func purchaseConditionIsTrue(conditions []monetizeapi.Condition, condType string) bool { + for _, c := range conditions { + if c.Type == condType { + return c.Status == "True" + } + } + return false +} + +func setPurchaseCondition(conditions *[]monetizeapi.Condition, condType, status, reason, message string) { + now := metav1.Now() + for i, c := range *conditions { + if c.Type == condType { + if c.Status != status { + (*conditions)[i].LastTransitionTime = now + } + (*conditions)[i].Status = status + (*conditions)[i].Reason = reason + (*conditions)[i].Message = message + return + } + } + *conditions = append(*conditions, monetizeapi.Condition{ + Type: condType, + Status: status, + Reason: reason, + Message: message, + LastTransitionTime: now, + }) +} diff --git a/internal/serviceoffercontroller/purchase_helpers.go b/internal/serviceoffercontroller/purchase_helpers.go new file mode 100644 index 00000000..fcbb1949 --- /dev/null +++ b/internal/serviceoffercontroller/purchase_helpers.go @@ -0,0 +1,476 @@ +package serviceoffercontroller + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/ObolNetwork/obol-stack/internal/model" + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "gopkg.in/yaml.v3" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + buyerConfigCM = "x402-buyer-config" + buyerAuthsCM = "x402-buyer-auths" + litellmSecret = "litellm-secrets" + litellmMasterKey = "LITELLM_MASTER_KEY" +) + +// litellmBaseURL returns the LiteLLM HTTP base URL. In production it resolves +// to the in-cluster Service DNS; tests can set Controller.litellmURLOverride +// to an httptest server instead. +func (c *Controller) litellmBaseURL(ns string) string { + if c.litellmURLOverride != "" { + return c.litellmURLOverride + } + return fmt.Sprintf("http://litellm.%s.svc:4000", ns) +} + +// getLiteLLMMasterKey reads the master key from the litellm-secrets Secret. +// The controller needs `secrets:get` RBAC on this Secret in the target +// namespace (granted to the serviceoffer-controller ClusterRole). +func (c *Controller) getLiteLLMMasterKey(ctx context.Context, ns string) (string, error) { + secret, err := c.kubeClient.CoreV1().Secrets(ns).Get(ctx, litellmSecret, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("get %s/%s: %w", ns, litellmSecret, err) + } + key := string(secret.Data[litellmMasterKey]) + if key == "" { + return "", fmt.Errorf("%s has empty %s", litellmSecret, litellmMasterKey) + } + return key, nil +} + +// hotAddLiteLLMModel adds a model via the LiteLLM /model/new HTTP API. The +// in-memory router is updated without a pod restart, preserving the x402-buyer +// sidecar's consumed-auth state (which lives in a pod-local emptyDir and would +// be wiped by a rollout). +// +// Returns an error if the API call fails; callers fall back to a pod restart +// only as a last resort — see addLiteLLMModelEntry. +func (c *Controller) hotAddLiteLLMModel(ctx context.Context, ns string, entry model.ModelEntry) error { + masterKey, err := c.getLiteLLMMasterKey(ctx, ns) + if err != nil { + return err + } + + body, err := json.Marshal(map[string]any{ + "model_name": entry.ModelName, + "litellm_params": map[string]any{ + "model": entry.LiteLLMParams.Model, + "api_base": entry.LiteLLMParams.APIBase, + "api_key": entry.LiteLLMParams.APIKey, + }, + }) + if err != nil { + return fmt.Errorf("marshal model_new body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.litellmBaseURL(ns)+"/model/new", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+masterKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("POST /model/new: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("POST /model/new: %s: %s", resp.Status, strings.TrimSpace(string(respBody))) + } + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} + +// hotDeleteLiteLLMModel removes a model via the LiteLLM /model/info → +// /model/delete API. It first queries model IDs by name, then deletes each +// matching entry. No pod restart — the router mutates in place. +func (c *Controller) hotDeleteLiteLLMModel(ctx context.Context, ns, modelName string) error { + masterKey, err := c.getLiteLLMMasterKey(ctx, ns) + if err != nil { + return err + } + + ids, err := c.litellmModelIDsByName(ctx, ns, masterKey, modelName) + if err != nil { + return err + } + if len(ids) == 0 { + return nil + } + + var firstErr error + for _, id := range ids { + body, _ := json.Marshal(map[string]string{"id": id}) + req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, + c.litellmBaseURL(ns)+"/model/delete", bytes.NewReader(body)) + if reqErr != nil { + if firstErr == nil { + firstErr = reqErr + } + continue + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+masterKey) + + resp, doErr := c.httpClient.Do(req) + if doErr != nil { + if firstErr == nil { + firstErr = fmt.Errorf("POST /model/delete: %w", doErr) + } + continue + } + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + resp.Body.Close() + if firstErr == nil { + firstErr = fmt.Errorf("POST /model/delete: %s: %s", resp.Status, strings.TrimSpace(string(respBody))) + } + continue + } + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + return firstErr +} + +// litellmModelIDsByName queries /model/info and returns the model_id values +// for every entry whose model_name matches. LiteLLM stores one entry per +// deployment; a single name can map to multiple IDs under load-balanced routes. +func (c *Controller) litellmModelIDsByName(ctx context.Context, ns, masterKey, modelName string) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + c.litellmBaseURL(ns)+"/model/info", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+masterKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("GET /model/info: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("GET /model/info: %s", resp.Status) + } + + var payload struct { + Data []struct { + ModelName string `json:"model_name"` + ModelInfo struct { + ID string `json:"id"` + } `json:"model_info"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("decode model_info: %w", err) + } + + var ids []string + for _, entry := range payload.Data { + if entry.ModelName == modelName && entry.ModelInfo.ID != "" { + ids = append(ids, entry.ModelInfo.ID) + } + } + return ids, nil +} + +// ── ConfigMap merge (optimistic concurrency) ──────────────────────────────── + +func (c *Controller) mergeBuyerConfig(ctx context.Context, ns, name string, upstream map[string]any) error { + cm, err := c.kubeClient.CoreV1().ConfigMaps(ns).Get(ctx, buyerConfigCM, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("get %s/%s: %w", ns, buyerConfigCM, err) + } + if cm.Data == nil { + cm.Data = make(map[string]string) + } + delete(cm.Data, "config.json") + configJSON, _ := json.MarshalIndent(upstream, "", " ") + cm.Data[name+".json"] = string(configJSON) + + _, err = c.kubeClient.CoreV1().ConfigMaps(ns).Update(ctx, cm, metav1.UpdateOptions{}) + return err +} + +func (c *Controller) mergeBuyerAuths(ctx context.Context, ns, name string, auths []map[string]string) error { + cm, err := c.kubeClient.CoreV1().ConfigMaps(ns).Get(ctx, buyerAuthsCM, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("get %s/%s: %w", ns, buyerAuthsCM, err) + } + if cm.Data == nil { + cm.Data = make(map[string]string) + } + delete(cm.Data, "auths.json") + authsJSON, _ := json.MarshalIndent(auths, "", " ") + cm.Data[name+".json"] = string(authsJSON) + + _, err = c.kubeClient.CoreV1().ConfigMaps(ns).Update(ctx, cm, metav1.UpdateOptions{}) + return err +} + +func (c *Controller) removeBuyerUpstream(ctx context.Context, ns, name string) { + // Remove from config. + cm, err := c.kubeClient.CoreV1().ConfigMaps(ns).Get(ctx, buyerConfigCM, metav1.GetOptions{}) + if err == nil { + if cm.Data == nil { + cm.Data = make(map[string]string) + } + delete(cm.Data, "config.json") + delete(cm.Data, name+".json") + c.kubeClient.CoreV1().ConfigMaps(ns).Update(ctx, cm, metav1.UpdateOptions{}) + } + + // Remove from auths. + authsCM, err := c.kubeClient.CoreV1().ConfigMaps(ns).Get(ctx, buyerAuthsCM, metav1.GetOptions{}) + if err == nil { + if authsCM.Data == nil { + authsCM.Data = make(map[string]string) + } + delete(authsCM.Data, "auths.json") + delete(authsCM.Data, name+".json") + c.kubeClient.CoreV1().ConfigMaps(ns).Update(ctx, authsCM, metav1.UpdateOptions{}) + } +} + +// addLiteLLMModelEntry adds a paid/ route to LiteLLM. Writes the +// ConfigMap (persistence across restarts) and then hot-adds via /model/new +// (no pod restart — preserves the buyer sidecar's consumed-auth state). +// +// If the hot-add API call fails, we do NOT fall back to a pod restart: that +// would wipe the sidecar's emptyDir /state/consumed.json and cause the +// facilitator to reject previously-spent auths as double-spends. Instead we +// log and rely on the ConfigMap being reloaded on the next natural restart. +func (c *Controller) addLiteLLMModelEntry(ctx context.Context, ns, modelName string) { + cm, err := c.kubeClient.CoreV1().ConfigMaps(ns).Get(ctx, "litellm-config", metav1.GetOptions{}) + if err != nil { + log.Printf("purchase: failed to read litellm-config: %v", err) + return + } + if cm.Data == nil { + cm.Data = make(map[string]string) + } + + var cfg model.LiteLLMConfig + if err := yaml.Unmarshal([]byte(cm.Data["config.yaml"]), &cfg); err != nil { + log.Printf("purchase: failed to parse litellm-config: %v", err) + return + } + + for _, entry := range cfg.ModelList { + if entry.ModelName == modelName { + return + } + } + + entry := model.ModelEntry{ + ModelName: modelName, + LiteLLMParams: model.LiteLLMParams{ + Model: "openai/" + modelName, + APIBase: "http://127.0.0.1:8402", + APIKey: "unused", + }, + } + cfg.ModelList = append(cfg.ModelList, entry) + + rendered, err := yaml.Marshal(&cfg) + if err != nil { + log.Printf("purchase: failed to serialize litellm-config: %v", err) + return + } + + cm.Data["config.yaml"] = string(rendered) + if _, err := c.kubeClient.CoreV1().ConfigMaps(ns).Update(ctx, cm, metav1.UpdateOptions{}); err != nil { + log.Printf("purchase: failed to update litellm-config: %v", err) + return + } + + if err := c.hotAddLiteLLMModel(ctx, ns, entry); err != nil { + // Secret missing is a legitimate "API not available" signal — LiteLLM + // will pick the model up on its next reload of config.yaml. + if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) { + log.Printf("purchase: hot-add skipped (%v); model will load on next config reload", err) + return + } + log.Printf("purchase: hot-add %s failed: %v; relying on ConfigMap reload", modelName, err) + } +} + +func preSignedAuthMaps(pr *monetizeapi.PurchaseRequest) ([]map[string]string, error) { + if len(pr.Spec.PreSignedAuths) == 0 { + return nil, fmt.Errorf("no pre-signed auths in spec") + } + + auths := make([]map[string]string, len(pr.Spec.PreSignedAuths)) + for i, a := range pr.Spec.PreSignedAuths { + auths[i] = map[string]string{ + "signature": normalizeRecoverySignature(a.Signature), + "from": a.From, + "to": a.To, + "value": a.Value, + "validAfter": a.ValidAfter, + "validBefore": a.ValidBefore, + "nonce": a.Nonce, + } + } + + return auths, nil +} + +// removeLiteLLMModelEntry drops a paid/ route from LiteLLM. Mirrors +// addLiteLLMModelEntry: ConfigMap patch (persistence) + hot-delete via the +// /model/delete API (no pod restart). See addLiteLLMModelEntry for the +// rationale on not falling back to a rollout restart. +func (c *Controller) removeLiteLLMModelEntry(ctx context.Context, ns, modelName string) { + cm, err := c.kubeClient.CoreV1().ConfigMaps(ns).Get(ctx, "litellm-config", metav1.GetOptions{}) + if err != nil { + log.Printf("purchase: remove model: failed to read litellm-config: %v", err) + return + } + if cm.Data == nil { + cm.Data = make(map[string]string) + } + + var cfg model.LiteLLMConfig + if err := yaml.Unmarshal([]byte(cm.Data["config.yaml"]), &cfg); err != nil { + log.Printf("purchase: remove model: failed to parse litellm-config: %v", err) + return + } + + filtered := cfg.ModelList[:0] + changed := false + for _, entry := range cfg.ModelList { + if entry.ModelName == modelName { + changed = true + continue + } + filtered = append(filtered, entry) + } + if changed { + cfg.ModelList = filtered + + rendered, err := yaml.Marshal(&cfg) + if err != nil { + log.Printf("purchase: remove model: failed to serialize litellm-config: %v", err) + return + } + cm.Data["config.yaml"] = string(rendered) + if _, err := c.kubeClient.CoreV1().ConfigMaps(ns).Update(ctx, cm, metav1.UpdateOptions{}); err != nil { + log.Printf("purchase: remove model: failed to update litellm-config: %v", err) + return + } + } + + if err := c.hotDeleteLiteLLMModel(ctx, ns, modelName); err != nil { + if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) { + log.Printf("purchase: hot-delete skipped (%v); model will drop on next config reload", err) + return + } + log.Printf("purchase: hot-delete %s failed: %v; relying on ConfigMap reload", modelName, err) + } +} + +// triggerBuyerReload sends POST /admin/reload to the x402-buyer sidecar +// on all running litellm pods. Best-effort — the sidecar reloads on its +// own 5-second timer anyway. +func (c *Controller) triggerBuyerReload(ctx context.Context, ns string) { + pods, err := c.kubeClient.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{ + LabelSelector: "app=litellm", + }) + if err != nil || len(pods.Items) == 0 { + return + } + + for _, pod := range pods.Items { + if pod.Status.Phase != "Running" || pod.Status.PodIP == "" { + continue + } + reloadURL := fmt.Sprintf("http://%s:8402/admin/reload", pod.Status.PodIP) + reqCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + req, _ := http.NewRequestWithContext(reqCtx, "POST", reloadURL, nil) + c.httpClient.Do(req) //nolint:bodyclose // best-effort, response ignored + cancel() + } +} + +// ── Sidecar status check ──────────────────────────────────────────────────── + +func (c *Controller) checkBuyerStatus(ctx context.Context, ns, name string) (remaining, spent int, err error) { + // List LiteLLM pods to get a pod IP. + pods, err := c.kubeClient.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{ + LabelSelector: "app=litellm", + }) + if err != nil || len(pods.Items) == 0 { + return 0, 0, fmt.Errorf("no litellm pods in %s", ns) + } + + for _, pod := range pods.Items { + if pod.Status.Phase != "Running" || pod.Status.PodIP == "" { + continue + } + + resp, err := c.httpClient.Get(fmt.Sprintf("http://%s:8402/status", pod.Status.PodIP)) + if err != nil { + continue + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + var status map[string]struct { + Remaining int `json:"remaining"` + Spent int `json:"spent"` + } + if err := json.Unmarshal(body, &status); err != nil { + continue + } + + if info, ok := status[name]; ok { + return info.Remaining, info.Spent, nil + } + } + + return 0, 0, fmt.Errorf("upstream %q not found in sidecar status", name) +} + +func normalizeRecoverySignature(sig string) string { + if len(sig) != 132 || !strings.HasPrefix(sig, "0x") { + return sig + } + + lastByte, err := strconv.ParseUint(sig[len(sig)-2:], 16, 8) + if err != nil { + return sig + } + if lastByte <= 1 { + return sig[:len(sig)-2] + fmt.Sprintf("%02x", lastByte+27) + } + + return sig +} + +func normalizePurchasedUpstreamURL(endpoint string) string { + trimmed := strings.TrimRight(strings.TrimSpace(endpoint), "/") + for _, suffix := range []string{"/v1/chat/completions", "/chat/completions"} { + if strings.HasSuffix(trimmed, suffix) { + return strings.TrimSuffix(trimmed, suffix) + } + } + + return trimmed +} + diff --git a/internal/serviceoffercontroller/purchase_helpers_test.go b/internal/serviceoffercontroller/purchase_helpers_test.go new file mode 100644 index 00000000..8ebd8d9a --- /dev/null +++ b/internal/serviceoffercontroller/purchase_helpers_test.go @@ -0,0 +1,254 @@ +package serviceoffercontroller + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/ObolNetwork/obol-stack/internal/model" + "gopkg.in/yaml.v3" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +// litellmFake is a minimal httptest stand-in for the LiteLLM admin API. +// It records every received request and responds to /model/new, /model/info, +// and /model/delete. Used to assert that addLiteLLMModelEntry and +// removeLiteLLMModelEntry hot-add/hot-delete instead of restarting the pod. +type litellmFake struct { + server *httptest.Server + addCalls atomic.Int32 + delCalls atomic.Int32 + infoResp []map[string]any // returned from /model/info + authSeen atomic.Value // last Authorization header value +} + +func newLiteLLMFake() *litellmFake { + f := &litellmFake{} + mux := http.NewServeMux() + mux.HandleFunc("/model/new", func(w http.ResponseWriter, r *http.Request) { + f.authSeen.Store(r.Header.Get("Authorization")) + f.addCalls.Add(1) + body, _ := io.ReadAll(r.Body) + _ = body + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) + }) + mux.HandleFunc("/model/info", func(w http.ResponseWriter, r *http.Request) { + f.authSeen.Store(r.Header.Get("Authorization")) + payload := map[string]any{"data": f.infoResp} + b, _ := json.Marshal(payload) + w.Header().Set("Content-Type", "application/json") + w.Write(b) + }) + mux.HandleFunc("/model/delete", func(w http.ResponseWriter, r *http.Request) { + f.authSeen.Store(r.Header.Get("Authorization")) + f.delCalls.Add(1) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) + }) + f.server = httptest.NewServer(mux) + return f +} + +func (f *litellmFake) close() { f.server.Close() } + +func newTestControllerWithLiteLLM(ns string) (*Controller, *litellmFake) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "litellm-config", + Namespace: ns, + }, + Data: map[string]string{ + "config.yaml": "model_list: []\n", + }, + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "litellm-secrets", + Namespace: ns, + }, + Data: map[string][]byte{ + "LITELLM_MASTER_KEY": []byte("sk-obol-test"), + }, + } + kubeClient := fake.NewSimpleClientset(cm, secret) + fakeAPI := newLiteLLMFake() + return &Controller{ + kubeClient: kubeClient, + httpClient: &http.Client{Timeout: 5 * time.Second}, + litellmURLOverride: fakeAPI.server.URL, + }, fakeAPI +} + +func TestAddLiteLLMModelEntryUpdatesConfigMapAndHotAdds(t *testing.T) { + c, fakeAPI := newTestControllerWithLiteLLM("llm") + defer fakeAPI.close() + + c.addLiteLLMModelEntry(context.Background(), "llm", "paid/qwen3.5:9b") + + cm, err := c.kubeClient.CoreV1().ConfigMaps("llm").Get(context.Background(), "litellm-config", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get litellm-config: %v", err) + } + + var cfg model.LiteLLMConfig + if err := yaml.Unmarshal([]byte(cm.Data["config.yaml"]), &cfg); err != nil { + t.Fatalf("parse config.yaml: %v", err) + } + if len(cfg.ModelList) != 1 { + t.Fatalf("expected 1 model entry, got %d", len(cfg.ModelList)) + } + entry := cfg.ModelList[0] + if entry.ModelName != "paid/qwen3.5:9b" { + t.Fatalf("model_name = %q, want %q", entry.ModelName, "paid/qwen3.5:9b") + } + if entry.LiteLLMParams.Model != "openai/paid/qwen3.5:9b" { + t.Fatalf("litellm_params.model = %q", entry.LiteLLMParams.Model) + } + + if got := fakeAPI.addCalls.Load(); got != 1 { + t.Fatalf("expected exactly 1 call to /model/new, got %d", got) + } + if auth, _ := fakeAPI.authSeen.Load().(string); auth != "Bearer sk-obol-test" { + t.Fatalf("authorization header = %q, want Bearer sk-obol-test", auth) + } +} + +func TestAddLiteLLMModelEntryIsIdempotent(t *testing.T) { + c, fakeAPI := newTestControllerWithLiteLLM("llm") + defer fakeAPI.close() + + c.addLiteLLMModelEntry(context.Background(), "llm", "paid/qwen3.5:9b") + firstCalls := fakeAPI.addCalls.Load() + c.addLiteLLMModelEntry(context.Background(), "llm", "paid/qwen3.5:9b") + + cm, _ := c.kubeClient.CoreV1().ConfigMaps("llm").Get(context.Background(), "litellm-config", metav1.GetOptions{}) + if strings.Count(cm.Data["config.yaml"], "paid/qwen3.5:9b") != 2 { + t.Fatal("expected exactly one model entry and one openai target reference") + } + if got := fakeAPI.addCalls.Load(); got != firstCalls { + t.Fatalf("idempotent add should not re-hit /model/new, got %d calls total", got) + } +} + +func TestAddLiteLLMModelEntryNeverRestartsDeployment(t *testing.T) { + // Deliberately omit any Deployment from the fake client. The controller + // must never touch Deployments during model add — if it tries, the fake + // client will surface a NotFound error that would be logged but harmless. + // What matters: no rollout annotation should appear under any code path, + // because restartLiteLLM no longer exists. + c, fakeAPI := newTestControllerWithLiteLLM("llm") + defer fakeAPI.close() + + c.addLiteLLMModelEntry(context.Background(), "llm", "paid/qwen3.5:9b") + + // There should be no Deployment list action at all. + deployList, err := c.kubeClient.AppsV1().Deployments("llm").List(context.Background(), metav1.ListOptions{}) + if err != nil { + t.Fatalf("list deployments: %v", err) + } + if len(deployList.Items) != 0 { + t.Fatalf("add should not create a Deployment; got %d", len(deployList.Items)) + } +} + +func TestAddLiteLLMModelEntryHandlesMissingConfigMap(t *testing.T) { + kubeClient := fake.NewSimpleClientset() + c := &Controller{ + kubeClient: kubeClient, + httpClient: &http.Client{Timeout: 5 * time.Second}, + } + c.addLiteLLMModelEntry(context.Background(), "llm", "paid/test-model") +} + +func TestRemoveLiteLLMModelEntryUpdatesConfigMapAndHotDeletes(t *testing.T) { + c, fakeAPI := newTestControllerWithLiteLLM("llm") + defer fakeAPI.close() + + // Seed /model/info with a matching entry so hot-delete resolves an ID. + fakeAPI.infoResp = []map[string]any{ + { + "model_name": "paid/qwen3.5:9b", + "model_info": map[string]any{"id": "abc-123"}, + }, + } + + c.addLiteLLMModelEntry(context.Background(), "llm", "paid/qwen3.5:9b") + c.removeLiteLLMModelEntry(context.Background(), "llm", "paid/qwen3.5:9b") + + cm, err := c.kubeClient.CoreV1().ConfigMaps("llm").Get(context.Background(), "litellm-config", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get litellm-config: %v", err) + } + if strings.Contains(cm.Data["config.yaml"], "paid/qwen3.5:9b") { + t.Fatal("expected model entry to be removed from config.yaml") + } + if got := fakeAPI.delCalls.Load(); got != 1 { + t.Fatalf("expected 1 call to /model/delete, got %d", got) + } +} + +func TestRemoveLiteLLMModelEntryNoMatch(t *testing.T) { + c, fakeAPI := newTestControllerWithLiteLLM("llm") + defer fakeAPI.close() + + // ConfigMap and live router both have no matching entry -> no delete call. + c.removeLiteLLMModelEntry(context.Background(), "llm", "paid/nonexistent") + if got := fakeAPI.delCalls.Load(); got != 0 { + t.Fatalf("expected no /model/delete calls for missing entry, got %d", got) + } +} + +func TestRemoveLiteLLMModelEntryRetriesHotDeleteWhenConfigMapAlreadyClean(t *testing.T) { + c, fakeAPI := newTestControllerWithLiteLLM("llm") + defer fakeAPI.close() + + // Simulates a previous reconcile that removed the persistent ConfigMap entry + // but crashed before, or failed during, the live /model/delete API call. + fakeAPI.infoResp = []map[string]any{ + { + "model_name": "paid/qwen3.5:9b", + "model_info": map[string]any{"id": "stale-live-route"}, + }, + } + + c.removeLiteLLMModelEntry(context.Background(), "llm", "paid/qwen3.5:9b") + + if got := fakeAPI.delCalls.Load(); got != 1 { + t.Fatalf("expected /model/delete retry for stale live route, got %d calls", got) + } +} + +func TestRemoveLiteLLMModelEntryServerError(t *testing.T) { + // No ConfigMap in the fake client → read fails, function logs and returns. + kubeClient := fake.NewSimpleClientset() + c := &Controller{ + kubeClient: kubeClient, + httpClient: &http.Client{Timeout: 5 * time.Second}, + } + c.removeLiteLLMModelEntry(context.Background(), "llm", "paid/test-model") +} + +// ── triggerBuyerReload tests ─────────────────────────────────────────────── + +func TestTriggerBuyerReload(t *testing.T) { + // The triggerBuyerReload hits pod IPs directly, not the LiteLLM service. + // We can't easily test the pod discovery with fake client, but we can + // verify the function doesn't panic with no pods. + kubeClient := fake.NewSimpleClientset() + c := &Controller{ + kubeClient: kubeClient, + httpClient: &http.Client{}, + } + + // Should not panic with no pods. + c.triggerBuyerReload(context.Background(), "llm") +} diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 78fd3684..e605ce3c 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -732,6 +732,39 @@ func findProjectRoot() string { } } +// LocalIngressURL returns the best local HTTP base URL for the current stack. +// For k3d, it prefers the first host port mapped to container port 80 in the +// generated k3d config. For historical/default setups it falls back to +// http://obol.stack or http://obol.stack:8080. +func LocalIngressURL(cfg *config.Config) string { + k3dConfigPath := filepath.Join(cfg.ConfigDir, k3dConfigFile) + if data, err := os.ReadFile(k3dConfigPath); err == nil { + for line := range strings.SplitSeq(string(data), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "- port:") { + continue + } + + portSpec := strings.TrimSpace(strings.TrimPrefix(line, "- port:")) + parts := strings.Split(portSpec, ":") + if len(parts) != 2 || parts[1] != "80" { + continue + } + + if parts[0] == "80" { + return "http://obol.stack" + } + return fmt.Sprintf("http://obol.stack:%s", parts[0]) + } + } + + if checkPortsAvailable([]int{80}) == nil { + return "http://obol.stack" + } + + return "http://obol.stack:8080" +} + // checkPortsAvailable verifies that all required ports can be bound. func checkPortsAvailable(ports []int) error { var blocked []int diff --git a/internal/stack/stack_test.go b/internal/stack/stack_test.go index 6b5f583b..125c3bb6 100644 --- a/internal/stack/stack_test.go +++ b/internal/stack/stack_test.go @@ -101,6 +101,42 @@ func TestFormatPorts(t *testing.T) { } } +func TestLocalIngressURL_DefaultK3dPort(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ConfigDir: tmpDir} + + err := os.WriteFile(filepath.Join(tmpDir, k3dConfigFile), []byte(` +ports: + - port: 80:80 + - port: 8080:80 +`), 0o644) + if err != nil { + t.Fatalf("write k3d config: %v", err) + } + + if got := LocalIngressURL(cfg); got != "http://obol.stack" { + t.Fatalf("LocalIngressURL() = %q, want %q", got, "http://obol.stack") + } +} + +func TestLocalIngressURL_CustomK3dPort(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{ConfigDir: tmpDir} + + err := os.WriteFile(filepath.Join(tmpDir, k3dConfigFile), []byte(` +ports: + - port: 18080:80 + - port: 18081:80 +`), 0o644) + if err != nil { + t.Fatalf("write k3d config: %v", err) + } + + if got := LocalIngressURL(cfg); got != "http://obol.stack:18080" { + t.Fatalf("LocalIngressURL() = %q, want %q", got, "http://obol.stack:18080") + } +} + func TestDestroyOldBackendIfSwitching_CleansStaleConfigs(t *testing.T) { // Simulate a k3d → k3s switch: k3d.yaml should be cleaned up tmpDir := t.TempDir() diff --git a/internal/testutil/eip712_signer.go b/internal/testutil/eip712_signer.go index 9ee9e431..15ccd8ef 100644 --- a/internal/testutil/eip712_signer.go +++ b/internal/testutil/eip712_signer.go @@ -22,7 +22,7 @@ const ( // SignRealPaymentHeader constructs a real EIP-712 TransferWithAuthorization // (ERC-3009) payment header and returns it as a base64-encoded string -// compatible with the x402 V1 wire format. +// compatible with the x402 v2 wire format. // // The signerKey is the buyer's private key (signs the authorization). // payTo is the seller's address (from ServiceOffer payment.payTo). @@ -102,12 +102,25 @@ func SignRealPaymentHeader(t *testing.T, signerKeyHex string, payTo string, amou sig[64] += 27 sigHex := fmt.Sprintf("0x%x", sig) - // Build the x402 V1 payment envelope. - // All numeric values that x402-rs expects as strings must be strings here. + // Build the x402 v2 payment envelope. envelope := map[string]any{ - "x402Version": 1, - "scheme": "exact", - "network": chainName(chainID), + "x402Version": 2, + "accepted": map[string]any{ + "scheme": "exact", + "network": chainCAIP2(chainID), + "amount": amount, + "asset": USDCBaseSepolia, + "payTo": payTo, + "maxTimeoutSeconds": 60, + // x402-rs strict v2 deserialization requires the EIP-3009 marker. + // The signature itself intentionally uses the deployed USDC + // contract's EIP-712 domain above. + "extra": map[string]any{ + "assetTransferMethod": "eip3009", + "name": "USD Coin", + "version": "2", + }, + }, "payload": map[string]any{ "signature": sigHex, "authorization": map[string]any{ @@ -119,12 +132,6 @@ func SignRealPaymentHeader(t *testing.T, signerKeyHex string, payTo string, amou "nonce": nonceHex, }, }, - "resource": map[string]any{ - "payTo": payTo, - "maxAmountRequired": amount, - "asset": USDCBaseSepolia, - "network": chainName(chainID), - }, } data, err := json.Marshal(envelope) @@ -240,9 +247,23 @@ func SignPaymentHeaderDirect(signerKeyHex, payTo, amount string, chainID int64) sig[64] += 27 envelope := map[string]any{ - "x402Version": 1, - "scheme": "exact", - "network": chainName(chainID), + "x402Version": 2, + "accepted": map[string]any{ + "scheme": "exact", + "network": chainCAIP2(chainID), + "amount": amount, + "asset": USDCBaseSepolia, + "payTo": payTo, + "maxTimeoutSeconds": 60, + // x402-rs strict v2 deserialization requires the EIP-3009 marker. + // The signature itself intentionally uses the deployed USDC + // contract's EIP-712 domain above. + "extra": map[string]any{ + "assetTransferMethod": "eip3009", + "name": "USD Coin", + "version": "2", + }, + }, "payload": map[string]any{ "signature": fmt.Sprintf("0x%x", sig), "authorization": map[string]any{ @@ -254,12 +275,6 @@ func SignPaymentHeaderDirect(signerKeyHex, payTo, amount string, chainID int64) "nonce": nonceHex, }, }, - "resource": map[string]any{ - "payTo": payTo, - "maxAmountRequired": amount, - "asset": USDCBaseSepolia, - "network": chainName(chainID), - }, } data, err := json.Marshal(envelope) @@ -282,3 +297,16 @@ func chainName(chainID int64) string { return fmt.Sprintf("eip155:%d", chainID) } } + +func chainCAIP2(chainID int64) string { + switch chainID { + case 84532: + return "eip155:84532" + case 8453: + return "eip155:8453" + case 1: + return "eip155:1" + default: + return fmt.Sprintf("eip155:%d", chainID) + } +} diff --git a/internal/x402/bdd_integration_steps_test.go b/internal/x402/bdd_integration_steps_test.go index 11134ce7..64a0035a 100644 --- a/internal/x402/bdd_integration_steps_test.go +++ b/internal/x402/bdd_integration_steps_test.go @@ -20,19 +20,29 @@ import ( // parsed402Response maps the x402 PaymentRequired response body. type parsed402Response struct { - X402Version int `json:"x402Version"` - Error string `json:"error"` - Accepts []struct { - Scheme string `json:"scheme"` - Network string `json:"network"` - Amount string `json:"maxAmountRequired"` - Asset string `json:"asset"` - PayTo string `json:"payTo"` - Resource string `json:"resource"` - Description string `json:"description"` - MimeType string `json:"mimeType"` - MaxTimeoutSeconds int `json:"maxTimeoutSeconds"` - } `json:"accepts"` + X402Version int `json:"x402Version"` + Error string `json:"error"` + Accepts []parsed402Accept `json:"accepts"` +} + +type parsed402Accept struct { + Scheme string `json:"scheme"` + Network string `json:"network"` + Amount string `json:"amount"` + MaxAmountRequired string `json:"maxAmountRequired"` + Asset string `json:"asset"` + PayTo string `json:"payTo"` + Resource string `json:"resource"` + Description string `json:"description"` + MimeType string `json:"mimeType"` + MaxTimeoutSeconds int `json:"maxTimeoutSeconds"` +} + +func (a parsed402Accept) price() string { + if a.Amount != "" { + return a.Amount + } + return a.MaxAmountRequired } // integrationWorld holds shared state for integration-tier BDD scenarios. @@ -187,7 +197,7 @@ func registerIntegrationSteps(ctx *godog.ScenarioContext, w *integrationWorld) { accept := w.parsed402.Accepts[0] payTo := accept.PayTo - amount := accept.Amount + amount := accept.price() if payTo == "" { payTo = w.payTo } @@ -207,8 +217,12 @@ func registerIntegrationSteps(ctx *godog.ScenarioContext, w *integrationWorld) { } accept := w.parsed402.Accepts[0] + amount := accept.price() + if amount == "" { + return fmt.Errorf("no amount in 402 accepts") + } w.signedPaymentHeader = testutil.SignRealPaymentHeader( - w.t, w.buyerKeyHex, accept.PayTo, accept.Amount, 84532, + w.t, w.buyerKeyHex, accept.PayTo, amount, 84532, ) return nil }) @@ -265,7 +279,7 @@ func registerIntegrationSteps(ctx *godog.ScenarioContext, w *integrationWorld) { return fmt.Errorf("empty accepts array in 402") } a := w.parsed402.Accepts[0] - if a.PayTo == "" || a.Network == "" || a.Amount == "" { + if a.PayTo == "" || a.Network == "" || a.price() == "" { return fmt.Errorf("incomplete accepts entry: %+v", a) } return nil @@ -287,13 +301,13 @@ func registerIntegrationSteps(ctx *godog.ScenarioContext, w *integrationWorld) { if a.PayTo == "" { return fmt.Errorf("payTo is empty") } - if a.Amount == "" { - return fmt.Errorf("price (maxAmountRequired) is empty") + if a.price() == "" { + return fmt.Errorf("price (amount/maxAmountRequired) is empty") } if a.Network == "" { return fmt.Errorf("network is empty") } - w.t.Logf("integration: discovered payTo=%s price=%s network=%s", a.PayTo, a.Amount, a.Network) + w.t.Logf("integration: discovered payTo=%s price=%s network=%s", a.PayTo, a.price(), a.Network) return nil }) @@ -570,7 +584,7 @@ func registerIntegrationSteps(ctx *godog.ScenarioContext, w *integrationWorld) { } a := w.parsed402.Accepts[0] w.t.Logf("integration: ✓ probe 402: payTo=%s price=%s network=%s", - a.PayTo, a.Amount, a.Network) + a.PayTo, a.price(), a.Network) return nil }) diff --git a/internal/x402/buy_side_test.go b/internal/x402/buy_side_test.go index e444a960..aeae2a62 100644 --- a/internal/x402/buy_side_test.go +++ b/internal/x402/buy_side_test.go @@ -118,16 +118,29 @@ func TestBuySidecar_EndToEnd(t *testing.T) { } // Check wire format fields. - if v, ok := envelope["x402Version"]; !ok || v != float64(1) { - t.Errorf("x402Version = %v, want 1", v) + if v, ok := envelope["x402Version"]; !ok || v != float64(2) { + t.Errorf("x402Version = %v, want 2", v) } - if v, ok := envelope["scheme"]; !ok || v != "exact" { - t.Errorf("scheme = %v, want exact", v) + accepted, ok := envelope["accepted"].(map[string]any) + if !ok { + t.Fatal("accepted missing or wrong type") + } + + if v, ok := accepted["scheme"]; !ok || v != "exact" { + t.Errorf("accepted.scheme = %v, want exact", v) + } + + if v, ok := accepted["network"]; !ok || v != "eip155:84532" { + t.Errorf("accepted.network = %v, want eip155:84532", v) + } + + if v, ok := accepted["amount"]; !ok || v != "1000" { + t.Errorf("accepted.amount = %v, want 1000", v) } - if v, ok := envelope["network"]; !ok || v != "base-sepolia" { - t.Errorf("network = %v, want base-sepolia", v) + if v, ok := accepted["payTo"]; !ok || v != payTo { + t.Errorf("accepted.payTo = %v, want %s", v, payTo) } // Check payload has authorization with correct fields. diff --git a/internal/x402/buyer/config.go b/internal/x402/buyer/config.go index f807ef59..f80135e5 100644 --- a/internal/x402/buyer/config.go +++ b/internal/x402/buyer/config.go @@ -12,6 +12,8 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" + "strings" ) // Config is the top-level sidecar configuration, loaded from a JSON file @@ -97,3 +99,67 @@ func LoadAuths(path string) (AuthsFile, error) { return auths, nil } + +// LoadConfigDir reads per-upstream config files from a directory. Each *.json +// file is one upstream, keyed by the filename stem (e.g. "42.json" → key "42"). +// This is the SSA-compatible format where the controller applies one key per +// PurchaseRequest via Server-Side Apply. +func LoadConfigDir(dir string) (*Config, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read config dir %s: %w", dir, err) + } + + cfg := &Config{Upstreams: make(map[string]UpstreamConfig)} + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + + name := strings.TrimSuffix(e.Name(), ".json") + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + + var upstream UpstreamConfig + if err := json.Unmarshal(data, &upstream); err != nil { + continue + } + + cfg.Upstreams[name] = upstream + } + + return cfg, nil +} + +// LoadAuthsDir reads per-upstream auth files from a directory. Each *.json +// file contains an array of PreSignedAuth for one upstream. +func LoadAuthsDir(dir string) (AuthsFile, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read auths dir %s: %w", dir, err) + } + + auths := make(AuthsFile) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + + name := strings.TrimSuffix(e.Name(), ".json") + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + continue + } + + var pool []*PreSignedAuth + if err := json.Unmarshal(data, &pool); err != nil { + continue + } + + auths[name] = pool + } + + return auths, nil +} diff --git a/internal/x402/buyer/encoding.go b/internal/x402/buyer/encoding.go new file mode 100644 index 00000000..8983247c --- /dev/null +++ b/internal/x402/buyer/encoding.go @@ -0,0 +1,44 @@ +package buyer + +import ( + "encoding/base64" + "encoding/json" + "fmt" + + x402types "github.com/coinbase/x402/go/types" +) + +// EncodePayment converts a v2 PaymentPayload to a base64-encoded JSON string +// for the X-PAYMENT HTTP header. +func EncodePayment(payment x402types.PaymentPayload) (string, error) { + paymentJSON, err := json.Marshal(payment) + if err != nil { + return "", fmt.Errorf("failed to marshal payment: %w", err) + } + return base64.StdEncoding.EncodeToString(paymentJSON), nil +} + +// SettlementResponse is the decoded X-PAYMENT-RESPONSE header. +type SettlementResponse struct { + Success bool `json:"success"` + ErrorReason string `json:"errorReason,omitempty"` + Transaction string `json:"transaction,omitempty"` + Network string `json:"network"` + Payer string `json:"payer"` +} + +// DecodeSettlement decodes a base64-encoded X-PAYMENT-RESPONSE header. +func DecodeSettlement(encoded string) (SettlementResponse, error) { + var settlement SettlementResponse + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return settlement, fmt.Errorf("failed to decode base64: %w", err) + } + + if err := json.Unmarshal(decoded, &settlement); err != nil { + return settlement, fmt.Errorf("failed to unmarshal settlement: %w", err) + } + + return settlement, nil +} diff --git a/internal/x402/buyer/encoding_test.go b/internal/x402/buyer/encoding_test.go new file mode 100644 index 00000000..6f57e834 --- /dev/null +++ b/internal/x402/buyer/encoding_test.go @@ -0,0 +1,89 @@ +package buyer + +import ( + "encoding/base64" + "encoding/json" + "testing" + + x402types "github.com/coinbase/x402/go/types" +) + +func TestEncodePayment_RoundTrip(t *testing.T) { + payload := x402types.PaymentPayload{ + X402Version: 2, + Accepted: x402types.PaymentRequirements{ + Scheme: "exact", + Network: "eip155:84532", + Amount: "1000", + Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + PayTo: "0xTo", + }, + Payload: map[string]interface{}{ + "signature": "0xSig", + "authorization": map[string]interface{}{ + "from": "0xFrom", "to": "0xTo", "value": "1000", + "validAfter": "0", "validBefore": "9999999999", "nonce": "0xNonce", + }, + }, + } + + encoded, err := EncodePayment(payload) + if err != nil { + t.Fatalf("EncodePayment: %v", err) + } + + // Decode and verify round-trip. + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("base64 decode: %v", err) + } + + var result x402types.PaymentPayload + if err := json.Unmarshal(decoded, &result); err != nil { + t.Fatalf("json unmarshal: %v", err) + } + + if result.X402Version != 2 { + t.Errorf("X402Version = %d, want 2", result.X402Version) + } + if result.Accepted.Scheme != "exact" { + t.Errorf("Scheme = %q, want %q", result.Accepted.Scheme, "exact") + } + if result.Accepted.Network != "eip155:84532" { + t.Errorf("Network = %q, want %q", result.Accepted.Network, "eip155:84532") + } +} + +func TestDecodeSettlement_RoundTrip(t *testing.T) { + original := SettlementResponse{ + Success: true, + Transaction: "0xTxHash", + Network: "base-sepolia", + Payer: "0xPayer", + } + + jsonBytes, _ := json.Marshal(original) + encoded := base64.StdEncoding.EncodeToString(jsonBytes) + + result, err := DecodeSettlement(encoded) + if err != nil { + t.Fatalf("DecodeSettlement: %v", err) + } + + if !result.Success { + t.Error("Success = false, want true") + } + if result.Transaction != "0xTxHash" { + t.Errorf("Transaction = %q, want %q", result.Transaction, "0xTxHash") + } + if result.Payer != "0xPayer" { + t.Errorf("Payer = %q, want %q", result.Payer, "0xPayer") + } +} + +func TestDecodeSettlement_InvalidBase64(t *testing.T) { + _, err := DecodeSettlement("not-valid-base64!!!") + if err == nil { + t.Error("expected error for invalid base64") + } +} diff --git a/internal/x402/buyer/proxy.go b/internal/x402/buyer/proxy.go index 5aa0aaa8..1a10e8bf 100644 --- a/internal/x402/buyer/proxy.go +++ b/internal/x402/buyer/proxy.go @@ -13,8 +13,7 @@ import ( "sync" "time" - x402 "github.com/mark3labs/x402-go" - "github.com/mark3labs/x402-go/encoding" + x402types "github.com/coinbase/x402/go/types" ) // Proxy is an OpenAI-compatible reverse proxy that routes requests to upstream @@ -32,6 +31,7 @@ type Proxy struct { mux *http.ServeMux metrics *metrics state *StateStore + reloadCh chan struct{} } type upstreamEntry struct { @@ -67,6 +67,7 @@ func NewProxy(cfg *Config, auths AuthsFile, state *StateStore) (*Proxy, error) { mux: http.NewServeMux(), metrics: newMetrics(), state: state, + reloadCh: make(chan struct{}, 1), } p.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { @@ -74,6 +75,7 @@ func NewProxy(cfg *Config, auths AuthsFile, state *StateStore) (*Proxy, error) { fmt.Fprint(w, "ok") }) p.mux.HandleFunc("GET /status", p.handleStatus) + p.mux.HandleFunc("POST /admin/reload", p.handleAdminReload) p.mux.Handle("GET /metrics", p.metrics.handler()) registerOpenAIRoutes(p.mux, p.handleModelRequest) @@ -161,6 +163,7 @@ func (p *Proxy) syncCompatibilityRoutesLocked() { fmt.Fprint(w, "ok") }) p.mux.HandleFunc("GET /status", p.handleStatus) + p.mux.HandleFunc("POST /admin/reload", p.handleAdminReload) p.mux.Handle("GET /metrics", p.metrics.handler()) registerOpenAIRoutes(p.mux, p.handleModelRequest) @@ -225,12 +228,12 @@ func (p *Proxy) buildUpstreamHandler(name, remoteModel string, cfg UpstreamConfi }, Transport: &replayableX402Transport{ Base: http.DefaultTransport, - Signers: []x402.Signer{signer}, - Selector: x402.NewDefaultPaymentSelector(), - OnPaymentAttempt: func(event x402.PaymentEvent) { + Signers: []Signer{signer}, + Selector: NewDefaultPaymentSelector(), + OnPaymentAttempt: func(event PaymentEvent) { p.metrics.paymentAttempts.With(labels).Inc() }, - OnPaymentFailure: func(event x402.PaymentEvent) { + OnPaymentFailure: func(event PaymentEvent) { p.metrics.paymentFailureTotal.With(labels).Inc() p.metrics.authRemaining.With(labels).Set(float64(signer.Remaining())) p.metrics.authSpent.With(labels).Set(float64(signer.Spent())) @@ -356,11 +359,11 @@ func bodyBufferMiddleware(next http.Handler) http.Handler { // httputil.ReverseProxy on newer Go versions. type replayableX402Transport struct { Base http.RoundTripper - Signers []x402.Signer - Selector x402.PaymentSelector - OnPaymentAttempt x402.PaymentCallback - OnPaymentSuccess x402.PaymentCallback - OnPaymentFailure x402.PaymentCallback + Signers []Signer + Selector PaymentSelector + OnPaymentAttempt PaymentCallback + OnPaymentSuccess PaymentCallback + OnPaymentFailure PaymentCallback } func (t *replayableX402Transport) RoundTrip(req *http.Request) (*http.Response, error) { @@ -386,7 +389,7 @@ func (t *replayableX402Transport) RoundTrip(req *http.Request) (*http.Response, requirements, err := parsePaymentRequirements(resp) if err != nil { resp.Body.Close() - return nil, x402.NewPaymentError(x402.ErrCodeInvalidRequirements, "failed to parse payment requirements", err) + return nil, NewPaymentError(ErrCodeInvalidRequirements, "failed to parse payment requirements", err) } resp.Body.Close() @@ -395,9 +398,13 @@ func (t *replayableX402Transport) RoundTrip(req *http.Request) (*http.Response, return nil, err } - var selectedRequirement *x402.PaymentRequirement + var selectedRequirement *x402types.PaymentRequirements for i := range requirements { - if requirements[i].Network == payment.Network && requirements[i].Scheme == payment.Scheme { + if requirements[i].Network == payment.Accepted.Network && + requirements[i].Scheme == payment.Accepted.Scheme && + requirements[i].Amount == payment.Accepted.Amount && + requirements[i].Asset == payment.Accepted.Asset && + requirements[i].PayTo == payment.Accepted.PayTo { selectedRequirement = &requirements[i] break } @@ -405,24 +412,24 @@ func (t *replayableX402Transport) RoundTrip(req *http.Request) (*http.Response, startTime := time.Now() if t.OnPaymentAttempt != nil && selectedRequirement != nil { - t.OnPaymentAttempt(x402.PaymentEvent{ - Type: x402.PaymentEventAttempt, + t.OnPaymentAttempt(PaymentEvent{ + Type: PaymentEventAttempt, Timestamp: startTime, Method: "HTTP", URL: req.URL.String(), - Network: payment.Network, - Scheme: payment.Scheme, - Amount: selectedRequirement.MaxAmountRequired, + Network: payment.Accepted.Network, + Scheme: payment.Accepted.Scheme, + Amount: selectedRequirement.Amount, Asset: selectedRequirement.Asset, Recipient: selectedRequirement.PayTo, }) } - paymentHeader, err := encoding.EncodePayment(*payment) + paymentHeader, err := EncodePayment(*payment) if err != nil { if t.OnPaymentFailure != nil { - t.OnPaymentFailure(x402.PaymentEvent{ - Type: x402.PaymentEventFailure, + t.OnPaymentFailure(PaymentEvent{ + Type: PaymentEventFailure, Timestamp: time.Now(), Method: "HTTP", URL: req.URL.String(), @@ -430,7 +437,7 @@ func (t *replayableX402Transport) RoundTrip(req *http.Request) (*http.Response, Duration: time.Since(startTime), }) } - return nil, x402.NewPaymentError(x402.ErrCodeSigningFailed, "failed to build payment header", err) + return nil, NewPaymentError(ErrCodeSigningFailed, "failed to build payment header", err) } retryReq, err := cloneRequestWithFreshBody(req) @@ -443,8 +450,8 @@ func (t *replayableX402Transport) RoundTrip(req *http.Request) (*http.Response, duration := time.Since(startTime) if err != nil { if t.OnPaymentFailure != nil { - t.OnPaymentFailure(x402.PaymentEvent{ - Type: x402.PaymentEventFailure, + t.OnPaymentFailure(PaymentEvent{ + Type: PaymentEventFailure, Timestamp: time.Now(), Method: "HTTP", URL: req.URL.String(), @@ -455,10 +462,10 @@ func (t *replayableX402Transport) RoundTrip(req *http.Request) (*http.Response, return nil, err } - settlement, _ := encoding.DecodeSettlement(respRetry.Header.Get("X-PAYMENT-RESPONSE")) + settlement, _ := DecodeSettlement(respRetry.Header.Get("X-PAYMENT-RESPONSE")) if settlement.Success && t.OnPaymentSuccess != nil { - event := x402.PaymentEvent{ - Type: x402.PaymentEventSuccess, + event := PaymentEvent{ + Type: PaymentEventSuccess, Timestamp: time.Now(), Method: "HTTP", URL: req.URL.String(), @@ -469,7 +476,7 @@ func (t *replayableX402Transport) RoundTrip(req *http.Request) (*http.Response, if selectedRequirement != nil { event.Network = selectedRequirement.Network event.Scheme = selectedRequirement.Scheme - event.Amount = selectedRequirement.MaxAmountRequired + event.Amount = selectedRequirement.Amount event.Asset = selectedRequirement.Asset event.Recipient = selectedRequirement.PayTo } @@ -517,24 +524,21 @@ func cloneRequestWithFreshBody(req *http.Request) (*http.Request, error) { return clone, nil } -func parsePaymentRequirements(resp *http.Response) ([]x402.PaymentRequirement, error) { +func parsePaymentRequirements(resp *http.Response) ([]x402types.PaymentRequirements, error) { body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } var paymentReqResp struct { - X402Version int `json:"x402Version"` - Error string `json:"error"` + X402Version int `json:"x402Version"` Accepts []struct { Scheme string `json:"scheme"` Network string `json:"network"` - MaxAmountRequired string `json:"maxAmountRequired"` + MaxAmountRequired string `json:"maxAmountRequired,omitempty"` + Amount string `json:"amount,omitempty"` Asset string `json:"asset"` PayTo string `json:"payTo"` - Resource string `json:"resource"` - Description string `json:"description,omitempty"` - MimeType string `json:"mimeType,omitempty"` MaxTimeoutSeconds int `json:"maxTimeoutSeconds"` Extra map[string]interface{} `json:"extra,omitempty"` } `json:"accepts"` @@ -546,17 +550,18 @@ func parsePaymentRequirements(resp *http.Response) ([]x402.PaymentRequirement, e return nil, fmt.Errorf("no payment requirements in response") } - requirements := make([]x402.PaymentRequirement, len(paymentReqResp.Accepts)) + requirements := make([]x402types.PaymentRequirements, len(paymentReqResp.Accepts)) for i, req := range paymentReqResp.Accepts { - requirements[i] = x402.PaymentRequirement{ + amount := req.Amount + if amount == "" { + amount = req.MaxAmountRequired + } + requirements[i] = x402types.PaymentRequirements{ Scheme: req.Scheme, - Network: req.Network, - MaxAmountRequired: req.MaxAmountRequired, + Network: normalizeNetworkID(req.Network), + Amount: amount, Asset: req.Asset, PayTo: req.PayTo, - Resource: req.Resource, - Description: req.Description, - MimeType: req.MimeType, MaxTimeoutSeconds: req.MaxTimeoutSeconds, Extra: req.Extra, } @@ -588,6 +593,21 @@ func (p *Proxy) handleStatus(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(result) //nolint:errchkjson // controlled status map } +func (p *Proxy) handleAdminReload(w http.ResponseWriter, _ *http.Request) { + select { + case p.reloadCh <- struct{}{}: + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"reload triggered"}`) + default: + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"reload already pending"}`) + } +} + +func (p *Proxy) ReloadCh() <-chan struct{} { + return p.reloadCh +} + // singleJoiningSlash joins a base and suffix path with exactly one slash. func singleJoiningSlash(a, b string) string { aslash := strings.HasSuffix(a, "/") diff --git a/internal/x402/buyer/proxy_test.go b/internal/x402/buyer/proxy_test.go index 62a7348c..0838a6dc 100644 --- a/internal/x402/buyer/proxy_test.go +++ b/internal/x402/buyer/proxy_test.go @@ -1043,3 +1043,69 @@ func writeFile(t *testing.T, path, content string) error { t.Helper() return os.WriteFile(path, []byte(content), 0o644) } + +func TestProxy_AdminReload(t *testing.T) { + cfg := &Config{Upstreams: map[string]UpstreamConfig{}} + auths := AuthsFile{} + + proxy, err := NewProxy(cfg, auths, nil) + if err != nil { + t.Fatalf("NewProxy: %v", err) + } + + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/admin/reload", nil)) + if rec.Code != http.StatusOK { + t.Errorf("admin/reload: got %d, want 200", rec.Code) + } + + body := rec.Body.String() + if !strings.Contains(body, "reload triggered") { + t.Errorf("body = %q, want 'reload triggered'", body) + } + + // Channel should have a signal. + select { + case <-proxy.ReloadCh(): + // expected + default: + t.Error("expected reload signal on channel") + } +} + +func TestProxy_AdminReloadIdempotent(t *testing.T) { + cfg := &Config{Upstreams: map[string]UpstreamConfig{}} + auths := AuthsFile{} + + proxy, err := NewProxy(cfg, auths, nil) + if err != nil { + t.Fatalf("NewProxy: %v", err) + } + + // First request: should get "reload triggered". + rec1 := httptest.NewRecorder() + proxy.ServeHTTP(rec1, httptest.NewRequest(http.MethodPost, "/admin/reload", nil)) + if rec1.Code != http.StatusOK { + t.Errorf("first admin/reload: got %d, want 200", rec1.Code) + } + + // Second request without draining the channel: "already pending". + rec2 := httptest.NewRecorder() + proxy.ServeHTTP(rec2, httptest.NewRequest(http.MethodPost, "/admin/reload", nil)) + if rec2.Code != http.StatusOK { + t.Errorf("second admin/reload: got %d, want 200", rec2.Code) + } + if !strings.Contains(rec2.Body.String(), "already pending") { + t.Errorf("body = %q, want 'already pending'", rec2.Body.String()) + } + + // Drain the channel. + <-proxy.ReloadCh() + + // Third request: "reload triggered" again. + rec3 := httptest.NewRecorder() + proxy.ServeHTTP(rec3, httptest.NewRequest(http.MethodPost, "/admin/reload", nil)) + if !strings.Contains(rec3.Body.String(), "reload triggered") { + t.Errorf("body = %q, want 'reload triggered'", rec3.Body.String()) + } +} diff --git a/internal/x402/buyer/signer.go b/internal/x402/buyer/signer.go index d6fd1395..0822c7f1 100644 --- a/internal/x402/buyer/signer.go +++ b/internal/x402/buyer/signer.go @@ -6,10 +6,10 @@ import ( "strings" "sync" - x402 "github.com/mark3labs/x402-go" + x402types "github.com/coinbase/x402/go/types" ) -// PreSignedSigner implements x402.Signer using pre-signed ERC-3009 +// PreSignedSigner implements Signer using pre-signed ERC-3009 // TransferWithAuthorization vouchers. It pops one auth from the pool per // Sign() call. The pool is finite — once exhausted, CanSign returns false. // @@ -33,7 +33,7 @@ func NewPreSignedSigner(network, payTo, asset, price string, auths []*PreSignedA copy(pool, auths) return &PreSignedSigner{ - network: network, + network: normalizeNetworkID(network), payTo: payTo, asset: asset, price: price, @@ -52,12 +52,12 @@ func (s *PreSignedSigner) Scheme() string { return "exact" } // CanSign checks if this signer can satisfy the given payment requirement. // Returns true if network, payTo, asset, and amount match and there are // remaining auths in the pool. -func (s *PreSignedSigner) CanSign(req *x402.PaymentRequirement) bool { +func (s *PreSignedSigner) CanSign(req *x402types.PaymentRequirements) bool { if req == nil { return false } - if !strings.EqualFold(req.Network, s.network) { + if !strings.EqualFold(normalizeNetworkID(req.Network), s.network) { return false } @@ -69,7 +69,15 @@ func (s *PreSignedSigner) CanSign(req *x402.PaymentRequirement) bool { return false } - if req.MaxAmountRequired != "" && req.MaxAmountRequired != s.price { + amount := req.Amount + if amount == "" { + if req.Extra != nil { + if legacy, ok := req.Extra["maxAmountRequired"].(string); ok && legacy != "" { + amount = legacy + } + } + } + if amount != "" && amount != s.price { return false } @@ -82,13 +90,16 @@ func (s *PreSignedSigner) CanSign(req *x402.PaymentRequirement) bool { // Sign pops one pre-signed authorization from the pool and returns it as a // PaymentPayload. Returns an error when the pool is exhausted. -func (s *PreSignedSigner) Sign(req *x402.PaymentRequirement) (*x402.PaymentPayload, error) { +func (s *PreSignedSigner) Sign(req *x402types.PaymentRequirements) (*x402types.PaymentPayload, error) { + if req == nil { + return nil, fmt.Errorf("payment requirements are nil") + } s.mu.Lock() defer s.mu.Unlock() if len(s.auths) == 0 { return nil, fmt.Errorf("pre-signed auth pool exhausted (spent %d): %w", - s.spent, x402.ErrNoValidSigner) + s.spent, ErrNoValidSigner) } // Pop from the front. @@ -105,19 +116,31 @@ func (s *PreSignedSigner) Sign(req *x402.PaymentRequirement) (*x402.PaymentPaylo } } - return &x402.PaymentPayload{ - X402Version: 1, - Scheme: "exact", - Network: s.network, - Payload: x402.EVMPayload{ - Signature: auth.Signature, - Authorization: x402.EVMAuthorization{ - From: auth.From, - To: auth.To, - Value: auth.Value, - ValidAfter: auth.ValidAfter, - ValidBefore: auth.ValidBefore, - Nonce: auth.Nonce, + accepted := *req + if accepted.Scheme == "" { + accepted.Scheme = "exact" + } + if accepted.Network == "" { + accepted.Network = s.network + } else { + accepted.Network = normalizeNetworkID(accepted.Network) + } + if accepted.Amount == "" && s.price != "" { + accepted.Amount = s.price + } + + return &x402types.PaymentPayload{ + X402Version: 2, + Accepted: accepted, + Payload: map[string]interface{}{ + "signature": auth.Signature, + "authorization": map[string]interface{}{ + "from": auth.From, + "to": auth.To, + "value": auth.Value, + "validAfter": auth.ValidAfter, + "validBefore": auth.ValidBefore, + "nonce": auth.Nonce, }, }, }, nil @@ -127,8 +150,8 @@ func (s *PreSignedSigner) Sign(req *x402.PaymentRequirement) (*x402.PaymentPaylo func (s *PreSignedSigner) GetPriority() int { return 0 } // GetTokens returns the single USDC token this signer handles. -func (s *PreSignedSigner) GetTokens() []x402.TokenConfig { - return []x402.TokenConfig{ +func (s *PreSignedSigner) GetTokens() []TokenConfig { + return []TokenConfig{ {Address: s.asset, Symbol: "USDC", Decimals: 6, Priority: 0}, } } @@ -151,3 +174,33 @@ func (s *PreSignedSigner) Spent() int { return s.spent } + +// normalizeNetworkID maps human-friendly chain names to CAIP-2 identifiers. +// Mirrors x402.NormalizeNetworkID — kept local to avoid an import cycle +// (x402 test files import buyer). +func normalizeNetworkID(network string) string { + switch strings.ToLower(strings.TrimSpace(network)) { + case "base", "base-mainnet": + return "eip155:8453" + case "base-sepolia": + return "eip155:84532" + case "ethereum", "ethereum-mainnet", "mainnet": + return "eip155:1" + case "sepolia": + return "eip155:11155111" + case "polygon", "polygon-mainnet": + return "eip155:137" + case "polygon-amoy": + return "eip155:80002" + case "avalanche", "avalanche-mainnet": + return "eip155:43114" + case "avalanche-fuji": + return "eip155:43113" + case "arbitrum", "arbitrum-one": + return "eip155:42161" + case "arbitrum-sepolia": + return "eip155:421614" + default: + return network + } +} diff --git a/internal/x402/buyer/signer_test.go b/internal/x402/buyer/signer_test.go index f27d9bf5..b8e9a5f8 100644 --- a/internal/x402/buyer/signer_test.go +++ b/internal/x402/buyer/signer_test.go @@ -4,7 +4,7 @@ import ( "sync" "testing" - x402 "github.com/mark3labs/x402-go" + x402types "github.com/coinbase/x402/go/types" ) func TestPreSignedSigner_CanSign(t *testing.T) { @@ -20,33 +20,33 @@ func TestPreSignedSigner_CanSign(t *testing.T) { tests := []struct { name string - req *x402.PaymentRequirement + req *x402types.PaymentRequirements want bool }{ { name: "matching requirement", - req: &x402.PaymentRequirement{ - Network: "base-sepolia", - PayTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - MaxAmountRequired: "1000", + req: &x402types.PaymentRequirements{ + Network: "eip155:84532", + PayTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + Amount: "1000", }, want: true, }, { name: "case-insensitive match", - req: &x402.PaymentRequirement{ - Network: "Base-Sepolia", - PayTo: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", - Asset: "0x036cbd53842c5426634e7929541ec2318f3dcf7e", - MaxAmountRequired: "1000", + req: &x402types.PaymentRequirements{ + Network: "eip155:84532", + PayTo: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", + Asset: "0x036cbd53842c5426634e7929541ec2318f3dcf7e", + Amount: "1000", }, want: true, }, { name: "wrong network", - req: &x402.PaymentRequirement{ - Network: "base", + req: &x402types.PaymentRequirements{ + Network: "eip155:8453", PayTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", }, @@ -54,8 +54,8 @@ func TestPreSignedSigner_CanSign(t *testing.T) { }, { name: "wrong payTo", - req: &x402.PaymentRequirement{ - Network: "base-sepolia", + req: &x402types.PaymentRequirements{ + Network: "eip155:84532", PayTo: "0xdeadbeef", Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", }, @@ -63,8 +63,8 @@ func TestPreSignedSigner_CanSign(t *testing.T) { }, { name: "wrong asset", - req: &x402.PaymentRequirement{ - Network: "base-sepolia", + req: &x402types.PaymentRequirements{ + Network: "eip155:84532", PayTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", Asset: "0xdeadbeef", }, @@ -72,11 +72,11 @@ func TestPreSignedSigner_CanSign(t *testing.T) { }, { name: "wrong amount", - req: &x402.PaymentRequirement{ - Network: "base-sepolia", - PayTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - MaxAmountRequired: "999", + req: &x402types.PaymentRequirements{ + Network: "eip155:84532", + PayTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + Amount: "999", }, want: false, }, @@ -112,11 +112,11 @@ func TestPreSignedSigner_Sign(t *testing.T) { nil, ) - req := &x402.PaymentRequirement{ - Network: "base-sepolia", - PayTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - MaxAmountRequired: "1000", + req := &x402types.PaymentRequirements{ + Network: "eip155:84532", + PayTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + Amount: "1000", } // First sign — should pop "0xaaa". @@ -125,14 +125,14 @@ func TestPreSignedSigner_Sign(t *testing.T) { t.Fatalf("first Sign: %v", err) } - payload1 := p1.Payload.(x402.EVMPayload) - if payload1.Signature != "0xaaa" { - t.Errorf("first signature = %q, want %q", payload1.Signature, "0xaaa") + payload1 := p1.Payload + if sig, _ := payload1["signature"].(string); sig != "0xaaa" { + t.Errorf("first signature = %q, want %q", sig, "0xaaa") } - if p1.X402Version != 1 || p1.Scheme != "exact" || p1.Network != "base-sepolia" { + if p1.X402Version != 2 || p1.Accepted.Scheme != "exact" || p1.Accepted.Network != "eip155:84532" { t.Errorf("unexpected payload fields: version=%d scheme=%s network=%s", - p1.X402Version, p1.Scheme, p1.Network) + p1.X402Version, p1.Accepted.Scheme, p1.Accepted.Network) } if signer.Remaining() != 1 { @@ -149,9 +149,9 @@ func TestPreSignedSigner_Sign(t *testing.T) { t.Fatalf("second Sign: %v", err) } - payload2 := p2.Payload.(x402.EVMPayload) - if payload2.Signature != "0xbbb" { - t.Errorf("second signature = %q, want %q", payload2.Signature, "0xbbb") + payload2 := p2.Payload + if sig, _ := payload2["signature"].(string); sig != "0xbbb" { + t.Errorf("second signature = %q, want %q", sig, "0xbbb") } // Third sign — pool exhausted. @@ -184,11 +184,11 @@ func TestPreSignedSigner_ConcurrentSign(t *testing.T) { nil, ) - req := &x402.PaymentRequirement{ - Network: "base-sepolia", - PayTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - MaxAmountRequired: "1000", + req := &x402types.PaymentRequirements{ + Network: "eip155:84532", + PayTo: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + Asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + Amount: "1000", } var wg sync.WaitGroup @@ -231,9 +231,9 @@ func TestPreSignedSigner_Interface(t *testing.T) { signer := NewPreSignedSigner("base-sepolia", "0xpayto", "0xasset", "1000", nil, 0, nil) // Verify interface compliance. - var _ x402.Signer = signer + var _ Signer = signer - if signer.Network() != "base-sepolia" { + if signer.Network() != "eip155:84532" { t.Errorf("Network() = %q", signer.Network()) } diff --git a/internal/x402/buyer/types.go b/internal/x402/buyer/types.go new file mode 100644 index 00000000..358c3610 --- /dev/null +++ b/internal/x402/buyer/types.go @@ -0,0 +1,116 @@ +package buyer + +import ( + "errors" + "fmt" + "math/big" + "time" + + x402types "github.com/coinbase/x402/go/types" +) + +// Signer produces x402 v2 payment payloads for a specific network and scheme. +// The buyer proxy holds an array of signers and selects the first one that can +// satisfy an incoming 402's requirements. +type Signer interface { + Network() string + Scheme() string + CanSign(req *x402types.PaymentRequirements) bool + Sign(req *x402types.PaymentRequirements) (*x402types.PaymentPayload, error) + GetPriority() int + GetTokens() []TokenConfig + GetMaxAmount() *big.Int +} + +// TokenConfig describes a token a signer can pay with. +type TokenConfig struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + Priority int `json:"priority"` +} + +// PaymentSelector picks a requirement and signs it. +type PaymentSelector interface { + SelectAndSign(requirements []x402types.PaymentRequirements, signers []Signer) (*x402types.PaymentPayload, error) +} + +// DefaultPaymentSelector iterates signers by priority and picks the first match. +type DefaultPaymentSelector struct{} + +// NewDefaultPaymentSelector returns a DefaultPaymentSelector. +func NewDefaultPaymentSelector() PaymentSelector { + return &DefaultPaymentSelector{} +} + +// SelectAndSign finds the first signer that can satisfy any requirement and signs it. +func (s *DefaultPaymentSelector) SelectAndSign(requirements []x402types.PaymentRequirements, signers []Signer) (*x402types.PaymentPayload, error) { + for _, req := range requirements { + for _, signer := range signers { + if signer.CanSign(&req) { + return signer.Sign(&req) + } + } + } + + return nil, ErrNoValidSigner +} + +// ErrNoValidSigner is returned when no signer in the pool can satisfy any requirement. +var ErrNoValidSigner = errors.New("no valid signer found for payment requirements") + +// PaymentError wraps an error with an x402-specific error code. +type PaymentError struct { + Code string + Message string + Err error +} + +func (e *PaymentError) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err) + } + return fmt.Sprintf("%s: %s", e.Code, e.Message) +} + +func (e *PaymentError) Unwrap() error { return e.Err } + +// NewPaymentError creates a PaymentError with the given code and message. +func NewPaymentError(code, msg string, err error) error { + return &PaymentError{Code: code, Message: msg, Err: err} +} + +// Error code constants. +const ( + ErrCodeInvalidRequirements = "invalid_requirements" + ErrCodeSigningFailed = "signing_failed" +) + +// PaymentEventType identifies the kind of payment event. +type PaymentEventType string + +const ( + PaymentEventAttempt PaymentEventType = "attempt" + PaymentEventSuccess PaymentEventType = "success" + PaymentEventFailure PaymentEventType = "failure" +) + +// PaymentEvent is emitted by the buyer transport for Prometheus instrumentation. +type PaymentEvent struct { + Type PaymentEventType + Timestamp time.Time + Duration time.Duration + Method string + URL string + Network string + Scheme string + Amount string + Asset string + Recipient string + Transaction string + Payer string + Error error +} + +// PaymentCallback receives payment lifecycle events. +type PaymentCallback func(PaymentEvent) diff --git a/internal/x402/chains.go b/internal/x402/chains.go new file mode 100644 index 00000000..e194f1fe --- /dev/null +++ b/internal/x402/chains.go @@ -0,0 +1,236 @@ +package x402 + +import ( + "fmt" + "math/big" + "strings" + + x402types "github.com/coinbase/x402/go/types" +) + +// ChainInfo holds chain-specific configuration for x402 payment gating. +// It maps a human-friendly chain name to the on-wire identifiers the +// facilitator expects (v1 network names, USDC contract address, EIP-3009 +// domain parameters). +type ChainInfo struct { + // Name is the human-friendly identifier used by the CLI (e.g., "base-sepolia"). + Name string + + // NetworkID is the v1 wire-format network name sent to the facilitator. + NetworkID string + + // CAIP2Network is the v2 wire-format network identifier. + CAIP2Network string + + // USDCAddress is the USDC token contract address on this chain. + USDCAddress string + + // Decimals is the token decimal precision (6 for USDC). + Decimals int + + // EIP3009Name is the EIP-712 domain name for TransferWithAuthorization. + EIP3009Name string + + // EIP3009Version is the EIP-712 domain version. + EIP3009Version string +} + +// Chain constants — USDC addresses verified against coinbase/x402/go v2.7.0 +// mechanisms/evm/constants.go and on-chain contract deployments. +var ( + ChainBaseMainnet = ChainInfo{ + Name: "base", + NetworkID: "base", + CAIP2Network: "eip155:8453", + USDCAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + Decimals: 6, + EIP3009Name: "USD Coin", + EIP3009Version: "2", + } + + ChainBaseSepolia = ChainInfo{ + Name: "base-sepolia", + NetworkID: "base-sepolia", + CAIP2Network: "eip155:84532", + USDCAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + Decimals: 6, + EIP3009Name: "USD Coin", + EIP3009Version: "2", + } + + ChainEthereumMainnet = ChainInfo{ + Name: "ethereum", + NetworkID: "ethereum", + CAIP2Network: "eip155:1", + USDCAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + Decimals: 6, + EIP3009Name: "USD Coin", + EIP3009Version: "2", + } + + ChainPolygonMainnet = ChainInfo{ + Name: "polygon", + NetworkID: "polygon", + CAIP2Network: "eip155:137", + USDCAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + Decimals: 6, + EIP3009Name: "USD Coin", + EIP3009Version: "2", + } + + ChainPolygonAmoy = ChainInfo{ + Name: "polygon-amoy", + NetworkID: "polygon-amoy", + CAIP2Network: "eip155:80002", + USDCAddress: "0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582", + Decimals: 6, + EIP3009Name: "USD Coin", + EIP3009Version: "2", + } + + ChainAvalancheMainnet = ChainInfo{ + Name: "avalanche", + NetworkID: "avalanche", + CAIP2Network: "eip155:43114", + USDCAddress: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", + Decimals: 6, + EIP3009Name: "USD Coin", + EIP3009Version: "2", + } + + ChainAvalancheFuji = ChainInfo{ + Name: "avalanche-fuji", + NetworkID: "avalanche-fuji", + CAIP2Network: "eip155:43113", + USDCAddress: "0x5425890298aed601595a70AB815c96711a31Bc65", + Decimals: 6, + EIP3009Name: "USD Coin", + EIP3009Version: "2", + } + + ChainArbitrumOne = ChainInfo{ + Name: "arbitrum-one", + NetworkID: "arbitrum-one", + CAIP2Network: "eip155:42161", + USDCAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + Decimals: 6, + EIP3009Name: "USD Coin", + EIP3009Version: "2", + } + + ChainArbitrumSepolia = ChainInfo{ + Name: "arbitrum-sepolia", + NetworkID: "arbitrum-sepolia", + CAIP2Network: "eip155:421614", + USDCAddress: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", + Decimals: 6, + EIP3009Name: "USD Coin", + EIP3009Version: "2", + } +) + +// NormalizeNetworkID maps a human-friendly chain name to its CAIP-2 network +// identifier. Already-normalized CAIP-2 values are returned as-is. +func NormalizeNetworkID(network string) string { + lower := strings.ToLower(strings.TrimSpace(network)) + switch lower { + case "base", "base-mainnet": + return ChainBaseMainnet.CAIP2Network + case "base-sepolia": + return ChainBaseSepolia.CAIP2Network + case "ethereum", "ethereum-mainnet", "mainnet": + return ChainEthereumMainnet.CAIP2Network + case "sepolia": + return "eip155:11155111" + case "polygon", "polygon-mainnet": + return ChainPolygonMainnet.CAIP2Network + case "polygon-amoy": + return ChainPolygonAmoy.CAIP2Network + case "avalanche", "avalanche-mainnet": + return ChainAvalancheMainnet.CAIP2Network + case "avalanche-fuji": + return ChainAvalancheFuji.CAIP2Network + case "arbitrum", "arbitrum-one": + return ChainArbitrumOne.CAIP2Network + case "arbitrum-sepolia": + return ChainArbitrumSepolia.CAIP2Network + default: + return network + } +} + +// ResolveChainInfo maps a human-friendly chain name to its ChainInfo. +// Phase 2 renames this to ResolveChain after deleting the old one in config.go. +func ResolveChainInfo(name string) (ChainInfo, error) { + switch name { + case "base", "base-mainnet": + return ChainBaseMainnet, nil + case "base-sepolia": + return ChainBaseSepolia, nil + case "ethereum", "ethereum-mainnet", "mainnet": + return ChainEthereumMainnet, nil + case "polygon", "polygon-mainnet": + return ChainPolygonMainnet, nil + case "polygon-amoy": + return ChainPolygonAmoy, nil + case "avalanche", "avalanche-mainnet": + return ChainAvalancheMainnet, nil + case "avalanche-fuji": + return ChainAvalancheFuji, nil + case "arbitrum-one", "arbitrum": + return ChainArbitrumOne, nil + case "arbitrum-sepolia": + return ChainArbitrumSepolia, nil + default: + return ChainInfo{}, fmt.Errorf( + "unsupported chain: %s (use: base, base-sepolia, ethereum, polygon, polygon-amoy, avalanche, avalanche-fuji, arbitrum-one, arbitrum-sepolia)", + name, + ) + } +} + +// decimalToAtomic converts a decimal token amount (e.g. "0.001") to atomic +// units using big.Float with 128-bit precision to avoid floating-point +// truncation (e.g. 0.001 * 1e6 must produce 1000, not 999). +func decimalToAtomic(amount string, decimals int) string { + amountFloat, _, _ := new(big.Float).SetPrec(128).Parse(amount, 10) + multiplier := new(big.Float).SetPrec(128).SetInt( + new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil), + ) + atomicFloat := new(big.Float).SetPrec(128).Mul(amountFloat, multiplier) + // Add 0.5 before truncating to int so we round to nearest. + atomicFloat.Add(atomicFloat, new(big.Float).SetPrec(128).SetFloat64(0.5)) + atomicInt, _ := atomicFloat.Int(nil) + return atomicInt.String() +} + +// BuildV1Requirement creates a v1 PaymentRequirementsV1 for USDC payment on +// the given chain. amount is the decimal USDC amount (e.g., "0.001" = $0.001). +func BuildV1Requirement(chain ChainInfo, amount, recipientAddress string) x402types.PaymentRequirementsV1 { + return x402types.PaymentRequirementsV1{ + Scheme: "exact", + Network: chain.NetworkID, + MaxAmountRequired: decimalToAtomic(amount, chain.Decimals), + Asset: chain.USDCAddress, + PayTo: recipientAddress, + MaxTimeoutSeconds: 60, + } +} + +// BuildV2Requirement creates a v2 PaymentRequirements for USDC payment on the +// given chain. amount is the decimal USDC amount (e.g. "0.001" = $0.001). +func BuildV2Requirement(chain ChainInfo, amount, recipientAddress string) x402types.PaymentRequirements { + return x402types.PaymentRequirements{ + Scheme: "exact", + Network: chain.CAIP2Network, + Amount: decimalToAtomic(amount, chain.Decimals), + Asset: chain.USDCAddress, + PayTo: recipientAddress, + MaxTimeoutSeconds: 60, + Extra: map[string]interface{}{ + "name": chain.EIP3009Name, + "version": chain.EIP3009Version, + "assetTransferMethod": "eip3009", + }, + } +} diff --git a/internal/x402/chains_test.go b/internal/x402/chains_test.go new file mode 100644 index 00000000..3e8d6c2e --- /dev/null +++ b/internal/x402/chains_test.go @@ -0,0 +1,82 @@ +package x402 + +import ( + "testing" +) + +func TestResolveChainInfo(t *testing.T) { + tests := []struct { + name string + want string // expected NetworkID + wantErr bool + }{ + {"base", "base", false}, + {"base-mainnet", "base", false}, + {"base-sepolia", "base-sepolia", false}, + {"ethereum", "ethereum", false}, + {"ethereum-mainnet", "ethereum", false}, + {"mainnet", "ethereum", false}, + {"polygon", "polygon", false}, + {"polygon-mainnet", "polygon", false}, + {"polygon-amoy", "polygon-amoy", false}, + {"avalanche", "avalanche", false}, + {"avalanche-mainnet", "avalanche", false}, + {"avalanche-fuji", "avalanche-fuji", false}, + {"arbitrum-one", "arbitrum-one", false}, + {"arbitrum", "arbitrum-one", false}, + {"arbitrum-sepolia", "arbitrum-sepolia", false}, + {"unknown-chain", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chain, err := ResolveChainInfo(tt.name) + if (err != nil) != tt.wantErr { + t.Fatalf("ResolveChainInfo(%q) error = %v, wantErr %v", tt.name, err, tt.wantErr) + } + if !tt.wantErr && chain.NetworkID != tt.want { + t.Errorf("ResolveChainInfo(%q).NetworkID = %q, want %q", tt.name, chain.NetworkID, tt.want) + } + }) + } +} + +func TestChainUSDCAddresses(t *testing.T) { + // Verify USDC addresses are non-empty and start with 0x. + chains := []ChainInfo{ + ChainBaseMainnet, ChainBaseSepolia, ChainEthereumMainnet, + ChainPolygonMainnet, ChainPolygonAmoy, + ChainAvalancheMainnet, ChainAvalancheFuji, + ChainArbitrumOne, ChainArbitrumSepolia, + } + + for _, c := range chains { + if c.USDCAddress == "" || c.USDCAddress[:2] != "0x" { + t.Errorf("chain %q: invalid USDC address %q", c.Name, c.USDCAddress) + } + if c.Decimals != 6 { + t.Errorf("chain %q: expected 6 decimals, got %d", c.Name, c.Decimals) + } + } +} + +func TestBuildV1Requirement(t *testing.T) { + req := BuildV1Requirement(ChainBaseSepolia, "0.001", "0xRecipient") + + if req.Scheme != "exact" { + t.Errorf("Scheme = %q, want %q", req.Scheme, "exact") + } + if req.Network != "base-sepolia" { + t.Errorf("Network = %q, want %q", req.Network, "base-sepolia") + } + // 0.001 USDC = 1000 atomic units (6 decimals) + if req.MaxAmountRequired != "1000" { + t.Errorf("MaxAmountRequired = %q, want %q", req.MaxAmountRequired, "1000") + } + if req.Asset != ChainBaseSepolia.USDCAddress { + t.Errorf("Asset = %q, want %q", req.Asset, ChainBaseSepolia.USDCAddress) + } + if req.PayTo != "0xRecipient" { + t.Errorf("PayTo = %q, want %q", req.PayTo, "0xRecipient") + } +} diff --git a/internal/x402/config.go b/internal/x402/config.go index 9df62628..1be220a5 100644 --- a/internal/x402/config.go +++ b/internal/x402/config.go @@ -5,7 +5,6 @@ import ( "net/url" "os" - x402lib "github.com/mark3labs/x402-go" "gopkg.in/yaml.v3" ) @@ -91,7 +90,7 @@ func LoadConfig(path string) (*PricingConfig, error) { // Apply defaults. if cfg.FacilitatorURL == "" { - cfg.FacilitatorURL = "https://facilitator.x402.rs" + cfg.FacilitatorURL = DefaultFacilitatorURL } if cfg.Chain == "" { @@ -133,34 +132,3 @@ func ValidateFacilitatorURL(u string) error { return fmt.Errorf("facilitator URL must use HTTPS (except localhost): %q", u) } - -// EthereumMainnet is the x402 ChainConfig for Ethereum mainnet USDC. -var EthereumMainnet = x402lib.ChainConfig{ - NetworkID: "ethereum", - USDCAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - Decimals: 6, - EIP3009Name: "USD Coin", - EIP3009Version: "2", -} - -// ResolveChain maps a chain name string to an x402 ChainConfig. -func ResolveChain(name string) (x402lib.ChainConfig, error) { - switch name { - case "base", "base-mainnet": - return x402lib.BaseMainnet, nil - case "base-sepolia": - return x402lib.BaseSepolia, nil - case "ethereum", "ethereum-mainnet", "mainnet": - return EthereumMainnet, nil - case "polygon", "polygon-mainnet": - return x402lib.PolygonMainnet, nil - case "polygon-amoy": - return x402lib.PolygonAmoy, nil - case "avalanche", "avalanche-mainnet": - return x402lib.AvalancheMainnet, nil - case "avalanche-fuji": - return x402lib.AvalancheFuji, nil - default: - return x402lib.ChainConfig{}, fmt.Errorf("unsupported chain: %s (use: base, base-sepolia, ethereum, polygon, polygon-amoy, avalanche, avalanche-fuji)", name) - } -} diff --git a/internal/x402/config_test.go b/internal/x402/config_test.go index 8775f544..8c98bee4 100644 --- a/internal/x402/config_test.go +++ b/internal/x402/config_test.go @@ -4,8 +4,6 @@ import ( "os" "path/filepath" "testing" - - x402lib "github.com/mark3labs/x402-go" ) func TestLoadConfig_ValidYAML(t *testing.T) { @@ -89,8 +87,8 @@ routes: t.Errorf("default chain = %q, want base-sepolia", cfg.Chain) } - if cfg.FacilitatorURL != "https://facilitator.x402.rs" { - t.Errorf("default facilitatorURL = %q, want https://facilitator.x402.rs", cfg.FacilitatorURL) + if cfg.FacilitatorURL != "https://x402.gcp.obol.tech" { + t.Errorf("default facilitatorURL = %q, want https://x402.gcp.obol.tech", cfg.FacilitatorURL) } } @@ -118,26 +116,26 @@ func TestLoadConfig_FileNotFound(t *testing.T) { func TestResolveChain_AllSupported(t *testing.T) { tests := []struct { name string - expected x402lib.ChainConfig + expected ChainInfo }{ - {"base-sepolia", x402lib.BaseSepolia}, - {"base", x402lib.BaseMainnet}, - {"ethereum", EthereumMainnet}, - {"polygon", x402lib.PolygonMainnet}, - {"polygon-amoy", x402lib.PolygonAmoy}, - {"avalanche", x402lib.AvalancheMainnet}, - {"avalanche-fuji", x402lib.AvalancheFuji}, + {"base-sepolia", ChainBaseSepolia}, + {"base", ChainBaseMainnet}, + {"ethereum", ChainEthereumMainnet}, + {"polygon", ChainPolygonMainnet}, + {"polygon-amoy", ChainPolygonAmoy}, + {"avalanche", ChainAvalancheMainnet}, + {"avalanche-fuji", ChainAvalancheFuji}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ResolveChain(tt.name) + got, err := ResolveChainInfo(tt.name) if err != nil { - t.Fatalf("ResolveChain(%q): %v", tt.name, err) + t.Fatalf("ResolveChainInfo(%q): %v", tt.name, err) } if got.NetworkID != tt.expected.NetworkID { - t.Errorf("ResolveChain(%q).NetworkID = %q, want %q", tt.name, got.NetworkID, tt.expected.NetworkID) + t.Errorf("ResolveChainInfo(%q).NetworkID = %q, want %q", tt.name, got.NetworkID, tt.expected.NetworkID) } }) } @@ -157,14 +155,14 @@ func TestResolveChain_Aliases(t *testing.T) { for _, tt := range tests { t.Run(tt.alias+"=="+tt.canonical, func(t *testing.T) { - aliasResult, err := ResolveChain(tt.alias) + aliasResult, err := ResolveChainInfo(tt.alias) if err != nil { - t.Fatalf("ResolveChain(%q): %v", tt.alias, err) + t.Fatalf("ResolveChainInfo(%q): %v", tt.alias, err) } - canonResult, err := ResolveChain(tt.canonical) + canonResult, err := ResolveChainInfo(tt.canonical) if err != nil { - t.Fatalf("ResolveChain(%q): %v", tt.canonical, err) + t.Fatalf("ResolveChainInfo(%q): %v", tt.canonical, err) } if aliasResult.NetworkID != canonResult.NetworkID { @@ -179,7 +177,7 @@ func TestResolveChain_Unsupported(t *testing.T) { unsupported := []string{"solana", "unknown-chain", ""} for _, name := range unsupported { t.Run(name, func(t *testing.T) { - _, err := ResolveChain(name) + _, err := ResolveChainInfo(name) if err == nil { t.Errorf("expected error for unsupported chain %q", name) } @@ -194,7 +192,7 @@ func TestValidateFacilitatorURL(t *testing.T) { wantErr bool }{ // HTTPS always allowed. - {"https standard", "https://facilitator.x402.rs", false}, + {"https standard", "https://x402.gcp.obol.tech", false}, {"https custom", "https://my-facilitator.example.com:8443/verify", false}, // Loopback/internal addresses allowed over HTTP. diff --git a/internal/x402/features/integration_payment_flow.feature b/internal/x402/features/integration_payment_flow.feature index 9096a8d2..c981bdbf 100644 --- a/internal/x402/features/integration_payment_flow.feature +++ b/internal/x402/features/integration_payment_flow.feature @@ -37,7 +37,7 @@ Feature: x402 Payment Flow — Real User Journey Scenario: Unpaid request returns 402 with pricing When the buyer sends an unpaid POST to the priced route Then the response status is 402 - And the response body contains x402Version 1 + And the response body contains x402Version 2 And the response body contains a valid accepts array # ─── Buy-side: paid request returns real inference ────────────────── diff --git a/internal/x402/forwardauth.go b/internal/x402/forwardauth.go new file mode 100644 index 00000000..1fe7a64f --- /dev/null +++ b/internal/x402/forwardauth.go @@ -0,0 +1,340 @@ +package x402 + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "time" + + x402types "github.com/coinbase/x402/go/types" +) + +// ForwardAuthConfig configures the ForwardAuth x402 middleware. +type ForwardAuthConfig struct { + // FacilitatorURL is the x402 facilitator service URL (e.g., "https://x402.org/facilitator"). + FacilitatorURL string + + // VerifyOnly skips blockchain settlement when true. Used by the Traefik + // ForwardAuth verifier where only payment verification is needed. + VerifyOnly bool +} + +// facilitatorVerifyRequest is the JSON body sent to POST /verify and /settle. +type facilitatorVerifyRequest struct { + X402Version int `json:"x402Version"` + PaymentPayload json.RawMessage `json:"paymentPayload"` + PaymentRequirements x402types.PaymentRequirements `json:"paymentRequirements"` +} + +// facilitatorVerifyResponse is the JSON response from POST /verify. +type facilitatorVerifyResponse struct { + IsValid bool `json:"isValid"` + InvalidReason string `json:"invalidReason,omitempty"` + InvalidMessage string `json:"invalidMessage,omitempty"` + Payer string `json:"payer,omitempty"` +} + +// facilitatorSettleResponse is the JSON response from POST /settle. +type facilitatorSettleResponse struct { + Success bool `json:"success"` + ErrorReason string `json:"errorReason,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + Transaction string `json:"transaction"` + Network string `json:"network"` + Payer string `json:"payer,omitempty"` +} + +// 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. +// +// When VerifyOnly is true (Traefik ForwardAuth path), settlement is skipped. +// When VerifyOnly is false (standalone gateway path), settlement runs only +// after the inner handler returns a success status (< 400). +func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.PaymentRequirements) func(http.Handler) http.Handler { + client := &http.Client{Timeout: 30 * time.Second} + + 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) + return + } + + // Decode the base64-encoded payment payload. + 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) + 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) + http.Error(w, "Invalid payment header", http.StatusBadRequest) + return + } + + matchedReq, found := findMatchingRequirementV1(payload, requirements) + if !found { + sendPaymentRequired(w, r, requirements) + return + } + + // Verify with facilitator. + verifyResp, err := facilitatorVerify(r.Context(), client, cfg.FacilitatorURL, payloadBytes, matchedReq) + if err != nil { + log.Printf("x402: facilitator verify error: %v", err) + http.Error(w, "Payment verification failed", http.StatusServiceUnavailable) + return + } + + if !verifyResp.IsValid { + log.Printf("x402: payment invalid: %s", verifyResp.InvalidReason) + sendPaymentRequired(w, r, requirements) + return + } + + // Payment verified — wrap with settlement interceptor. + interceptor := &settlementInterceptor{ + w: w, + settleFunc: func() bool { + if cfg.VerifyOnly { + return true + } + + settleResp, err := facilitatorSettle(r.Context(), client, cfg.FacilitatorURL, payloadBytes, matchedReq) + if err != nil { + log.Printf("x402: settlement failed: %v", err) + http.Error(w, "Payment settlement failed", http.StatusServiceUnavailable) + return false + } + + if !settleResp.Success { + log.Printf("x402: settlement unsuccessful: %s", settleResp.ErrorReason) + sendPaymentRequired(w, r, requirements) + return false + } + + // Encode settlement response as X-PAYMENT-RESPONSE header. + settleJSON, _ := json.Marshal(settleResp) + w.Header().Set("X-PAYMENT-RESPONSE", base64.StdEncoding.EncodeToString(settleJSON)) + return true + }, + onFailure: func(statusCode int) { + log.Printf("x402: handler returned %d, skipping settlement", statusCode) + }, + } + + next.ServeHTTP(interceptor, r) + }) + } +} + +// sendPaymentRequired writes a 402 response with v2 payment requirements. +func sendPaymentRequired(w http.ResponseWriter, r *http.Request, requirements []x402types.PaymentRequirements) { + resource := &x402types.ResourceInfo{ + URL: buildResourceURL(r), + Description: "Payment required for " + r.URL.Path, + } + resp := x402types.PaymentRequired{ + X402Version: 2, + Error: "Payment required for this resource", + Resource: resource, + Accepts: requirements, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusPaymentRequired) + _ = json.NewEncoder(w).Encode(resp) +} + +// findMatchingRequirementV1 finds the first requirement matching the payment's +// scheme and network. +func findMatchingRequirementV1(payment x402types.PaymentPayload, requirements []x402types.PaymentRequirements) (x402types.PaymentRequirements, bool) { + for _, req := range requirements { + if req.Scheme == payment.Accepted.Scheme && + req.Network == payment.Accepted.Network && + req.Amount == payment.Accepted.Amount && + req.Asset == payment.Accepted.Asset && + req.PayTo == payment.Accepted.PayTo { + return req, true + } + } + return x402types.PaymentRequirements{}, false +} + +// facilitatorVerify calls POST /verify on the facilitator. +func facilitatorVerify(ctx context.Context, client *http.Client, facilitatorURL string, payloadBytes []byte, requirement x402types.PaymentRequirements) (*facilitatorVerifyResponse, error) { + body := facilitatorVerifyRequest{ + X402Version: 2, + PaymentPayload: json.RawMessage(payloadBytes), + PaymentRequirements: requirement, + } + + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal verify request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", facilitatorURL+"/verify", bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("create verify request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("facilitator verify: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read verify response: %w", err) + } + + var verifyResp facilitatorVerifyResponse + if err := json.Unmarshal(respBody, &verifyResp); err != nil { + return nil, fmt.Errorf("facilitator verify (%d): %s", resp.StatusCode, string(respBody)) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("facilitator verify failed (%d): %s", resp.StatusCode, verifyResp.InvalidReason) + } + + return &verifyResp, nil +} + +// facilitatorSettle calls POST /settle on the facilitator. +func facilitatorSettle(ctx context.Context, client *http.Client, facilitatorURL string, payloadBytes []byte, requirement x402types.PaymentRequirements) (*facilitatorSettleResponse, error) { + body := facilitatorVerifyRequest{ + X402Version: 2, + PaymentPayload: json.RawMessage(payloadBytes), + PaymentRequirements: requirement, + } + + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal settle request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", facilitatorURL+"/settle", bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("create settle request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("facilitator settle: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read settle response: %w", err) + } + + var settleResp facilitatorSettleResponse + if err := json.Unmarshal(respBody, &settleResp); err != nil { + return nil, fmt.Errorf("facilitator settle (%d): %s", resp.StatusCode, string(respBody)) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("facilitator settle failed (%d): %s", resp.StatusCode, settleResp.ErrorReason) + } + + return &settleResp, nil +} + +func buildResourceURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + host := r.Host + if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" { + host = forwardedHost + } + uri := r.RequestURI + if forwardedURI := r.Header.Get("X-Forwarded-Uri"); forwardedURI != "" { + uri = forwardedURI + } + return scheme + "://" + host + uri +} + +// settlementInterceptor wraps a ResponseWriter to intercept the status code. +// Settlement runs only when the inner handler succeeds (status < 400). +// Faithfully ported from mark3labs/x402-go/http/middleware.go. +type settlementInterceptor struct { + w http.ResponseWriter + settleFunc func() bool + onFailure func(statusCode int) + committed bool + hijacked bool +} + +func (i *settlementInterceptor) Header() http.Header { + return i.w.Header() +} + +func (i *settlementInterceptor) Write(b []byte) (int, error) { + if !i.committed { + i.WriteHeader(http.StatusOK) + } + + if i.hijacked { + return len(b), nil + } + + return i.w.Write(b) +} + +func (i *settlementInterceptor) WriteHeader(statusCode int) { + if i.committed { + return + } + i.committed = true + + // Handler error — pass through, no settlement. + if statusCode >= 400 { + if i.onFailure != nil { + i.onFailure(statusCode) + } + i.w.WriteHeader(statusCode) + return + } + + // Handler success — settle before writing status. + if !i.settleFunc() { + i.hijacked = true + return + } + + i.w.WriteHeader(statusCode) +} + +func (i *settlementInterceptor) Flush() { + if flusher, ok := i.w.(http.Flusher); ok { + flusher.Flush() + } +} + +func (i *settlementInterceptor) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hijacker, ok := i.w.(http.Hijacker); ok { + return hijacker.Hijack() + } + return nil, nil, fmt.Errorf("hijacking not supported") +} diff --git a/internal/x402/forwardauth_test.go b/internal/x402/forwardauth_test.go new file mode 100644 index 00000000..42b1c959 --- /dev/null +++ b/internal/x402/forwardauth_test.go @@ -0,0 +1,307 @@ +package x402 + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + x402types "github.com/coinbase/x402/go/types" +) + +// mockFacilitator returns an httptest.Server that accepts /verify and /settle. +// verifyValid controls whether /verify returns isValid=true. +// settleOK controls whether /settle returns success=true. +// verifyCalled/settleCalled are incremented on each call. +func mockFacilitatorV1(verifyValid, settleOK bool, verifyCalled, settleCalled *atomic.Int32) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/verify": + verifyCalled.Add(1) + resp := facilitatorVerifyResponse{ + IsValid: verifyValid, + Payer: "0xPayer", + } + if !verifyValid { + resp.InvalidReason = "test_invalid" + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + + case "/settle": + settleCalled.Add(1) + resp := facilitatorSettleResponse{ + Success: settleOK, + Transaction: "0xTxHash", + Network: "base-sepolia", + Payer: "0xPayer", + } + if !settleOK { + resp.ErrorReason = "test_settle_fail" + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + + default: + http.Error(w, "not found", http.StatusNotFound) + } + })) +} + +func validPaymentHeader() string { + payload := x402types.PaymentPayload{ + X402Version: 2, + Accepted: x402types.PaymentRequirements{ + Scheme: "exact", + Network: "eip155:84532", + Amount: "1000", + Asset: ChainBaseSepolia.USDCAddress, + PayTo: "0xWallet", + }, + Payload: map[string]interface{}{ + "signature": "0xSig", + "authorization": map[string]interface{}{ + "from": "0xFrom", "to": "0xTo", "value": "1000", + "validAfter": "0", "validBefore": "9999999999", "nonce": "0xNonce", + }, + }, + } + b, _ := json.Marshal(payload) + return base64.StdEncoding.EncodeToString(b) +} + +func testRequirements() []x402types.PaymentRequirements { + return []x402types.PaymentRequirements{ + BuildV2Requirement(ChainBaseSepolia, "0.001", "0xWallet"), + } +} + +func TestForwardAuth_NoPayment_Returns402(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") + }) + + req := httptest.NewRequest("POST", "/v1/chat/completions", nil) + rec := httptest.NewRecorder() + mw(inner).ServeHTTP(rec, req) + + if rec.Code != http.StatusPaymentRequired { + t.Errorf("status = %d, want %d", rec.Code, http.StatusPaymentRequired) + } + + // Verify 402 body contains accepts array. + var body x402types.PaymentRequired + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode 402 body: %v", err) + } + if len(body.Accepts) == 0 { + t.Error("402 body has no accepts") + } + if verifyCalled.Load() != 0 { + t.Error("facilitator.Verify should not be called when no payment header") + } +} + +func TestForwardAuth_ValidPayment_VerifyOnly(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("X-PAYMENT", 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 !innerCalled { + t.Error("inner handler was not called") + } + if verifyCalled.Load() != 1 { + t.Errorf("verify called %d times, want 1", verifyCalled.Load()) + } + if settleCalled.Load() != 0 { + t.Errorf("settle called %d times, want 0 (VerifyOnly=true)", settleCalled.Load()) + } +} + +func TestForwardAuth_InvalidPayment_Returns402(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.Errorf("status = %d, want %d", rec.Code, http.StatusPaymentRequired) + } + if verifyCalled.Load() != 1 { + t.Errorf("verify called %d times, want 1", verifyCalled.Load()) + } +} + +func TestForwardAuth_SettleOnSuccess(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("X-PAYMENT", 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 verifyCalled.Load() != 1 { + t.Errorf("verify called %d times, want 1", verifyCalled.Load()) + } + if settleCalled.Load() != 1 { + t.Errorf("settle called %d times, want 1", settleCalled.Load()) + } + + // Check X-PAYMENT-RESPONSE header is set. + if rec.Header().Get("X-PAYMENT-RESPONSE") == "" { + t.Error("X-PAYMENT-RESPONSE header not set after settlement") + } + + // Check response body passes through. + body, _ := io.ReadAll(rec.Body) + if string(body) != `{"result":"ok"}` { + t.Errorf("body = %q, want %q", string(body), `{"result":"ok"}`) + } +} + +func TestForwardAuth_NoSettleOnHandlerError(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) { + http.Error(w, "upstream error", http.StatusInternalServerError) + }) + + 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.StatusInternalServerError { + t.Errorf("status = %d, want %d", rec.Code, http.StatusInternalServerError) + } + if settleCalled.Load() != 0 { + t.Errorf("settle called %d times, want 0 (handler failed)", settleCalled.Load()) + } +} + +func TestForwardAuth_UpstreamAuthPropagation(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) { + // Simulate what the verifier does: set Authorization for upstream. + w.Header().Set("Authorization", "Bearer sk-litellm-key") + w.WriteHeader(http.StatusOK) + }) + + 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.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + + // The Authorization header set by the inner handler should be in the response + // (Traefik copies authResponseHeaders to the forwarded request). + if got := rec.Header().Get("Authorization"); got != "Bearer sk-litellm-key" { + t.Errorf("Authorization header = %q, want %q", got, "Bearer sk-litellm-key") + } +} + +func TestForwardAuth_NoUpstreamAuth(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) { + w.WriteHeader(http.StatusOK) + }) + + 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.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + + // No Authorization header should be set when inner handler doesn't set one. + if got := rec.Header().Get("Authorization"); got != "" { + t.Errorf("Authorization header = %q, want empty", got) + } +} diff --git a/internal/x402/setup.go b/internal/x402/setup.go index 3f377b0d..7c869da4 100644 --- a/internal/x402/setup.go +++ b/internal/x402/setup.go @@ -16,6 +16,10 @@ const ( x402Namespace = "x402" pricingConfigMap = "x402-pricing" x402SecretName = "x402-secrets" + + // DefaultFacilitatorURL is the Obol-operated x402 facilitator for payment + // verification and settlement. Supports Base Mainnet and Base Sepolia. + DefaultFacilitatorURL = "https://x402.gcp.obol.tech" ) var x402Manifest = mustReadX402Manifest() @@ -42,7 +46,7 @@ func EnsureVerifier(cfg *config.Config) error { // Setup configures x402 pricing in the cluster by patching the ConfigMap // and Secret. Stakater Reloader auto-restarts the verifier pod. -// If facilitatorURL is empty, the default (https://facilitator.x402.rs) is used. +// If facilitatorURL is empty, the Obol-operated facilitator is used. func Setup(cfg *config.Config, wallet, chain, facilitatorURL string) error { if err := ValidateWallet(wallet); err != nil { return err @@ -52,6 +56,10 @@ func Setup(cfg *config.Config, wallet, chain, facilitatorURL string) error { } bin, kc := kubectl.Paths(cfg) + // Populate the CA certificates bundle from the host so the distroless + // verifier image can TLS-verify the facilitator. + populateCABundle(bin, kc) + // 1. Patch the Secret with the wallet address. fmt.Printf("Configuring x402: setting wallet address...\n") secretPatch := map[string]any{"stringData": map[string]string{"WALLET_ADDRESS": wallet}} @@ -69,7 +77,7 @@ func Setup(cfg *config.Config, wallet, chain, facilitatorURL string) error { // static/manual routes. fmt.Printf("Updating x402 pricing config...\n") if facilitatorURL == "" { - facilitatorURL = "https://facilitator.x402.rs" + facilitatorURL = DefaultFacilitatorURL } existingCfg, _ := GetPricingConfig(cfg) var existingRoutes []RouteRule @@ -131,6 +139,50 @@ func GetPricingConfig(cfg *config.Config) (*PricingConfig, error) { return pcfg, nil } +// populateCABundle reads the host's CA certificate bundle and replaces +// the ca-certificates ConfigMap in the x402 namespace. The x402-verifier +// image is distroless and ships without a CA store, so TLS verification +// of external facilitators fails without this. +// +// Uses "kubectl create --dry-run | kubectl replace" instead of "kubectl +// apply" because the macOS CA bundle (~290KB) exceeds the 262KB +// annotation limit that kubectl apply requires. +func populateCABundle(bin, kc string) { + // Common CA bundle paths across Linux distros and macOS. + candidates := []string{ + "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu + "/etc/pki/tls/certs/ca-bundle.crt", // RHEL/Fedora + "/etc/ssl/cert.pem", // macOS / Alpine + } + var caPath string + for _, path := range candidates { + if info, err := os.Stat(path); err == nil && info.Size() > 0 { + caPath = path + break + } + } + if caPath == "" { + return // no CA bundle found — skip silently + } + + // Pipe through kubectl create --dry-run to generate the ConfigMap YAML, + // then kubectl replace to apply it without the annotation size limit. + if err := kubectl.PipeCommands(bin, kc, + []string{"create", "configmap", "ca-certificates", "-n", x402Namespace, + "--from-file=ca-certificates.crt=" + caPath, + "--dry-run=client", "-o", "yaml"}, + []string{"replace", "-f", "-"}); err != nil { + return + } + + // Restart the verifier so it picks up the newly populated CA bundle. + // The ConfigMap is mounted as a volume; Kubernetes may take 60-120s to + // propagate changes, and we need TLS to work immediately for the + // facilitator connection. + _ = kubectl.RunSilent(bin, kc, + "rollout", "restart", "deployment/x402-verifier", "-n", x402Namespace) +} + func patchPricingConfig(bin, kc string, pcfg *PricingConfig) error { pricingBytes, err := yaml.Marshal(pcfg) if err != nil { diff --git a/internal/x402/setup_test.go b/internal/x402/setup_test.go index 1e7722d8..ff8b7652 100644 --- a/internal/x402/setup_test.go +++ b/internal/x402/setup_test.go @@ -103,7 +103,7 @@ func TestPricingConfig_YAMLRoundTrip(t *testing.T) { original := PricingConfig{ Wallet: "0xGLOBALGLOBALGLOBALGLOBALGLOBALGLOBALGL", Chain: "base-sepolia", - FacilitatorURL: "https://facilitator.x402.rs", + FacilitatorURL: "https://x402.gcp.obol.tech", VerifyOnly: true, Routes: []RouteRule{ { @@ -188,7 +188,7 @@ func TestPricingConfig_YAMLWithPerRouteOverrides(t *testing.T) { pcfg := PricingConfig{ Wallet: "0xGLOBALGLOBALGLOBALGLOBALGLOBALGLOBALGL", Chain: "base-sepolia", - FacilitatorURL: "https://facilitator.x402.rs", + FacilitatorURL: "https://x402.gcp.obol.tech", Routes: []RouteRule{ { Pattern: "/inference-llama/v1/*", diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index ed4a3f57..981970dd 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -6,8 +6,7 @@ import ( "net/http" "sync/atomic" - x402lib "github.com/mark3labs/x402-go" - x402http "github.com/mark3labs/x402-go/http" + x402types "github.com/coinbase/x402/go/types" "github.com/prometheus/client_golang/prometheus" ) @@ -16,8 +15,8 @@ import ( // to /verify; the Verifier either returns 200 (allow) or 402 (pay-wall). type Verifier struct { config atomic.Pointer[PricingConfig] - chain atomic.Pointer[x402lib.ChainConfig] - chains atomic.Pointer[map[string]x402lib.ChainConfig] // pre-resolved: chain name → config + chain atomic.Pointer[ChainInfo] + chains atomic.Pointer[map[string]ChainInfo] // pre-resolved: chain name → config metrics *verifierMetrics } @@ -37,18 +36,18 @@ func (v *Verifier) Reload(cfg *PricingConfig) error { } func (v *Verifier) load(cfg *PricingConfig) error { - chain, err := ResolveChain(cfg.Chain) + chain, err := ResolveChainInfo(cfg.Chain) if err != nil { return fmt.Errorf("resolve chain: %w", err) } // Pre-resolve all unique chain names (global + per-route overrides) // so HandleVerify avoids per-request chain resolution. - chains := map[string]x402lib.ChainConfig{cfg.Chain: chain} + chains := map[string]ChainInfo{cfg.Chain: chain} for _, r := range cfg.Routes { if r.Network != "" { if _, ok := chains[r.Network]; !ok { - rc, err := ResolveChain(r.Network) + rc, err := ResolveChainInfo(r.Network) if err != nil { return fmt.Errorf("resolve chain for route %q: %w", r.Pattern, err) } @@ -115,20 +114,10 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) { return } - requirement, err := x402lib.NewUSDCPaymentRequirement(x402lib.USDCRequirementConfig{ - Chain: chain, - Amount: rule.Price, - RecipientAddress: wallet, - }) - if err != nil { - log.Printf("x402-verifier: failed to create payment requirement: %v", err) - http.Error(w, "internal error", http.StatusInternalServerError) + requirement := BuildV2Requirement(chain, rule.Price, wallet) - return - } - - // Reconstruct the original request context so x402-go generates correct - // payment requirements (resource URL, host, etc.). + // Reconstruct the original request context so the middleware generates + // correct payment requirements (resource URL, host, etc.). if host := r.Header.Get("X-Forwarded-Host"); host != "" { r.Host = host } @@ -140,18 +129,17 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) { r.Method = method } - // Reuse x402-go's middleware wrapping a handler that returns 200. + // Use the local ForwardAuth middleware wrapping a handler that returns 200. // When the inner handler runs (payment approved), it sets the Authorization // header if the route has upstreamAuth configured. Traefik's authResponseHeaders // copies this to the forwarded request, authenticating it with the upstream. labels := prometheusLabels(rule) v.metrics.requestsTotal.With(labels).Inc() - middleware := x402http.NewX402Middleware(&x402http.Config{ - FacilitatorURL: cfg.FacilitatorURL, - PaymentRequirements: []x402lib.PaymentRequirement{requirement}, - VerifyOnly: cfg.VerifyOnly, - }) + middleware := NewForwardAuthMiddleware(ForwardAuthConfig{ + FacilitatorURL: cfg.FacilitatorURL, + VerifyOnly: cfg.VerifyOnly, + }, []x402types.PaymentRequirements{requirement}) upstreamAuth := rule.UpstreamAuth inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/x402/verifier_test.go b/internal/x402/verifier_test.go index 383e2a98..2792ad26 100644 --- a/internal/x402/verifier_test.go +++ b/internal/x402/verifier_test.go @@ -11,7 +11,7 @@ import ( "sync/atomic" "testing" - x402lib "github.com/mark3labs/x402-go" + x402types "github.com/coinbase/x402/go/types" dto "github.com/prometheus/client_model/go" "github.com/prometheus/common/expfmt" ) @@ -36,7 +36,7 @@ func newMockFacilitator(t *testing.T, opts mockFacilitatorOpts) *mockFacilitator mux.HandleFunc("/supported", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"kinds":[{"x402Version":1,"scheme":"exact","network":"base-sepolia"}]}`) + fmt.Fprintf(w, `{"kinds":[{"x402Version":2,"scheme":"exact","network":"eip155:84532"}]}`) }) mux.HandleFunc("/verify", func(w http.ResponseWriter, r *http.Request) { @@ -62,17 +62,29 @@ func newMockFacilitator(t *testing.T, opts mockFacilitatorOpts) *mockFacilitator // testPaymentHeader returns a base64-encoded x402 PaymentPayload for BaseSepolia. func testPaymentHeader(t *testing.T) string { + return testPaymentHeaderFor(t, + "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "100", + ) +} + +func testPaymentHeaderFor(t *testing.T, payTo, amount string) string { t.Helper() - p := x402lib.PaymentPayload{ - X402Version: 1, - Scheme: "exact", - Network: x402lib.BaseSepolia.NetworkID, + p := x402types.PaymentPayload{ + X402Version: 2, + Accepted: x402types.PaymentRequirements{ + Scheme: "exact", + Network: ChainBaseSepolia.CAIP2Network, + Amount: amount, + Asset: ChainBaseSepolia.USDCAddress, + PayTo: payTo, + }, Payload: map[string]any{ "signature": "0xmocksignature", "authorization": map[string]any{ "from": "0x1234567890123456789012345678901234567890", - "to": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", - "value": "1000", + "to": payTo, + "value": amount, "validAfter": "0", "validBefore": "9999999999", "nonce": "0xabcdef", @@ -371,10 +383,10 @@ func TestVerifier_ReadyzNotReady(t *testing.T) { // parse402Accepts is a test helper that decodes a 402 response body and returns // the first PaymentRequirement from the "accepts" array. -func parse402Accepts(t *testing.T, body []byte) x402lib.PaymentRequirement { +func parse402Accepts(t *testing.T, body []byte) x402types.PaymentRequirements { t.Helper() var resp struct { - Accepts []x402lib.PaymentRequirement `json:"accepts"` + Accepts []x402types.PaymentRequirements `json:"accepts"` } if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("failed to decode 402 body: %v\nbody: %s", err, string(body)) @@ -457,10 +469,10 @@ func TestVerifier_PerRouteNetwork_ResolvesCorrectChain(t *testing.T) { pr := parse402Accepts(t, body) // BaseMainnet.NetworkID is "base"; BaseSepolia.NetworkID is "base-sepolia". - if pr.Network != x402lib.BaseMainnet.NetworkID { - t.Errorf("network = %q, want %q (base mainnet)", pr.Network, x402lib.BaseMainnet.NetworkID) + if pr.Network != ChainBaseMainnet.CAIP2Network { + t.Errorf("network = %q, want %q (base mainnet)", pr.Network, ChainBaseMainnet.CAIP2Network) } - if pr.Network == x402lib.BaseSepolia.NetworkID { + if pr.Network == ChainBaseSepolia.CAIP2Network { t.Error("network should NOT be base-sepolia — per-route override was ignored") } } @@ -487,7 +499,7 @@ func TestVerifier_PerRoutePayTo_WithValidPayment(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/verify", nil) req.Header.Set("X-Forwarded-Uri", "/services/test/foo") req.Header.Set("X-Forwarded-Host", "obol.stack") - req.Header.Set("X-PAYMENT", testPaymentHeader(t)) + req.Header.Set("X-PAYMENT", testPaymentHeaderFor(t, routeWallet, "1000")) w := httptest.NewRecorder() v.HandleVerify(w, req) diff --git a/internal/x402/watcher_test.go b/internal/x402/watcher_test.go index 3e966bd9..e0de14e1 100644 --- a/internal/x402/watcher_test.go +++ b/internal/x402/watcher_test.go @@ -10,7 +10,7 @@ import ( const validWatcherYAML = `wallet: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" chain: "base-sepolia" -facilitatorURL: "https://facilitator.x402.rs" +facilitatorURL: "https://x402.gcp.obol.tech" routes: - pattern: "/rpc/*" price: "0.0001" @@ -32,7 +32,7 @@ func TestWatchConfig_DetectsChange(t *testing.T) { v, err := NewVerifier(&PricingConfig{ Wallet: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", Chain: "base-sepolia", - FacilitatorURL: "https://facilitator.x402.rs", + FacilitatorURL: "https://x402.gcp.obol.tech", Routes: []RouteRule{{Pattern: "/rpc/*", Price: "0.0001"}}, }) if err != nil { @@ -49,7 +49,7 @@ func TestWatchConfig_DetectsChange(t *testing.T) { // Write updated config with a new route. updatedYAML := `wallet: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" chain: "base-sepolia" -facilitatorURL: "https://facilitator.x402.rs" +facilitatorURL: "https://x402.gcp.obol.tech" routes: - pattern: "/rpc/*" price: "0.0001" @@ -79,7 +79,7 @@ func TestWatchConfig_IgnoresUnchanged(t *testing.T) { v, err := NewVerifier(&PricingConfig{ Wallet: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", Chain: "base-sepolia", - FacilitatorURL: "https://facilitator.x402.rs", + FacilitatorURL: "https://x402.gcp.obol.tech", Routes: []RouteRule{{Pattern: "/rpc/*", Price: "0.0001"}}, }) if err != nil { @@ -112,7 +112,7 @@ func TestWatchConfig_InvalidConfig(t *testing.T) { v, err := NewVerifier(&PricingConfig{ Wallet: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", Chain: "base-sepolia", - FacilitatorURL: "https://facilitator.x402.rs", + FacilitatorURL: "https://x402.gcp.obol.tech", Routes: []RouteRule{{Pattern: "/rpc/*", Price: "0.0001"}}, }) if err != nil { @@ -149,7 +149,7 @@ func TestWatchConfig_CancelContext(t *testing.T) { v, err := NewVerifier(&PricingConfig{ Wallet: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", Chain: "base-sepolia", - FacilitatorURL: "https://facilitator.x402.rs", + FacilitatorURL: "https://x402.gcp.obol.tech", Routes: []RouteRule{{Pattern: "/rpc/*", Price: "0.0001"}}, }) if err != nil { @@ -181,7 +181,7 @@ func TestWatchConfig_MissingFile(t *testing.T) { v, err := NewVerifier(&PricingConfig{ Wallet: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", Chain: "base-sepolia", - FacilitatorURL: "https://facilitator.x402.rs", + FacilitatorURL: "https://x402.gcp.obol.tech", Routes: []RouteRule{{Pattern: "/rpc/*", Price: "0.0001"}}, }) if err != nil { diff --git a/obolup.sh b/obolup.sh index 07f938fd..2fe1b29b 100755 --- a/obolup.sh +++ b/obolup.sh @@ -110,7 +110,9 @@ command_exists() { check_prerequisites() { local missing=() - # Node.js 22+ / npm — required for openclaw CLI (unless already installed) + # Node.js 22+ / npm — preferred for openclaw CLI install. + # If missing, install_openclaw() will fall back to Docker image extraction. + # Only block here if neither npm NOR docker is available. local need_npm=true if command_exists openclaw; then local oc_version @@ -122,12 +124,17 @@ check_prerequisites() { if [[ "$need_npm" == "true" ]]; then if ! command_exists npm; then - missing+=("Node.js 22+ (npm) — required to install openclaw CLI") + if ! command_exists docker; then + missing+=("Node.js 22+ (npm) or Docker — required to install openclaw CLI") + fi + # npm missing but docker available — install_openclaw() will use Docker fallback else local node_major node_major=$(node --version 2>/dev/null | sed 's/v//' | cut -d. -f1) if [[ -z "$node_major" ]] || [[ "$node_major" -lt 22 ]]; then - missing+=("Node.js 22+ (found: v${node_major:-none}) — required for openclaw CLI") + if ! command_exists docker; then + missing+=("Node.js 22+ (found: v${node_major:-none}) or Docker — required for openclaw CLI") + fi fi fi fi @@ -1126,23 +1133,48 @@ install_openclaw() { return 0 fi - # Require Node.js 22+ and npm + # Prefer npm install; fall back to extracting the binary from Docker image. + local use_npm=true if ! command_exists npm; then - log_warn "npm not found — cannot install openclaw CLI" - echo "" - echo " Install Node.js 22+ first, then re-run obolup.sh" - echo " Or install manually: npm install -g openclaw@$target_version" - echo "" - return 1 + use_npm=false + else + local node_major + node_major=$(node --version 2>/dev/null | sed 's/v//' | cut -d. -f1) + if [[ -z "$node_major" ]] || [[ "$node_major" -lt 22 ]]; then + use_npm=false + fi fi - local node_major - node_major=$(node --version 2>/dev/null | sed 's/v//' | cut -d. -f1) - if [[ -z "$node_major" ]] || [[ "$node_major" -lt 22 ]]; then - log_warn "Node.js 22+ required for openclaw (found: v${node_major:-none})" + if [[ "$use_npm" == "false" ]]; then + # Docker fallback: extract openclaw binary from the published image. + if command_exists docker; then + log_info "npm/Node.js not available — extracting openclaw from Docker image..." + local image="ghcr.io/obolnetwork/openclaw:$target_version" + if docker pull "$image" 2>&1 | tail -1; then + local cid + cid=$(docker create "$image" 2>/dev/null) + if [[ -n "$cid" ]]; then + docker cp "$cid:/usr/local/bin/openclaw" "$OBOL_BIN_DIR/openclaw" 2>/dev/null \ + || docker cp "$cid:/app/openclaw" "$OBOL_BIN_DIR/openclaw" 2>/dev/null \ + || docker cp "$cid:/openclaw" "$OBOL_BIN_DIR/openclaw" 2>/dev/null + docker rm "$cid" >/dev/null 2>&1 + if [[ -f "$OBOL_BIN_DIR/openclaw" ]]; then + chmod +x "$OBOL_BIN_DIR/openclaw" + log_success "openclaw v$target_version installed (from Docker image)" + return 0 + fi + fi + fi + log_warn "Docker extraction failed" + echo " Pull the Docker image: docker pull $image" + echo "" + return 1 + fi + + log_warn "npm and Docker both unavailable — cannot install openclaw CLI" echo "" - echo " Upgrade Node.js, then re-run obolup.sh" - echo " Or install manually: npm install -g openclaw@$target_version" + echo " Install Node.js 22+ first, then re-run obolup.sh" + echo " Or pull the Docker image: docker pull ghcr.io/obolnetwork/openclaw:$target_version" echo "" return 1 fi