From 970800f4a5d7c18d13e9dbc7879e79c4d48c47a2 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Thu, 5 Mar 2026 22:27:14 +0100 Subject: [PATCH 1/4] feat: BDD integration tests + upstream auth injection + tunnel URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the full sell→discover→buy BDD test suite and the upstream auth injection mechanism, rebased cleanly on current main. ## BDD Integration Tests (godog/Gherkin) 7 scenarios, 75 steps, following the real user journey: 1. Operator sells inference via CLI + agent reconciles 2. Unpaid request returns 402 with pricing 3. Paid request returns real inference (EIP-712 → verify → Ollama) 4. Full discovery-to-payment cycle 5. Paid request through Cloudflare tunnel 6. Agent discovers registered service through tunnel 7. Operator deletes ServiceOffer + cleanup TestMain bootstrap: obol stack init/up → model setup → sell pricing → agent init → sell http → wait for reconciliation. No kubectl shortcuts. ## Upstream Auth Injection x402-verifier now injects Authorization header on paid requests: - RouteRule.UpstreamAuth field in pricing config - Verifier sets header in 200 response → Traefik copies via authResponseHeaders - monetize.py reads LiteLLM master key → writes upstreamAuth to route - Eliminates manual HTTPRoute RequestHeaderModifier patches ## Tunnel URL Injection `obol tunnel status` auto-sets AGENT_BASE_URL on the obol-agent deployment. monetize.py reads it to publish the tunnel URL in registration JSON. Files: - internal/x402/features/integration_payment_flow.feature (new) - internal/x402/bdd_integration_test.go (new) - internal/x402/bdd_integration_steps_test.go (new) - internal/x402/config.go (UpstreamAuth field) - internal/x402/verifier.go (inject Authorization on 200) - internal/embed/skills/sell/scripts/monetize.py (read master key, upstreamAuth) - internal/tunnel/tunnel.go (InjectBaseURL, auto-inject on status) - internal/embed/infrastructure/.../obol-agent-monetize-rbac.yaml (secrets:get) --- go.mod | 8 + go.sum | 33 + .../templates/obol-agent-monetize-rbac.yaml | 4 + .../embed/skills/sell/scripts/monetize.py | 46 +- internal/tunnel/tunnel.go | 22 + internal/x402/bdd_integration_steps_test.go | 646 ++++++++++++++++++ internal/x402/bdd_integration_test.go | 361 ++++++++++ internal/x402/config.go | 6 + .../features/integration_payment_flow.feature | 97 +++ internal/x402/verifier.go | 12 +- 10 files changed, 1224 insertions(+), 11 deletions(-) create mode 100644 internal/x402/bdd_integration_steps_test.go create mode 100644 internal/x402/bdd_integration_test.go create mode 100644 internal/x402/features/integration_payment_flow.feature diff --git a/go.mod b/go.mod index fbbb765..7ab014c 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,9 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect + github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect + github.com/cucumber/godog v0.15.1 // indirect + 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/ethereum/c-kzg-4844/v2 v2.1.5 // indirect @@ -49,9 +52,13 @@ require ( github.com/gagliardetto/solana-go v1.14.0 // indirect github.com/gagliardetto/treeout v0.1.4 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gofrs/uuid v4.3.1+incompatible // indirect github.com/google/go-configfs-tsm v0.2.2 // indirect github.com/google/logger v1.1.1 // indirect github.com/gorilla/websocket v1.4.2 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-memdb v1.3.4 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/holiman/uint256 v1.3.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.1 // indirect @@ -69,6 +76,7 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect 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 diff --git a/go.sum b/go.sum index cb3b344..e779454 100644 --- a/go.sum +++ b/go.sum @@ -49,12 +49,20 @@ github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAK github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= 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= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= +github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= +github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= +github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI= +github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8= +github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI= +github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s= +github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -105,6 +113,9 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= +github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= @@ -134,6 +145,16 @@ github.com/graph-gophers/graphql-go v1.3.0 h1:Eb9x/q6MFpCLz7jBCiP/WTxjSDrYLR1QY4 github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= +github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-memdb v1.3.4 h1:XSL3NR682X/cVk2IeV0d70N4DZ9ljI885xAEU8IoK3c= +github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hf/nitrite v0.0.0-20241225144000-c2d5d3c4f303 h1:XBSq4rXFUgD8ic6Mr7dBwJN/47yg87XpZQhiknfr4Cg= github.com/hf/nitrite v0.0.0-20241225144000-c2d5d3c4f303/go.mod h1:ycRhVmo6wegyEl6WN+zXOHUTJvB0J2tiuH88q/McTK8= github.com/hf/nsm v0.0.0-20220930140112-cd181bd646b9 h1:pU32bJGmZwF4WXb9Yaz0T8vHDtIPVxqDOdmYdwTQPqw= @@ -146,6 +167,7 @@ github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/influxdata/influxdb-client-go/v2 v2.4.0 h1:HGBfZYStlx3Kqvsv1h2pJixbCl/jhnFtxpKFAv9Tu5k= github.com/influxdata/influxdb-client-go/v2 v2.4.0/go.mod h1:vLNHdxTJkIf2mSLvGrpj8TCcISApPoXkaxP8g9uRlW8= github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSHzRbhzK8RdXOsAdfDgO49TtqC1oZ+acxPrkfTxcCs= @@ -250,12 +272,23 @@ github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1 github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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/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= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= 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 65bc693..4e333a3 100644 --- a/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml +++ b/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml @@ -78,6 +78,10 @@ rules: - apiGroups: ["apps"] resources: ["deployments"] verbs: ["create", "update", "patch", "delete"] + # Secrets - read upstream auth tokens (e.g., LiteLLM master key) + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] --- #------------------------------------------------------------------------------ diff --git a/internal/embed/skills/sell/scripts/monetize.py b/internal/embed/skills/sell/scripts/monetize.py index d103212..afb92a3 100644 --- a/internal/embed/skills/sell/scripts/monetize.py +++ b/internal/embed/skills/sell/scripts/monetize.py @@ -21,6 +21,7 @@ """ import argparse +import base64 import json import os import re @@ -516,7 +517,7 @@ def stage_payment_gate(spec, ns, name, token, ssl_ctx): "spec": { "forwardAuth": { "address": "http://x402-verifier.x402.svc.cluster.local:8080/verify", - "authResponseHeaders": ["X-Payment-Status", "X-Payment-Tx"], + "authResponseHeaders": ["X-Payment-Status", "X-Payment-Tx", "Authorization"], }, }, } @@ -551,6 +552,25 @@ def stage_payment_gate(spec, ns, name, token, ssl_ctx): return True +def _read_upstream_auth(spec, token, ssl_ctx): + """Read the LiteLLM master key from the cluster and return a Bearer token. + + Returns "Bearer " or empty string if the secret is not available. + """ + upstream_ns = spec.get("upstream", {}).get("namespace", "llm") + secret_path = f"/api/v1/namespaces/{upstream_ns}/secrets/litellm-secrets" + try: + secret = api_get(secret_path, token, ssl_ctx, quiet=True) + encoded = secret.get("data", {}).get("LITELLM_MASTER_KEY", "") + if encoded: + key = base64.b64decode(encoded).decode("utf-8").strip() + if key: + return f"Bearer {key}" + except (SystemExit, Exception) as e: + print(f" Note: could not read LiteLLM master key: {e}") + return "" + + def _add_pricing_route(spec, name, token, ssl_ctx): """Add a pricing route to the x402-verifier ConfigMap for this offer. @@ -585,17 +605,29 @@ def _add_pricing_route(spec, name, token, ssl_ctx): print(f" Pricing route {route_pattern} already exists") return + # Read upstream auth token so the x402-verifier can inject Authorization. + upstream_auth = _read_upstream_auth(spec, token, ssl_ctx) + + # Detect indentation of existing routes. + indent = "" + for line in pricing_yaml_str.splitlines(): + stripped = line.lstrip() + if stripped.startswith("- pattern:"): + indent = line[: len(line) - len(stripped)] + break + # Build the new route entry in YAML format. - # Per-route payTo and network enable multi-offer with different wallets/chains. route_entry = ( - f'- pattern: "{route_pattern}"\n' - f' price: "{price}"\n' - f' description: "ServiceOffer {name}"\n' + f'{indent}- pattern: "{route_pattern}"\n' + f'{indent} price: "{price}"\n' + f'{indent} description: "ServiceOffer {name}"\n' ) if pay_to: - route_entry += f' payTo: "{pay_to}"\n' + route_entry += f'{indent} payTo: "{pay_to}"\n' if network: - route_entry += f' network: "{network}"\n' + route_entry += f'{indent} network: "{network}"\n' + if upstream_auth: + route_entry += f'{indent} upstreamAuth: "{upstream_auth}"\n' # Append route to existing routes section or create it. if "routes:" in pricing_yaml_str: diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 1dbb0d1..b868e97 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -77,9 +77,31 @@ func Status(cfg *config.Config, u *ui.UI) error { printStatusBox(u, mode, statusLabel, url, time.Now()) u.Printf("Test with: curl %s/", url) + // Auto-inject tunnel URL into obol-agent so registration JSON uses it. + if url != "" && url != "(not available)" { + if err := InjectBaseURL(cfg, url); err == nil { + u.Dim("Agent base URL updated to " + url) + } + } + return nil } +// InjectBaseURL sets AGENT_BASE_URL on the obol-agent deployment so that +// monetize.py uses the tunnel URL in registration JSON. +func InjectBaseURL(cfg *config.Config, tunnelURL string) error { + kubectlPath := filepath.Join(cfg.BinDir, "kubectl") + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + + cmd := exec.Command(kubectlPath, + "--kubeconfig", kubeconfigPath, + "set", "env", "deployment/openclaw", + "-n", "openclaw-obol-agent", + fmt.Sprintf("AGENT_BASE_URL=%s", strings.TrimRight(tunnelURL, "/")), + ) + return cmd.Run() +} + // GetTunnelURL parses cloudflared logs to extract the quick tunnel URL. func GetTunnelURL(cfg *config.Config) (string, error) { kubectlPath := filepath.Join(cfg.BinDir, "kubectl") diff --git a/internal/x402/bdd_integration_steps_test.go b/internal/x402/bdd_integration_steps_test.go new file mode 100644 index 0000000..55d15b6 --- /dev/null +++ b/internal/x402/bdd_integration_steps_test.go @@ -0,0 +1,646 @@ +//go:build integration + +package x402 + +import ( + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/ObolNetwork/obol-stack/internal/kubectl" + "github.com/ObolNetwork/obol-stack/internal/testutil" + "github.com/cucumber/godog" +) + +// 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"` +} + +// integrationWorld holds shared state for integration-tier BDD scenarios. +// Each scenario gets a fresh world. Background steps bootstrap Anvil, +// facilitator, and verifier patching per scenario. +type integrationWorld struct { + t *testing.T + + // Cluster access (from package-level vars set by TestMain). + kubectlBin string + kubeconfig string + + // Per-scenario infrastructure. + anvil *testutil.AnvilFork + facilitator *testutil.MockFacilitator + + // Tunnel. + tunnelURL string + + // Pricing route (from TestMain bootstrap). + routePath string + payTo string + + // Buyer. + buyerKeyHex string + + // Request/Response state. + lastResponse *http.Response + lastBody []byte + lastStatusCode int + + // Parsed 402. + parsed402 *parsed402Response + + // Signed payment header. + signedPaymentHeader string + + // Discovered registration. + registrationJSON map[string]interface{} + discoveredEndpoint string +} + +func newIntegrationWorld(t *testing.T) *integrationWorld { + return &integrationWorld{ + t: t, + kubectlBin: integrationKubectlBin, + kubeconfig: integrationKubeconfig, + routePath: integrationRoutePath, + payTo: integrationPayTo, + } +} + +func (w *integrationWorld) cleanup() { + // Anvil, facilitator, and verifier restore are handled by t.Cleanup. +} + +// ── Step registration ──────────────────────────────────────────────────────── + +func registerIntegrationSteps(ctx *godog.ScenarioContext, w *integrationWorld) { + // ── Background / Given ─────────────────────────────────────────── + + ctx.Given(`^an Anvil fork of Base Sepolia is running$`, func() error { + if !integrationReady { + return godog.ErrPending + } + + anvil := testutil.StartAnvilFork(w.t) + w.anvil = anvil + w.t.Logf("integration: Anvil fork on port %d", anvil.Port) + return nil + }) + + ctx.Given(`^the buyer has (\d+) USDC on the fork$`, func(amount int) error { + if w.anvil == nil { + return fmt.Errorf("Anvil fork not running") + } + if w.buyerKeyHex == "" { + // Use default Anvil account #2 for now; will be overridden by + // "a buyer with Anvil key" step if present. + w.buyerKeyHex = "2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" + } + + buyerAddr := testutil.AnvilKeyAddress(w.t, w.buyerKeyHex) + // amount is in USDC (6 decimals). + microUnits := new(big.Int).Mul(big.NewInt(int64(amount)), big.NewInt(1_000_000)) + w.anvil.MintUSDC(w.t, buyerAddr.Hex(), microUnits) + w.t.Logf("integration: minted %d USDC to %s", amount, buyerAddr.Hex()) + return nil + }) + + ctx.Given(`^a facilitator is running against the fork$`, func() error { + w.facilitator = testutil.StartMockFacilitator(w.t) + w.t.Logf("integration: mock facilitator on port %d, cluster URL %s", + w.facilitator.Port, w.facilitator.ClusterURL) + return nil + }) + + ctx.Given(`^the x402-verifier is patched to use the facilitator$`, func() error { + if w.facilitator == nil { + return fmt.Errorf("facilitator not running") + } + if w.kubectlBin == "" { + return godog.ErrPending + } + + testutil.PatchVerifierFacilitator(w.t, w.kubectlBin, w.kubeconfig, w.facilitator.ClusterURL) + return nil + }) + + ctx.Given(`^a buyer with Anvil key "([^"]*)"$`, func(keyHex string) error { + w.buyerKeyHex = keyHex + return nil + }) + + ctx.Given(`^the Cloudflare tunnel is reachable$`, func() error { + tunnelURL := os.Getenv("TUNNEL_URL") + if tunnelURL == "" { + // Try auto-detect from cloudflared logs. + if w.kubectlBin != "" { + tunnelURL = detectTunnelURL(w) + } + } + if tunnelURL == "" { + return godog.ErrPending + } + w.tunnelURL = strings.TrimRight(tunnelURL, "/") + + // Quick health check. + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Head(w.tunnelURL + "/") + if err != nil { + return fmt.Errorf("tunnel unreachable at %s: %w", w.tunnelURL, err) + } + resp.Body.Close() + return nil + }) + + // ── When (local cluster) ───────────────────────────────────────── + + ctx.When(`^the buyer sends an unpaid POST to the priced route$`, func() error { + url := fmt.Sprintf("http://localhost:8080%s", w.routePath) + return w.doInferencePost(url, nil) + }) + + ctx.When(`^the buyer signs an EIP-712 payment from the 402 response$`, func() error { + if w.parsed402 == nil || len(w.parsed402.Accepts) == 0 { + return fmt.Errorf("must receive 402 before signing") + } + if w.buyerKeyHex == "" { + return fmt.Errorf("buyer key not set") + } + + accept := w.parsed402.Accepts[0] + payTo := accept.PayTo + amount := accept.Amount + if payTo == "" { + payTo = w.payTo + } + if amount == "" { + return fmt.Errorf("no amount in 402 accepts") + } + + w.signedPaymentHeader = testutil.SignRealPaymentHeader( + w.t, w.buyerKeyHex, payTo, amount, 84532, + ) + return nil + }) + + ctx.When(`^the buyer constructs payment from the discovered pricing$`, func() error { + if w.parsed402 == nil || len(w.parsed402.Accepts) == 0 { + return fmt.Errorf("must receive 402 before constructing payment") + } + + accept := w.parsed402.Accepts[0] + w.signedPaymentHeader = testutil.SignRealPaymentHeader( + w.t, w.buyerKeyHex, accept.PayTo, accept.Amount, 84532, + ) + return nil + }) + + ctx.When(`^the buyer sends the paid POST to the priced route$`, func() error { + if w.signedPaymentHeader == "" { + return fmt.Errorf("no signed payment header") + } + url := fmt.Sprintf("http://localhost:8080%s", w.routePath) + return w.doInferencePost(url, map[string]string{"X-PAYMENT": w.signedPaymentHeader}) + }) + + // ── When (tunnel) ──────────────────────────────────────────────── + + ctx.When(`^the buyer sends an unpaid POST through the tunnel$`, func() error { + if w.tunnelURL == "" { + return fmt.Errorf("tunnel URL not set") + } + url := fmt.Sprintf("%s%s", w.tunnelURL, w.routePath) + return w.doInferencePost(url, nil) + }) + + ctx.When(`^the buyer sends the paid POST through the tunnel$`, func() error { + if w.signedPaymentHeader == "" { + return fmt.Errorf("no signed payment header") + } + url := fmt.Sprintf("%s%s", w.tunnelURL, w.routePath) + return w.doInferencePost(url, map[string]string{"X-PAYMENT": w.signedPaymentHeader}) + }) + + // ── Then ───────────────────────────────────────────────────────── + + ctx.Then(`^the response status is (\d+)$`, func(expected int) error { + if w.lastStatusCode != expected { + return fmt.Errorf("expected status %d, got %d (body: %s)", + expected, w.lastStatusCode, truncate(w.lastBody, 500)) + } + return nil + }) + + ctx.Then(`^the response body contains x402Version (\d+)$`, func(version int) error { + if !strings.Contains(string(w.lastBody), fmt.Sprintf(`"x402Version":%d`, version)) && + !strings.Contains(string(w.lastBody), fmt.Sprintf(`"x402Version": %d`, version)) { + return fmt.Errorf("x402Version %d not found in response: %s", version, truncate(w.lastBody, 300)) + } + return nil + }) + + ctx.Then(`^the response body contains a valid accepts array$`, func() error { + if w.parsed402 == nil { + return fmt.Errorf("no parsed 402 response") + } + if len(w.parsed402.Accepts) == 0 { + return fmt.Errorf("empty accepts array in 402") + } + a := w.parsed402.Accepts[0] + if a.PayTo == "" || a.Network == "" || a.Amount == "" { + return fmt.Errorf("incomplete accepts entry: %+v", a) + } + return nil + }) + + ctx.Then(`^the response contains a real inference result$`, func() error { + return validateInferenceResponse(w, false) + }) + + ctx.Then(`^the response contains non-empty inference content$`, func() error { + return validateInferenceResponse(w, false) + }) + + ctx.Then(`^the 402 response contains payTo and price and network$`, func() error { + if w.parsed402 == nil || len(w.parsed402.Accepts) == 0 { + return fmt.Errorf("no parsed 402 response with accepts") + } + a := w.parsed402.Accepts[0] + if a.PayTo == "" { + return fmt.Errorf("payTo is empty") + } + if a.Amount == "" { + return fmt.Errorf("price (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) + return nil + }) + + ctx.Then(`^the facilitator received at least (\d+) verify calls?$`, func(min int) error { + if w.facilitator == nil { + return fmt.Errorf("no facilitator running") + } + actual := int(w.facilitator.VerifyCalls.Load()) + if actual < min { + return fmt.Errorf("expected at least %d verify calls, got %d", min, actual) + } + return nil + }) + + // ── Sell-side steps ────────────────────────────────────────────── + // These validate that the real `obol sell http` + agent reconciliation + // path works. TestMain already runs these commands during bootstrap, + // so these steps verify the resulting state. + + ctx.When(`^the operator runs "obol sell http" to create a ServiceOffer$`, func() error { + // The ServiceOffer was created by TestMain via `obol sell http`. + // Verify it exists. + out, err := kubectl.Output(w.kubectlBin, w.kubeconfig, + "get", "serviceoffers.obol.org", serviceOfferName, + "-n", serviceOfferNamespace, "--no-headers") + if err != nil { + return fmt.Errorf("ServiceOffer not found (was obol sell http run?): %v", err) + } + w.t.Logf("integration: ServiceOffer exists: %s", strings.TrimSpace(out)) + return nil + }) + + ctx.When(`^the agent reconciles the ServiceOffer$`, func() error { + // TestMain already waited for Ready. If not ready, trigger manually. + out, err := kubectl.Output(w.kubectlBin, w.kubeconfig, + "get", "serviceoffers.obol.org", serviceOfferName, + "-n", serviceOfferNamespace, + "-o", "jsonpath={.status.conditions[?(@.type=='Ready')].status}") + if err != nil || strings.TrimSpace(out) != "True" { + // Trigger reconciliation manually. + triggerReconciliation(w.kubectlBin, w.kubeconfig) + // Poll for Ready. + return waitForServiceOfferReady(w.kubectlBin, w.kubeconfig, + serviceOfferName, serviceOfferNamespace, 120*time.Second) + } + return nil + }) + + ctx.Then(`^the ServiceOffer status is "([^"]*)"$`, func(expected string) error { + out, err := kubectl.Output(w.kubectlBin, w.kubeconfig, + "get", "serviceoffers.obol.org", serviceOfferName, + "-n", serviceOfferNamespace, + "-o", "jsonpath={.status.conditions[?(@.type=='"+expected+"')].status}") + if err != nil { + return fmt.Errorf("could not read ServiceOffer status: %v", err) + } + if strings.TrimSpace(out) != "True" { + // Dump all conditions for debugging. + conds, _ := kubectl.Output(w.kubectlBin, w.kubeconfig, + "get", "serviceoffers.obol.org", serviceOfferName, + "-n", serviceOfferNamespace, + "-o", "jsonpath={range .status.conditions[*]}{.type}: {.status} ({.message}){\"\\n\"}{end}") + return fmt.Errorf("ServiceOffer condition %s is not True.\nConditions:\n%s", expected, conds) + } + w.t.Logf("integration: ServiceOffer condition %s = True", expected) + return nil + }) + + ctx.Then(`^a Middleware "([^"]*)" exists in the offer namespace$`, func(name string) error { + _, err := kubectl.Output(w.kubectlBin, w.kubeconfig, + "get", "middleware", name, "-n", serviceOfferNamespace) + if err != nil { + return fmt.Errorf("Middleware %s not found in %s: %v", name, serviceOfferNamespace, err) + } + w.t.Logf("integration: ✓ Middleware %s exists", name) + return nil + }) + + ctx.Then(`^an HTTPRoute "([^"]*)" exists in the offer namespace$`, func(name string) error { + _, err := kubectl.Output(w.kubectlBin, w.kubeconfig, + "get", "httproute", name, "-n", serviceOfferNamespace) + if err != nil { + return fmt.Errorf("HTTPRoute %s not found in %s: %v", name, serviceOfferNamespace, err) + } + w.t.Logf("integration: ✓ HTTPRoute %s exists", name) + return nil + }) + + ctx.Then(`^the x402-pricing ConfigMap contains a route for the offer$`, func() error { + out, err := kubectl.Output(w.kubectlBin, w.kubeconfig, + "get", "cm", "x402-pricing", "-n", "x402", + "-o", "jsonpath={.data.pricing\\.yaml}") + if err != nil { + return fmt.Errorf("could not read x402-pricing: %v", err) + } + pattern := "/services/" + serviceOfferName + "/*" + if !strings.Contains(out, pattern) { + return fmt.Errorf("pricing ConfigMap does not contain route %s:\n%s", pattern, out) + } + w.t.Logf("integration: ✓ Pricing route %s present", pattern) + return nil + }) + + // ── Discovery + buy-side steps ─────────────────────────────────── + + ctx.When(`^the agent fetches the registration JSON from the tunnel$`, func() error { + if w.tunnelURL == "" { + return fmt.Errorf("tunnel URL not set") + } + regURL := w.tunnelURL + "/.well-known/agent-registration.json" + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Get(regURL) + if err != nil { + return fmt.Errorf("fetch registration JSON: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("registration JSON returned %d: %s", resp.StatusCode, truncate(body, 200)) + } + var reg map[string]interface{} + if err := json.Unmarshal(body, ®); err != nil { + return fmt.Errorf("parse registration JSON: %w", err) + } + w.registrationJSON = reg + w.t.Logf("integration: registration JSON from tunnel: name=%v x402=%v", + reg["name"], reg["x402Support"]) + return nil + }) + + ctx.Then(`^the registration contains x402Support$`, func() error { + if w.registrationJSON == nil { + return fmt.Errorf("no registration JSON fetched") + } + x402, _ := w.registrationJSON["x402Support"].(bool) + if !x402 { + return fmt.Errorf("registration does not have x402Support=true") + } + w.t.Log("integration: ✓ x402Support=true") + return nil + }) + + ctx.Then(`^the registration contains a service endpoint$`, func() error { + if w.registrationJSON == nil { + return fmt.Errorf("no registration JSON fetched") + } + services, ok := w.registrationJSON["services"].([]interface{}) + if !ok || len(services) == 0 { + return fmt.Errorf("registration has no services") + } + svc, ok := services[0].(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid service entry") + } + endpoint, _ := svc["endpoint"].(string) + if endpoint == "" { + return fmt.Errorf("service has no endpoint") + } + + // If endpoint uses obol.stack (local), rewrite to tunnel URL for probing. + if strings.Contains(endpoint, "obol.stack") && w.tunnelURL != "" { + // Extract path from local endpoint and prepend tunnel URL. + parts := strings.SplitN(endpoint, "/services/", 2) + if len(parts) == 2 { + endpoint = w.tunnelURL + "/services/" + parts[1] + } + } + w.discoveredEndpoint = endpoint + w.t.Logf("integration: ✓ service endpoint: %s", endpoint) + return nil + }) + + ctx.When(`^the agent probes the tunnel service endpoint$`, func() error { + if w.discoveredEndpoint == "" { + return fmt.Errorf("no service endpoint discovered") + } + probeURL := w.discoveredEndpoint + "/v1/chat/completions" + return w.doInferencePost(probeURL, nil) + }) + + ctx.Then(`^the probe returns 402 with pricing info$`, func() error { + if w.lastStatusCode != 402 { + return fmt.Errorf("expected probe to return 402, got %d: %s", + w.lastStatusCode, truncate(w.lastBody, 200)) + } + if w.parsed402 == nil || len(w.parsed402.Accepts) == 0 { + return fmt.Errorf("402 response has no accepts array") + } + a := w.parsed402.Accepts[0] + w.t.Logf("integration: ✓ probe 402: payTo=%s price=%s network=%s", + a.PayTo, a.Amount, a.Network) + return nil + }) + + // ── Cleanup steps ──────────────────────────────────────────────── + + ctx.When(`^the operator deletes the ServiceOffer via CLI$`, func() error { + if integrationObolBin == "" { + return fmt.Errorf("obol binary not set") + } + err := runObol(integrationObolBin, "sell", "delete", serviceOfferName, + "-n", serviceOfferNamespace, "-f") + if err != nil { + return fmt.Errorf("obol sell delete failed: %v", err) + } + // Wait for deletion to propagate. + time.Sleep(3 * time.Second) + return nil + }) + + ctx.Then(`^the ServiceOffer no longer exists$`, func() error { + _, err := kubectl.Output(w.kubectlBin, w.kubeconfig, + "get", "serviceoffers.obol.org", serviceOfferName, + "-n", serviceOfferNamespace) + if err == nil { + return fmt.Errorf("ServiceOffer still exists after delete") + } + w.t.Log("integration: ✓ ServiceOffer deleted") + return nil + }) + + ctx.Then(`^the x402-pricing ConfigMap does not contain a route for the offer$`, func() error { + out, err := kubectl.Output(w.kubectlBin, w.kubeconfig, + "get", "cm", "x402-pricing", "-n", "x402", + "-o", "jsonpath={.data.pricing\\.yaml}") + if err != nil { + return fmt.Errorf("could not read x402-pricing: %v", err) + } + pattern := "/services/" + serviceOfferName + "/*" + if strings.Contains(out, pattern) { + return fmt.Errorf("pricing route %s still present after delete:\n%s", pattern, out) + } + w.t.Log("integration: ✓ Pricing route removed") + return nil + }) +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +// doInferencePost sends a POST to the given URL with an OpenAI-compatible body. +func (w *integrationWorld) doInferencePost(url string, headers map[string]string) error { + model := integrationModel + if model == "" { + model = "llama3.2" + } + body := fmt.Sprintf(`{"model":"%s","messages":[{"role":"user","content":"Reply with exactly: Hello World"}],"max_tokens":20,"stream":false}`, model) + + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + + client := &http.Client{Timeout: 180 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("POST %s: %w", url, err) + } + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + w.lastResponse = resp + w.lastBody = respBody + w.lastStatusCode = resp.StatusCode + + // Auto-parse 402 responses. + if resp.StatusCode == http.StatusPaymentRequired { + var parsed parsed402Response + if err := json.Unmarshal(respBody, &parsed); err == nil { + w.parsed402 = &parsed + } + } + + return nil +} + +// validateInferenceResponse checks the response is a valid OpenAI chat completion. +// Accepts either text content or tool_calls as valid output (some models like +// llama3.2 may generate tool_calls instead of text for short prompts). +func validateInferenceResponse(w *integrationWorld, requireText bool) error { + var result struct { + Choices []struct { + Message struct { + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + ToolCalls []interface{} `json:"tool_calls"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(w.lastBody, &result); err != nil { + return fmt.Errorf("parse inference response: %w (body: %s)", err, truncate(w.lastBody, 300)) + } + if len(result.Choices) == 0 { + return fmt.Errorf("no choices in inference response: %s", truncate(w.lastBody, 300)) + } + msg := result.Choices[0].Message + hasContent := msg.Content != "" + hasReasoning := msg.ReasoningContent != "" + hasToolCalls := len(msg.ToolCalls) > 0 + if !hasContent && !hasReasoning && !hasToolCalls { + return fmt.Errorf("inference response has no content, reasoning, or tool_calls: %s", truncate(w.lastBody, 300)) + } + switch { + case hasContent: + w.t.Logf("integration: inference content = %s", truncateStr(msg.Content, 100)) + case hasReasoning: + w.t.Logf("integration: inference reasoning = %s", truncateStr(msg.ReasoningContent, 100)) + default: + w.t.Logf("integration: inference returned %d tool_calls", len(msg.ToolCalls)) + } + return nil +} + +// detectTunnelURL tries to extract the tunnel URL from cloudflared logs. +func detectTunnelURL(w *integrationWorld) string { + out, err := kubectl.Output(w.kubectlBin, w.kubeconfig, "logs", + "-n", "traefik", "-l", "app.kubernetes.io/name=cloudflared", + "--tail=50") + if err != nil { + return "" + } + // Look for "https://.cfargotunnel.com" or similar in logs. + for _, line := range strings.Split(out, "\n") { + if idx := strings.Index(line, "https://"); idx >= 0 { + candidate := line[idx:] + // Trim at first whitespace. + if space := strings.IndexAny(candidate, " \t\n"); space > 0 { + candidate = candidate[:space] + } + if strings.Contains(candidate, "trycloudflare.com") || strings.Contains(candidate, "cfargotunnel.com") { + return strings.TrimRight(candidate, "/") + } + } + } + return "" +} + +func truncate(b []byte, max int) string { + return truncateStr(string(b), max) +} + +func truncateStr(s string, max int) string { + if len(s) > max { + return s[:max] + "..." + } + return s +} diff --git a/internal/x402/bdd_integration_test.go b/internal/x402/bdd_integration_test.go new file mode 100644 index 0000000..2ecf1ad --- /dev/null +++ b/internal/x402/bdd_integration_test.go @@ -0,0 +1,361 @@ +//go:build integration + +package x402 + +import ( + "context" + "encoding/base64" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ObolNetwork/obol-stack/internal/kubectl" + "github.com/cucumber/godog" +) + +// ── Package-level state set by TestMain ────────────────────────────────────── + +var ( + integrationKubectlBin string + integrationKubeconfig string + integrationRoutePath string + integrationPayTo string + integrationObolBin string + integrationModel string + + // Set to true when TestMain successfully bootstraps the cluster. + integrationReady bool +) + +const ( + serviceOfferName = "bdd-test" + serviceOfferNamespace = "llm" + serviceOfferPayTo = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" +) + +// TestMain bootstraps the full obol stack following the real user journey: +// +// 1. Build obol binary from source +// 2. obol stack init + up (real cluster) +// 3. obol model setup (real LLM provider) +// 4. obol sell pricing (real x402 configuration) +// 5. obol agent init (real agent singleton + RBAC + monetize skill) +// 6. obol sell http (real ServiceOffer CR) +// 7. Wait for agent reconciliation (real heartbeat cron) +// +// No kubectl shortcuts. Every step matches what a user runs. +func TestMain(m *testing.M) { + if os.Getenv("OBOL_INTEGRATION_SKIP_BOOTSTRAP") == "true" { + // Escape hatch: use a pre-existing cluster with pre-deployed ServiceOffer. + binDir := os.Getenv("OBOL_BIN_DIR") + configDir := os.Getenv("OBOL_CONFIG_DIR") + if binDir != "" && configDir != "" { + integrationKubectlBin = filepath.Join(binDir, "kubectl") + integrationKubeconfig = filepath.Join(configDir, "kubeconfig.yaml") + integrationObolBin = filepath.Join(binDir, "obol") + integrationRoutePath = "/services/" + serviceOfferName + "/v1/chat/completions" + integrationPayTo = serviceOfferPayTo + integrationModel = os.Getenv("OBOL_TEST_MODEL") + if integrationModel == "" { + integrationModel = "qwen3.5:9b" + } + integrationReady = true + } + os.Exit(m.Run()) + } + + projectRoot := findProjectRoot() + workspaceDir := filepath.Join(projectRoot, ".workspace") + + // Set environment for development mode. + os.Setenv("OBOL_DEVELOPMENT", "true") + os.Setenv("OBOL_CONFIG_DIR", filepath.Join(workspaceDir, "config")) + os.Setenv("OBOL_BIN_DIR", filepath.Join(workspaceDir, "bin")) + os.Setenv("OBOL_DATA_DIR", filepath.Join(workspaceDir, "data")) + + configDir := os.Getenv("OBOL_CONFIG_DIR") + binDir := os.Getenv("OBOL_BIN_DIR") + + obolBin := filepath.Join(binDir, "obol") + kubeconfigPath := filepath.Join(configDir, "kubeconfig.yaml") + kubectlBin := filepath.Join(binDir, "kubectl") + + // ── Step 1: Build obol binary ──────────────────────────────────── + log.Println("═══ Step 1: Building obol binary ═══") + if err := buildObol(projectRoot, obolBin); err != nil { + log.Fatalf("build obol: %v", err) + } + integrationObolBin = obolBin + + // ── Step 2: obol stack init + up ───────────────────────────────── + log.Println("═══ Step 2: obol stack init + up (3-5 minutes) ═══") + if err := runObol(obolBin, "stack", "init", "--backend", "k3d", "--force"); err != nil { + log.Fatalf("obol stack init: %v", err) + } + if err := runObol(obolBin, "stack", "up"); err != nil { + log.Fatalf("obol stack up: %v", err) + } + + integrationKubectlBin = kubectlBin + integrationKubeconfig = kubeconfigPath + + // Wait for core infrastructure. + log.Println(" Waiting for x402-verifier...") + if err := waitForPod(kubectlBin, kubeconfigPath, "x402", "app=x402-verifier", 180*time.Second); err != nil { + teardown(obolBin) + log.Fatalf("x402-verifier not ready: %v", err) + } + log.Println(" Waiting for LiteLLM...") + if err := waitForPod(kubectlBin, kubeconfigPath, "llm", "app=litellm", 300*time.Second); err != nil { + teardown(obolBin) + log.Fatalf("LiteLLM not ready: %v", err) + } + + // ── Step 3: obol model setup ───────────────────────────────────── + log.Println("═══ Step 3: obol model setup ═══") + if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" { + log.Println(" Configuring Anthropic provider") + if err := runObol(obolBin, "model", "setup", "--provider", "anthropic", "--api-key", apiKey); err != nil { + teardown(obolBin) + log.Fatalf("obol model setup anthropic: %v", err) + } + integrationModel = "claude-sonnet-4-20250514" + } else if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" { + log.Println(" Configuring OpenAI provider") + if err := runObol(obolBin, "model", "setup", "--provider", "openai", "--api-key", apiKey); err != nil { + teardown(obolBin) + log.Fatalf("obol model setup openai: %v", err) + } + integrationModel = "gpt-4o-mini" + } else { + log.Println(" No cloud API key, using default Ollama") + integrationModel = "llama3.2" + } + if envModel := os.Getenv("OBOL_TEST_MODEL"); envModel != "" { + integrationModel = envModel + } + + // ── Step 4: obol sell pricing ──────────────────────────────────── + log.Println("═══ Step 4: obol sell pricing ═══") + if err := runObol(obolBin, "sell", "pricing", + "--wallet", serviceOfferPayTo, + "--chain", "base-sepolia"); err != nil { + teardown(obolBin) + log.Fatalf("obol sell pricing: %v", err) + } + + // ── Step 5: obol agent init ────────────────────────────────────── + log.Println("═══ Step 5: obol agent init (deploys agent + RBAC + monetize skill) ═══") + if err := runObol(obolBin, "agent", "init"); err != nil { + teardown(obolBin) + log.Fatalf("obol agent init: %v", err) + } + + // Wait for the obol-agent pod to be Running. + log.Println(" Waiting for obol-agent pod...") + if err := waitForPod(kubectlBin, kubeconfigPath, "openclaw-obol-agent", "app=openclaw", 300*time.Second); err != nil { + teardown(obolBin) + log.Fatalf("obol-agent not ready: %v", err) + } + + // ── Step 6: obol sell http ─────────────────────────────────────── + log.Println("═══ Step 6: obol sell http (creates ServiceOffer CR) ═══") + if err := runObol(obolBin, "sell", "http", serviceOfferName, + "--wallet", serviceOfferPayTo, + "--chain", "base-sepolia", + "--price", "0.001", + "--upstream", "litellm", + "--port", "4000", + "--namespace", serviceOfferNamespace, + "--health-path", "/health/readiness"); err != nil { + teardown(obolBin) + log.Fatalf("obol sell http: %v", err) + } + + // ── Step 7: Wait for agent reconciliation ──────────────────────── + log.Println("═══ Step 7: Waiting for agent to reconcile ServiceOffer ═══") + if err := waitForServiceOfferReady(kubectlBin, kubeconfigPath, serviceOfferName, serviceOfferNamespace, 180*time.Second); err != nil { + // If the heartbeat hasn't fired yet, manually trigger reconciliation. + log.Println(" ServiceOffer not Ready, triggering manual reconciliation...") + triggerReconciliation(kubectlBin, kubeconfigPath) + if err := waitForServiceOfferReady(kubectlBin, kubeconfigPath, serviceOfferName, serviceOfferNamespace, 120*time.Second); err != nil { + teardown(obolBin) + log.Fatalf("ServiceOffer not Ready after reconciliation: %v", err) + } + } + + // Restart x402-verifier to pick up the pricing route added by reconciliation. + log.Println(" Restarting x402-verifier...") + _ = kubectl.RunSilent(kubectlBin, kubeconfigPath, "rollout", "restart", "deployment/x402-verifier", "-n", "x402") + _ = waitForPod(kubectlBin, kubeconfigPath, "x402", "app=x402-verifier", 120*time.Second) + + // Let Traefik pick up the new HTTPRoute. + time.Sleep(5 * time.Second) + + integrationRoutePath = "/services/" + serviceOfferName + "/v1/chat/completions" + integrationPayTo = serviceOfferPayTo + integrationReady = true + + log.Printf("═══ Bootstrap complete: route=%s model=%s ═══", integrationRoutePath, integrationModel) + + code := m.Run() + teardown(obolBin) + os.Exit(code) +} + +// TestBDDIntegration runs the BDD scenarios. +// +// go test -tags integration -v -run TestBDDIntegration -timeout 20m ./internal/x402/ +func TestBDDIntegration(t *testing.T) { + if !integrationReady { + t.Skip("integration bootstrap did not complete") + } + + suite := godog.TestSuite{ + ScenarioInitializer: func(ctx *godog.ScenarioContext) { + w := newIntegrationWorld(t) + + ctx.After(func(gctx context.Context, sc *godog.Scenario, err error) (context.Context, error) { + w.cleanup() + return gctx, nil + }) + + registerIntegrationSteps(ctx, w) + }, + Options: &godog.Options{ + Format: "pretty", + Paths: []string{"features/integration_payment_flow.feature"}, + Tags: "@integration", + TestingT: t, + }, + } + + if suite.Run() != 0 { + t.Fatal("integration BDD scenarios failed") + } +} + +// ── Bootstrap helpers ──────────────────────────────────────────────────────── + +func findProjectRoot() string { + dir, _ := os.Getwd() + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + log.Fatal("could not find project root (go.mod)") + } + dir = parent + } +} + +func buildObol(projectRoot, outputPath string) error { + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return err + } + os.Remove(outputPath) + cmd := exec.Command("go", "build", "-o", outputPath, "./cmd/obol") + cmd.Dir = projectRoot + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func runObol(obolBin string, args ...string) error { + cmd := exec.Command(obolBin, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + return cmd.Run() +} + +func runObolOutput(obolBin string, args ...string) (string, error) { + cmd := exec.Command(obolBin, args...) + cmd.Env = os.Environ() + out, err := cmd.CombinedOutput() + return string(out), err +} + +func waitForPod(kubectlBin, kubeconfig, namespace, labelSelector string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + out, err := kubectl.Output(kubectlBin, kubeconfig, "get", "pods", "-n", namespace, + "-l", labelSelector, "--no-headers") + if err == nil && strings.Contains(out, "Running") { + log.Printf(" ✓ %s in %s is Running", labelSelector, namespace) + return nil + } + time.Sleep(5 * time.Second) + } + return fmt.Errorf("timeout waiting for pod %s in %s", labelSelector, namespace) +} + +// waitForServiceOfferReady polls the ServiceOffer until Ready=True. +func waitForServiceOfferReady(kubectlBin, kubeconfig, name, namespace string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + out, err := kubectl.Output(kubectlBin, kubeconfig, + "get", "serviceoffers.obol.org", name, "-n", namespace, + "-o", "jsonpath={.status.conditions[?(@.type=='Ready')].status}") + if err == nil && strings.TrimSpace(out) == "True" { + log.Printf(" ✓ ServiceOffer %s/%s is Ready", namespace, name) + return nil + } + time.Sleep(5 * time.Second) + } + // Log current conditions for debugging. + out, _ := kubectl.Output(kubectlBin, kubeconfig, + "get", "serviceoffers.obol.org", name, "-n", namespace, + "-o", "jsonpath={range .status.conditions[*]}{.type}: {.status} ({.message}){\"\\n\"}{end}") + log.Printf(" ServiceOffer conditions:\n%s", out) + return fmt.Errorf("timeout waiting for ServiceOffer %s/%s to be Ready", namespace, name) +} + +// triggerReconciliation manually runs monetize.py inside the obol-agent pod. +// This simulates the heartbeat cron firing. +func triggerReconciliation(kubectlBin, kubeconfig string) { + out, err := kubectl.Output(kubectlBin, kubeconfig, + "exec", "-i", "-n", "openclaw-obol-agent", "deploy/openclaw", "-c", "openclaw", + "--", "python3", "/data/.openclaw/skills/sell/scripts/monetize.py", "process", "--all") + if err != nil { + log.Printf(" manual reconciliation error: %v\n%s", err, out) + } else { + log.Printf(" reconciliation output:\n%s", out) + } +} + +func decodeBase64(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + decoded, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return s + } + return string(decoded) +} + +func teardown(obolBin string) { + log.Println("═══ Tearing down ═══") + + // Delete the ServiceOffer via CLI (tests the real cleanup path). + if integrationObolBin != "" { + _ = runObol(integrationObolBin, "sell", "delete", serviceOfferName, + "-n", serviceOfferNamespace, "-f") + } + + if err := runObol(obolBin, "stack", "down"); err != nil { + log.Printf("Warning: obol stack down failed: %v", err) + } + if err := runObol(obolBin, "stack", "purge", "-f"); err != nil { + log.Printf("Warning: obol stack purge failed: %v", err) + } +} diff --git a/internal/x402/config.go b/internal/x402/config.go index 821c6ff..087b5fb 100644 --- a/internal/x402/config.go +++ b/internal/x402/config.go @@ -51,6 +51,12 @@ type RouteRule struct { // Network overrides the global chain for this route (human-friendly). // If empty, falls back to PricingConfig.Chain. Network string `yaml:"network,omitempty"` + + // UpstreamAuth is injected as the Authorization header on approved requests. + // The x402-verifier sets this header in its 200 response; Traefik copies it + // to the forwarded request via authResponseHeaders. This lets the upstream + // (e.g., LiteLLM) authenticate the request without exposing the key to buyers. + UpstreamAuth string `yaml:"upstreamAuth,omitempty"` } // LoadConfig reads and parses a pricing configuration YAML file. diff --git a/internal/x402/features/integration_payment_flow.feature b/internal/x402/features/integration_payment_flow.feature new file mode 100644 index 0000000..69267db --- /dev/null +++ b/internal/x402/features/integration_payment_flow.feature @@ -0,0 +1,97 @@ +@integration +Feature: x402 Payment Flow — Real User Journey + As an operator running the Obol Stack + I want to sell inference and have buyers pay for it + So that the full production path is verified end-to-end + + # This test follows the EXACT user journey — no kubectl shortcuts. + # Every step maps to a real CLI command or agent behavior. + # + # Run (full bootstrap — creates cluster from scratch): + # go test -tags integration -v -run TestBDDIntegration -timeout 20m ./internal/x402/ + # + # Run (skip bootstrap — use existing cluster): + # OBOL_INTEGRATION_SKIP_BOOTSTRAP=true OBOL_TEST_MODEL=qwen3.5:9b \ + # go test -tags integration -v -run TestBDDIntegration -timeout 10m ./internal/x402/ + + Background: + Given an Anvil fork of Base Sepolia is running + And the buyer has 10 USDC on the fork + And a facilitator is running against the fork + And the x402-verifier is patched to use the facilitator + And a buyer with Anvil key "2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" + + # ─── Sell-side: the operator creates a ServiceOffer via CLI ───────── + + @integration @local @sell + Scenario: Operator sells inference via CLI and agent reconciles + When the operator runs "obol sell http" to create a ServiceOffer + And the agent reconciles the ServiceOffer + Then the ServiceOffer status is "Ready" + And a Middleware "x402-bdd-test" exists in the offer namespace + And an HTTPRoute "so-bdd-test" exists in the offer namespace + And the x402-pricing ConfigMap contains a route for the offer + + # ─── Buy-side: unpaid request gets 402 ────────────────────────────── + + @integration @local @payment-gate + 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 a valid accepts array + + # ─── Buy-side: paid request returns real inference ────────────────── + + @integration @local @payment + Scenario: Paid request returns real inference + When the buyer sends an unpaid POST to the priced route + Then the response status is 402 + When the buyer signs an EIP-712 payment from the 402 response + And the buyer sends the paid POST to the priced route + Then the response status is 200 + And the response contains a real inference result + And the facilitator received at least 1 verify call + + # ─── Buy-side: discovery-driven payment ───────────────────────────── + + @integration @local @discovery + Scenario: Full discovery-to-payment cycle + When the buyer sends an unpaid POST to the priced route + Then the response status is 402 + And the 402 response contains payTo and price and network + When the buyer constructs payment from the discovered pricing + And the buyer sends the paid POST to the priced route + Then the response status is 200 + And the response contains non-empty inference content + + # ─── Buy-side: through Cloudflare tunnel ──────────────────────────── + + @integration @tunnel + Scenario: Paid request through Cloudflare tunnel + Given the Cloudflare tunnel is reachable + When the buyer sends an unpaid POST through the tunnel + Then the response status is 402 + When the buyer signs an EIP-712 payment from the 402 response + And the buyer sends the paid POST through the tunnel + Then the response status is 200 + And the response contains a real inference result + + # ─── Buy-side: discover via tunnel → probe → verify ────────────── + + @integration @tunnel @discover + Scenario: Agent discovers registered service through tunnel + Given the Cloudflare tunnel is reachable + When the agent fetches the registration JSON from the tunnel + Then the registration contains x402Support + And the registration contains a service endpoint + When the agent probes the tunnel service endpoint + Then the probe returns 402 with pricing info + + # ─── Cleanup: operator deletes the offer ──────────────────────────── + + @integration @local @cleanup + Scenario: Operator deletes ServiceOffer and resources are cleaned up + When the operator deletes the ServiceOffer via CLI + Then the ServiceOffer no longer exists + And the x402-pricing ConfigMap does not contain a route for the offer diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index fc835c8..5130457 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -131,17 +131,21 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) { r.Method = method } - // Reuse x402-go's middleware wrapping a dummy handler that returns 200. - // The middleware either: - // - Returns 402 (no/invalid payment) — Traefik forwards this to the client - // - Calls the inner handler (valid payment) → 200 → Traefik allows the request + // Reuse x402-go's 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. middleware := x402http.NewX402Middleware(&x402http.Config{ FacilitatorURL: cfg.FacilitatorURL, PaymentRequirements: []x402lib.PaymentRequirement{requirement}, VerifyOnly: cfg.VerifyOnly, }) + upstreamAuth := rule.UpstreamAuth inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if upstreamAuth != "" { + w.Header().Set("Authorization", upstreamAuth) + } w.WriteHeader(http.StatusOK) }) From eadcffd1f1f07eabf3386efef662c8b6dbaef9a1 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Thu, 5 Mar 2026 22:34:31 +0100 Subject: [PATCH 2/4] fix: scope agent secrets access to litellm-secrets only The previous commit added secrets:get to the cluster-wide openclaw-monetize-workload ClusterRole, which gave the agent read access to ALL secrets in ALL namespaces. Fix: remove secrets from ClusterRole and add a namespaced Role in the llm namespace scoped to litellm-secrets only via resourceNames restriction. Same pattern as the existing openclaw-x402-pricing Role in the x402 namespace. Verified: - Agent can read litellm-secrets in llm namespace (200 OK) - Agent cannot list kube-system secrets (403 Forbidden) - All 7 BDD scenarios pass with scoped RBAC --- .../templates/obol-agent-monetize-rbac.yaml | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) 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 4e333a3..6d8eb16 100644 --- a/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml +++ b/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml @@ -78,10 +78,6 @@ rules: - apiGroups: ["apps"] resources: ["deployments"] verbs: ["create", "update", "patch", "delete"] - # Secrets - read upstream auth tokens (e.g., LiteLLM master key) - - apiGroups: [""] - resources: ["secrets"] - verbs: ["get"] --- #------------------------------------------------------------------------------ @@ -152,3 +148,38 @@ subjects: - kind: ServiceAccount name: openclaw namespace: openclaw-obol-agent + +--- +#------------------------------------------------------------------------------ +# Role - LiteLLM secrets read access (scoped to llm namespace) +# The agent reads the LiteLLM master key to write upstreamAuth in pricing +# routes. Scoped to a single secret to prevent broad secret access. +#------------------------------------------------------------------------------ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: openclaw-litellm-secrets + namespace: llm +rules: + - apiGroups: [""] + resources: ["secrets"] + resourceNames: ["litellm-secrets"] + verbs: ["get"] + +--- +#------------------------------------------------------------------------------ +# RoleBinding - Binds LiteLLM secrets access to agent ServiceAccount +#------------------------------------------------------------------------------ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: openclaw-litellm-secrets-binding + namespace: llm +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: openclaw-litellm-secrets +subjects: + - kind: ServiceAccount + name: openclaw + namespace: openclaw-obol-agent From 90ccd5c2f1ed29a7cc20faded903d0fd1fd43472 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 6 Mar 2026 00:44:58 +0100 Subject: [PATCH 3/4] feat: enrich ERC-8004 registration with OASF metadata + on-chain filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enriches agent registration with machine-readable metadata for discovery: ## Off-chain (Registration JSON) - Add OASF service entry with skills[] and domains[] taxonomy paths Default for inference: skills=["natural_language_processing/text_generation/chat_completion"] Default for inference: domains=["technology/artificial_intelligence"] - Build richer description from spec (model name, price, type) - Always emit supportedTrust field (empty array if none) - CLI flags: --register-skills, --register-domains for custom taxonomy ## On-chain (setMetadata) - New _set_metadata_on_chain() in monetize.py — ABI-encodes and signs setMetadata(uint256, string, bytes) via remote-signer + eRPC - Sets "x402.supported" = 0x01 and "service.type" = "inference"|"http" after successful register() — indexed MetadataSet events enable eth_getLogs topic-based filtering without fetching every JSON ## Discovery - discovery.py search --x402-only: filter by MetadataSet events where indexedMetadataKey == keccak256("x402.supported") - discovery.py search --filter : generic metadata key filter ## Types - ServiceDef gains Skills/Domains fields (json:"skills/domains,omitempty") ## BDD - Discover scenario now validates OASF skills and domains in registration 7 scenarios, 77 steps all pass. --- cmd/obol/sell.go | 14 ++ .../skills/discovery/scripts/discovery.py | 90 +++++++++++- .../embed/skills/sell/scripts/monetize.py | 132 +++++++++++++++++- internal/erc8004/types.go | 10 +- internal/x402/bdd_integration_steps_test.go | 50 +++++++ .../features/integration_payment_flow.feature | 2 + 6 files changed, 289 insertions(+), 9 deletions(-) diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 8c65604..06443ad 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -279,6 +279,14 @@ Examples: Name: "register-image", Usage: "Agent image URL for ERC-8004 registration", }, + &cli.StringSliceFlag{ + Name: "register-skills", + Usage: "OASF skills for discovery (e.g. natural_language_processing/text_generation)", + }, + &cli.StringSliceFlag{ + Name: "register-domains", + Usage: "OASF domains for discovery (e.g. technology/artificial_intelligence)", + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.NArg() == 0 { @@ -339,6 +347,12 @@ Examples: if img := cmd.String("register-image"); img != "" { reg["image"] = img } + if skills := cmd.StringSlice("register-skills"); len(skills) > 0 { + reg["skills"] = skills + } + if domains := cmd.StringSlice("register-domains"); len(domains) > 0 { + reg["domains"] = domains + } spec["registration"] = reg } diff --git a/internal/embed/skills/discovery/scripts/discovery.py b/internal/embed/skills/discovery/scripts/discovery.py index 89fd678..aadab6d 100644 --- a/internal/embed/skills/discovery/scripts/discovery.py +++ b/internal/embed/skills/discovery/scripts/discovery.py @@ -65,6 +65,9 @@ # Event topic: Registered(uint256 indexed agentId, string agentURI, address indexed owner) REGISTERED_TOPIC = "0xca52e62c367d81bb2e328eb795f7c7ba24afb478408a26c0e201d155c449bc4a" +# Event topic: MetadataSet(uint256 indexed agentId, string indexed indexedMetadataKey, string metadataKey, bytes metadataValue) +METADATA_SET_TOPIC = "0x2c149ed548c6d2993cd73efe187df6eccabe4538091b33adbd25fafdb8a1468b" + # --------------------------------------------------------------------------- # RPC helpers @@ -277,6 +280,70 @@ def search_registered_events(chain=None, limit=20, from_block=None, lookback=100 return events +def search_by_metadata(metadata_key, chain=None, limit=20, lookback=10000): + """Search for agents that have a specific on-chain metadata key set. + + Uses MetadataSet events with indexed metadataKey topic for efficient filtering. + Returns a list of unique agentIds that have the given metadata set. + """ + registry = _get_registry(chain or DEFAULT_CHAIN) + + # Get latest block and scan backwards. + latest_hex = _rpc("eth_blockNumber", [], chain) + latest = int(latest_hex, 16) if isinstance(latest_hex, str) else 0 + start = max(0, latest - lookback) + + # The second topic is keccak256(metadataKey) since it's an indexed string. + import hashlib + try: + from Crypto.Hash import keccak as _keccak + h = _keccak.new(digest_bits=256) + h.update(metadata_key.encode("utf-8")) + key_topic = "0x" + h.hexdigest() + except ImportError: + # Fallback: precomputed for common keys + _precomputed = { + "x402.supported": "0x" + hashlib.sha256(b"x402.supported").hexdigest(), # Not keccak — need proper hash + } + # Use cast if available + import subprocess + try: + result = subprocess.run(["cast", "keccak", metadata_key], capture_output=True, text=True, timeout=5) + key_topic = result.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + print(f"Warning: cannot compute keccak256 for key filter '{metadata_key}'", file=sys.stderr) + return [] + + params = { + "address": registry, + "topics": [METADATA_SET_TOPIC, None, key_topic], # [event_sig, agentId(any), metadataKey] + "fromBlock": hex(start), + "toBlock": "latest", + } + logs = _rpc("eth_getLogs", [params], chain) + if not logs: + return [] + + # Extract unique agentIds. + seen = set() + agents = [] + for log in logs: + topics = log.get("topics", []) + if len(topics) >= 2: + agent_id = int(topics[1], 16) + if agent_id not in seen: + seen.add(agent_id) + agents.append({ + "agentId": agent_id, + "blockNumber": int(log.get("blockNumber", "0x0"), 16), + }) + + agents.sort(key=lambda e: e["blockNumber"], reverse=True) + if limit and limit > 0: + agents = agents[:limit] + return agents + + def fetch_agent_uri_json(uri): """Fetch the registration JSON from an agent's URI. @@ -329,9 +396,26 @@ def cmd_search(args): """Search for recently registered agents via Registered events.""" chain = args.chain or DEFAULT_CHAIN limit = args.limit or 20 - print(f"Searching for agents on {chain} (limit: {limit})...") - lookback = getattr(args, "lookback", 10000) + x402_only = getattr(args, "x402_only", False) + filter_key = getattr(args, "filter", None) + + # Metadata-filtered search (uses MetadataSet events instead of Registered). + if x402_only or filter_key: + key = filter_key or "x402.supported" + print(f"Searching for agents with on-chain metadata '{key}' on {chain} (limit: {limit})...") + agents = search_by_metadata(key, chain=chain, limit=limit, lookback=lookback) + if not agents: + print(f"No agents found with metadata key '{key}'.") + return + print(f"\nFound {len(agents)} agent(s) with '{key}':\n") + print(f"{'Agent ID':>10} {'Block':>10}") + print(f"{'-' * 10} {'-' * 10}") + for a in agents: + print(f"{a['agentId']:>10} {a['blockNumber']:>10}") + return + + print(f"Searching for agents on {chain} (limit: {limit})...") events = search_registered_events(chain=chain, limit=limit, lookback=lookback) if not events: print("No registered agents found.") @@ -464,6 +548,8 @@ def main(): p_search.add_argument("--chain", default=None, help="Chain/network name (default: base-sepolia)") p_search.add_argument("--limit", type=int, default=20, help="Max results (default: 20)") p_search.add_argument("--lookback", type=int, default=10000, help="Scan last N blocks (default: 10000)") + p_search.add_argument("--x402-only", action="store_true", help="Only show agents with x402.supported metadata") + p_search.add_argument("--filter", default=None, help="Filter by on-chain metadata key (e.g. service.type)") # agent p_agent = sub.add_parser("agent", help="Get agent details by ID") diff --git a/internal/embed/skills/sell/scripts/monetize.py b/internal/embed/skills/sell/scripts/monetize.py index afb92a3..3fe78b9 100644 --- a/internal/embed/skills/sell/scripts/monetize.py +++ b/internal/embed/skills/sell/scripts/monetize.py @@ -89,6 +89,9 @@ def _validate_network(network): # keccak256("register(string)")[:4] REGISTER_SELECTOR = "f2c298be" +# keccak256("setMetadata(uint256,string,bytes)")[:4] +SET_METADATA_SELECTOR = "ce1b815f" + # keccak256("Registered(uint256,string,address)") REGISTERED_TOPIC = "0xca52e62c367d81bb2e328eb795f7c7ba24afb478408a26c0e201d155c449bc4a" @@ -359,6 +362,89 @@ def _parse_registered_event(receipt): raise RuntimeError("Registered event not found in transaction receipt") +def _abi_encode_uint256(n): + """ABI-encode a uint256 as 32 bytes.""" + return n.to_bytes(32, byteorder="big") + + +def _abi_encode_bytes(data): + """ABI-encode a bytes value (offset + length + padded data).""" + length = len(data) + padded = data + b"\x00" * (32 - length % 32) if length % 32 != 0 else data + return length.to_bytes(32, byteorder="big") + padded + + +def _set_metadata_on_chain(agent_id, key, value_bytes): + """Call setMetadata(uint256, string, bytes) on the Identity Registry. + + Sets indexed on-chain metadata that buyers can filter via MetadataSet events. + Uses the same remote-signer + eRPC pattern as _register_on_chain. + """ + from_addr = _get_signing_address() + + # ABI-encode setMetadata(uint256 agentId, string metadataKey, bytes metadataValue) + # Layout: selector + agentId(32) + offset_key(32) + offset_value(32) + key_data + value_data + agent_id_enc = _abi_encode_uint256(agent_id) + key_enc = _abi_encode_string(key) + value_enc = _abi_encode_bytes(value_bytes) + + # Dynamic offsets: key starts at 3*32=96, value starts at 96+len(key_enc) + offset_key = (96).to_bytes(32, byteorder="big") + offset_value = (96 + len(key_enc)).to_bytes(32, byteorder="big") + + calldata = ( + bytes.fromhex(SET_METADATA_SELECTOR) + + agent_id_enc + + offset_key + + offset_value + + key_enc + + value_enc + ) + calldata_hex = "0x" + calldata.hex() + + nonce_hex = _rpc("eth_getTransactionCount", [from_addr, "pending"]) + nonce = int(nonce_hex, 16) + + base_fee = int(_rpc("eth_gasPrice"), 16) + try: + max_priority = int(_rpc("eth_maxPriorityFeePerGas"), 16) + except (RuntimeError, urllib.error.URLError): + max_priority = 1_000_000_000 + max_fee = base_fee * 2 + max_priority + + tx_obj = {"from": from_addr, "to": IDENTITY_REGISTRY, "data": calldata_hex} + gas_limit = int(int(_rpc("eth_estimateGas", [tx_obj]), 16) * 1.3) + + tx_req = { + "chain_id": BASE_SEPOLIA_CHAIN_ID, + "to": IDENTITY_REGISTRY, + "nonce": nonce, + "gas_limit": gas_limit, + "max_fee_per_gas": max_fee, + "max_priority_fee_per_gas": max_priority, + "value": "0x0", + "data": calldata_hex, + } + result = _remote_signer_post(f"/api/v1/sign/{from_addr}/transaction", tx_req) + signed_tx = result.get("signed_transaction", "") + if not signed_tx: + raise RuntimeError("Remote-signer returned empty signed transaction") + + tx_hash = _rpc("eth_sendRawTransaction", [signed_tx]) + + # Wait for receipt (short timeout — metadata is non-critical). + for _ in range(30): + receipt = _rpc("eth_getTransactionReceipt", [tx_hash]) + if receipt is not None: + status = int(receipt.get("status", "0x0"), 16) + if status != 1: + print(f" Warning: setMetadata tx reverted (tx: {tx_hash})") + return + return + time.sleep(2) + print(f" Warning: setMetadata receipt timeout (tx: {tx_hash})") + + # --------------------------------------------------------------------------- # Reconciliation stages # --------------------------------------------------------------------------- @@ -835,6 +921,16 @@ def stage_registered(spec, ns, name, token, ssl_ctx): f"Agent {agent_id} on base-sepolia (tx: {tx_hash[:18]}...)", token, ssl_ctx) print(f" Registered as agent {agent_id} (tx: {tx_hash})") + # Set on-chain metadata for indexed discovery (MetadataSet events). + # Buyers can filter agents by these keys without fetching every registration JSON. + offer_type = spec.get("type", "http") + try: + print(f" Setting on-chain metadata: x402.supported=true, service.type={offer_type}") + _set_metadata_on_chain(agent_id, "x402.supported", b"\x01") + _set_metadata_on_chain(agent_id, "service.type", offer_type.encode("utf-8")) + except Exception as e: + print(f" Warning: on-chain metadata failed (non-blocking): {e}") + # Publish the ERC-8004 registration JSON (agent-managed resources). _publish_registration_json(spec, ns, name, agent_id, tx_hash, token, ssl_ctx) return True @@ -863,10 +959,29 @@ def _publish_registration_json(spec, ns, name, agent_id, tx_hash, token, ssl_ctx # ── 1. Build the registration JSON ───────────────────────────────────── # ERC-8004 REQUIRED fields: type, name, description, image, services, # x402Support, active, registrations. All are always emitted. + # Build a richer description from spec fields. + offer_type = spec.get("type", "http") + price_info = get_effective_price(spec) + model_info = spec.get("model", {}) + default_desc = f"x402 payment-gated {offer_type} service: {name}" + if model_info.get("name"): + default_desc = f"{model_info['name']} inference via x402 micropayments ({price_info} USDC/request)" + + # OASF skills and domains for machine-readable capability discovery. + # Defaults based on service type; overridden by spec.registration.skills/domains. + default_skills = { + "inference": ["natural_language_processing/text_generation/chat_completion"], + } + default_domains = { + "inference": ["technology/artificial_intelligence"], + } + skills = registration.get("skills", default_skills.get(offer_type, [])) + domains = registration.get("domains", default_domains.get(offer_type, [])) + doc = { "type": "https://eips.ethereum.org/EIPS/eip-8004#registration-v1", "name": registration.get("name", name), - "description": registration.get("description", f"x402 payment-gated service: {name}"), + "description": registration.get("description", default_desc), "image": registration.get("image", f"{base_url}/agent-icon.png"), "x402Support": True, "active": True, @@ -874,7 +989,7 @@ def _publish_registration_json(spec, ns, name, agent_id, tx_hash, token, ssl_ctx { "name": "web", "endpoint": f"{base_url}{url_path}", - } + }, ], "registrations": [ { @@ -882,9 +997,18 @@ def _publish_registration_json(spec, ns, name, agent_id, tx_hash, token, ssl_ctx "agentRegistry": f"eip155:{BASE_SEPOLIA_CHAIN_ID}:{IDENTITY_REGISTRY}", } ], + "supportedTrust": registration.get("supportedTrust", []), } - if registration.get("supportedTrust"): - doc["supportedTrust"] = registration["supportedTrust"] + + # Add OASF service entry for structured capability discovery. + if skills or domains: + oasf_entry = {"name": "OASF", "version": "0.8"} + if skills: + oasf_entry["skills"] = skills + if domains: + oasf_entry["domains"] = domains + doc["services"].append(oasf_entry) + if registration.get("services"): for svc in registration["services"]: if svc.get("endpoint"): diff --git a/internal/erc8004/types.go b/internal/erc8004/types.go index 2c934ff..a572ee9 100644 --- a/internal/erc8004/types.go +++ b/internal/erc8004/types.go @@ -28,10 +28,14 @@ type AgentRegistration struct { const RegistrationType = "https://eips.ethereum.org/EIPS/eip-8004#registration-v1" // ServiceDef describes an endpoint the agent exposes. +// For OASF entries (name="OASF"), Skills and Domains provide machine-readable +// taxonomy for agent discovery. See https://schema.oasf.outshift.com/ type ServiceDef struct { - Name string `json:"name"` // e.g., "web", "A2A", "MCP" - Endpoint string `json:"endpoint"` // full URL - Version string `json:"version,omitempty"` // protocol version (SHOULD per spec) + Name string `json:"name"` // e.g., "web", "A2A", "MCP", "OASF" + Endpoint string `json:"endpoint,omitempty"` // full URL (omitempty for OASF entries) + Version string `json:"version,omitempty"` // protocol version (SHOULD per spec) + Skills []string `json:"skills,omitempty"` // OASF skill taxonomy paths + Domains []string `json:"domains,omitempty"` // OASF domain taxonomy paths } // OnChainReg links the registration to its on-chain record. diff --git a/internal/x402/bdd_integration_steps_test.go b/internal/x402/bdd_integration_steps_test.go index 55d15b6..c79849f 100644 --- a/internal/x402/bdd_integration_steps_test.go +++ b/internal/x402/bdd_integration_steps_test.go @@ -466,6 +466,56 @@ func registerIntegrationSteps(ctx *godog.ScenarioContext, w *integrationWorld) { return nil }) + ctx.Then(`^the registration contains OASF skills$`, func() error { + if w.registrationJSON == nil { + return fmt.Errorf("no registration JSON fetched") + } + services, ok := w.registrationJSON["services"].([]interface{}) + if !ok { + return fmt.Errorf("no services array in registration") + } + for _, s := range services { + svc, ok := s.(map[string]interface{}) + if !ok { + continue + } + if svc["name"] == "OASF" { + skills, ok := svc["skills"].([]interface{}) + if !ok || len(skills) == 0 { + return fmt.Errorf("OASF service entry has no skills") + } + w.t.Logf("integration: ✓ OASF skills: %v", skills) + return nil + } + } + return fmt.Errorf("no OASF service entry found in registration services") + }) + + ctx.Then(`^the registration contains OASF domains$`, func() error { + if w.registrationJSON == nil { + return fmt.Errorf("no registration JSON fetched") + } + services, ok := w.registrationJSON["services"].([]interface{}) + if !ok { + return fmt.Errorf("no services array in registration") + } + for _, s := range services { + svc, ok := s.(map[string]interface{}) + if !ok { + continue + } + if svc["name"] == "OASF" { + domains, ok := svc["domains"].([]interface{}) + if !ok || len(domains) == 0 { + return fmt.Errorf("OASF service entry has no domains") + } + w.t.Logf("integration: ✓ OASF domains: %v", domains) + return nil + } + } + return fmt.Errorf("no OASF service entry found in registration services") + }) + ctx.When(`^the agent probes the tunnel service endpoint$`, func() error { if w.discoveredEndpoint == "" { return fmt.Errorf("no service endpoint discovered") diff --git a/internal/x402/features/integration_payment_flow.feature b/internal/x402/features/integration_payment_flow.feature index 69267db..0d22ec1 100644 --- a/internal/x402/features/integration_payment_flow.feature +++ b/internal/x402/features/integration_payment_flow.feature @@ -85,6 +85,8 @@ Feature: x402 Payment Flow — Real User Journey When the agent fetches the registration JSON from the tunnel Then the registration contains x402Support And the registration contains a service endpoint + And the registration contains OASF skills + And the registration contains OASF domains When the agent probes the tunnel service endpoint Then the probe returns 402 with pricing info From 20a34ddf2af809f16249ba97ba3ab82385a8b610 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Fri, 6 Mar 2026 11:18:49 +0100 Subject: [PATCH 4/4] docs: add BDD x402 testing to dev skill Update obol-stack-dev skill with: - BDD integration test documentation (7 scenarios, 77 steps) - How to run: skip-bootstrap (2min) vs full bootstrap (15min) - TestMain sequence (real user journey, no shortcuts) - Per-scenario infrastructure (Anvil fork, mock facilitator) - File locations (feature, test runner, step definitions) - Updated triggers: x402, sell, buy, BDD, gherkin, payment, monetize --- .agents/skills/obol-stack-dev/SKILL.md | 6 +- .../references/integration-testing.md | 75 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/.agents/skills/obol-stack-dev/SKILL.md b/.agents/skills/obol-stack-dev/SKILL.md index b69f1f2..7344238 100644 --- a/.agents/skills/obol-stack-dev/SKILL.md +++ b/.agents/skills/obol-stack-dev/SKILL.md @@ -1,10 +1,10 @@ --- name: obol-stack-dev -description: Obol Stack development, testing, and LLM routing validation through LiteLLM. Use when developing, testing, or validating inference paths (Ollama, Anthropic, OpenAI) through the LiteLLM gateway, writing integration tests, or working with obol CLI wrappers. +description: Obol Stack development, testing, and validation. Covers LLM routing through LiteLLM, x402 payment flow (sell/buy), BDD integration tests (Gherkin/godog), ERC-8004 registration, and obol CLI wrappers. metadata: version: "2.0.0" domain: infrastructure - triggers: obol, litellm, openclaw, inference, integration test, model routing, smart routing, LLM proxy, provider setup + triggers: obol, litellm, openclaw, inference, integration test, model routing, smart routing, LLM proxy, provider setup, x402, sell, buy, BDD, gherkin, payment, monetize role: specialist scope: development-and-testing output-format: code-and-commands @@ -20,8 +20,10 @@ Complete guide for developing, testing, and validating the Obol Stack's LLM rout - Setting up the Obol Stack development environment - Testing LLM inference through LiteLLM (Ollama, Anthropic, OpenAI) - Writing or running integration tests for OpenClaw instances +- Running BDD integration tests for the x402 sell→discover→buy payment flow - Debugging model routing issues (401s, 500s, provider misconfig) - Understanding the 2-tier LLM architecture (LiteLLM gateway + per-instance config) +- Testing x402 payment gating, ERC-8004 registration, OASF metadata - Deploying and validating OpenClaw instances with different providers - Working with the `obol` CLI wrappers (kubectl, helm, helmfile, k9s) diff --git a/.agents/skills/obol-stack-dev/references/integration-testing.md b/.agents/skills/obol-stack-dev/references/integration-testing.md index 16a6ed3..21694cd 100644 --- a/.agents/skills/obol-stack-dev/references/integration-testing.md +++ b/.agents/skills/obol-stack-dev/references/integration-testing.md @@ -159,6 +159,81 @@ func TestIntegration_MyTest(t *testing.T) { 5. Deploy via `obol openclaw sync` 6. Model name format: `openai/` (always openai/ prefix through LiteLLM) +## BDD Integration Tests (x402 Payment Flow) + +A separate BDD test suite validates the full sell→discover→buy payment flow using Gherkin scenarios and godog. These tests live in `internal/x402/` and follow the **real user journey** — no kubectl shortcuts. + +### Running BDD Tests + +```bash +# Against existing cluster (fast, ~2min) +export OBOL_DEVELOPMENT=true +export OBOL_CONFIG_DIR=$(pwd)/.workspace/config +export OBOL_BIN_DIR=$(pwd)/.workspace/bin +export OBOL_DATA_DIR=$(pwd)/.workspace/data +export OBOL_INTEGRATION_SKIP_BOOTSTRAP=true +export OBOL_TEST_MODEL=qwen3.5:9b + +go test -tags integration -v -run TestBDDIntegration -timeout 10m ./internal/x402/ + +# Full bootstrap from scratch (~15min, creates cluster) +go test -tags integration -v -run TestBDDIntegration -timeout 20m ./internal/x402/ +``` + +### BDD Scenarios (7 total, 77 steps) + +| Scenario | What it validates | +|----------|------------------| +| Operator sells inference via CLI + agent reconciles | `obol sell http` → CRD → 6-stage reconciliation | +| Unpaid request returns 402 | x402 payment gate | +| Paid request returns real inference | EIP-712 → verify → LiteLLM → Ollama | +| Discovery-to-payment cycle | Parse 402 → sign → pay → 200 | +| Paid request through Cloudflare tunnel | Full flow via tunnel | +| Agent discovers service through tunnel | `.well-known` → x402Support → OASF skills/domains → probe 402 | +| Operator deletes + cleanup | CR + pricing route removed | + +### BDD Test Files + +| File | Role | +|------|------| +| `internal/x402/features/integration_payment_flow.feature` | Gherkin scenarios | +| `internal/x402/bdd_integration_test.go` | TestMain bootstrap (builds binary, stack init/up, agent init, sell) | +| `internal/x402/bdd_integration_steps_test.go` | Step definitions (Anvil, facilitator, payment signing) | + +### BDD TestMain Bootstrap (Full Mode) + +``` +1. go build ./cmd/obol +2. obol stack init + up +3. obol model setup (Anthropic/OpenAI/Ollama) +4. obol sell pricing --wallet ... --chain base-sepolia +5. obol agent init (deploys agent + RBAC + monetize skill) +6. obol sell http bdd-test --upstream litellm --port 4000 ... +7. Wait for agent reconciliation (6 stages → Ready) +8. Restart x402-verifier +``` + +### Skip-Bootstrap Mode + +Set `OBOL_INTEGRATION_SKIP_BOOTSTRAP=true` to use an existing cluster. Requires: +- ServiceOffer `bdd-test` in `llm` namespace (Ready) +- x402-verifier running +- LiteLLM running with at least one model +- Ollama running (for inference) + +### Per-Scenario Infrastructure + +Each scenario gets a fresh: +- Anvil fork (Base Sepolia, on random port) +- Mock facilitator (accepts all payments) +- x402-verifier patched to use the scenario's facilitator + +### Key Dependency + +```bash +go get github.com/cucumber/godog@v0.15.1 +``` + ## Timing and Timeouts | Operation | Typical Time | Timeout |