From e2f6b172f0e98486a9fac0aa1ee66fe8bd7323df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 1 May 2026 19:26:56 +0100 Subject: [PATCH 1/3] improve demo sell command --- cmd/obol/sell.go | 75 +++++++++++++------ cmd/obol/sell_test.go | 57 +++++++++++++- internal/serviceoffercontroller/render.go | 10 ++- .../render_builders_test.go | 39 ++++++++++ 4 files changed, 154 insertions(+), 27 deletions(-) diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index a07bd713..0c875e12 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -1001,13 +1001,13 @@ Types: Example: obol sell demo hello - obol sell demo blocks --chain base - obol sell demo oracle --price 0.01`, + obol sell demo blocks --chain base-sepolia + obol sell demo oracle --token OBOL --chain ethereum --price 0.01`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "wallet", Aliases: []string{"w"}, - Usage: "USDC recipient wallet address (auto-detected from remote-signer)", + Usage: "Token recipient wallet address (auto-detected from remote-signer)", Sources: cli.EnvVars("X402_WALLET"), }, &cli.StringFlag{ @@ -1015,9 +1015,14 @@ Example: Usage: "Payment chain (base, base-sepolia, ethereum)", Value: "base", }, + &cli.StringFlag{ + Name: "token", + Usage: "Payment token (USDC, OBOL)", + Value: "USDC", + }, &cli.StringFlag{ Name: "price", - Usage: "Override default per-request price in USDC", + Usage: "Override default per-request price (in token units)", }, &cli.StringFlag{ Name: "name", @@ -1068,6 +1073,17 @@ Example: chain := cmd.String("chain") + // Resolve token metadata. resolveAssetTerms may flip chain to ethereum + // for non-USDC tokens when --chain wasn't explicitly set. + assetTerms, err := resolveAssetTerms(cmd, &chain) + if err != nil { + return err + } + symbol := assetTerms.Symbol + if symbol == "" { + symbol = "USDC" + } + u.Infof("Deploying demo %q (%s)", typeName, spec.Description) // 1. Deploy demo backend (namespace + Deployment + Service). @@ -1076,7 +1092,7 @@ Example: } // 2. Create ServiceOffer. - soManifest := buildDemoServiceOffer(name, demoNamespace, chain, wallet, price, spec) + soManifest := buildDemoServiceOffer(name, demoNamespace, chain, wallet, price, spec, assetTerms) applyOut, err := kubectlApplyOutput(cfg, soManifest) if err != nil { return fmt.Errorf("apply ServiceOffer: %w", err) @@ -1085,7 +1101,7 @@ Example: if strings.Contains(applyOut, "configured") || strings.Contains(applyOut, "unchanged") { action = "updated" } - u.Successf("ServiceOffer %s/%s %s (type: http, price: %s USDC/req)", demoNamespace, name, action, price) + u.Successf("ServiceOffer %s/%s %s (type: http, price: %s %s/req)", demoNamespace, name, action, price, symbol) u.Infof("The controller will reconcile: health-check → payment gate → route") u.Infof("Check status: obol sell status %s -n %s", name, demoNamespace) @@ -1104,7 +1120,7 @@ Example: // 4. Print try-it instructions. u.Blank() - printDemoTryIt(u, name, typeName, price, chain, tunnelURL) + printDemoTryIt(u, name, typeName, price, symbol, chain, tunnelURL) return nil }, @@ -1263,7 +1279,20 @@ func demoRPCNetwork(paymentChain string) string { } // buildDemoServiceOffer returns a ServiceOffer manifest for a demo service. -func buildDemoServiceOffer(name, ns, chain, wallet, price string, spec demoSpec) map[string]any { +func buildDemoServiceOffer(name, ns, chain, wallet, price string, spec demoSpec, asset schemas.AssetTerms) map[string]any { + payment := map[string]any{ + "scheme": "exact", + "network": chain, + "payTo": wallet, + "maxTimeoutSeconds": 300, + "price": map[string]any{ + "perRequest": price, + }, + } + if !asset.IsZero() { + payment["asset"] = asset + } + return map[string]any{ "apiVersion": "obol.org/v1alpha1", "kind": "ServiceOffer", @@ -1279,16 +1308,8 @@ func buildDemoServiceOffer(name, ns, chain, wallet, price string, spec demoSpec) "port": 8080, "healthPath": "/health", }, - "payment": map[string]any{ - "scheme": "exact", - "network": chain, - "payTo": wallet, - "maxTimeoutSeconds": 300, - "price": map[string]any{ - "perRequest": price, - }, - }, - "path": "/services/" + name, + "payment": payment, + "path": "/services/" + name, "registration": map[string]any{ "enabled": true, "name": name, @@ -1300,16 +1321,24 @@ func buildDemoServiceOffer(name, ns, chain, wallet, price string, spec demoSpec) } // printDemoTryIt prints copy-paste instructions for calling the demo service. -func printDemoTryIt(u *ui.UI, name, typeName, price, chain, tunnelURL string) { +func printDemoTryIt(u *ui.UI, name, typeName, price, symbol, chain, tunnelURL string) { endpoint := "/services/" + name if tunnelURL != "" { endpoint = tunnelURL + "/services/" + name } + // EIP-3009-native tokens (USDC, EURC) use TransferWithAuthorization. + // Other ERC-20s (e.g. OBOL) settle through Permit2. + transferMethod := "ERC-3009 TransferWithAuthorization" + if symbol != "USDC" && symbol != "EURC" { + transferMethod = "Permit2 (ERC-20 with off-chain authorization)" + } + u.Bold("── Try it ──────────────────────────────────────────────") u.Blank() u.Printf(" Demo %q is live at: %s", typeName, endpoint) + u.Printf(" Price: %s %s/request on %s", price, symbol, chain) u.Blank() u.Printf(" 1. Probe for pricing (see the 402 response):") @@ -1324,7 +1353,7 @@ func printDemoTryIt(u *ui.UI, name, typeName, price, chain, tunnelURL string) { u.Dim("") u.Dim(" client = x402_client(") u.Dim(" httpx.Client(),") - u.Dim(` private_key="", # USDC holder on ` + chain) + u.Dim(fmt.Sprintf(` private_key="", # %s holder on %s`, symbol, chain)) u.Dim(" )") u.Dim(fmt.Sprintf(` resp = client.get("%s")`, endpoint)) u.Dim(" print(resp.json())") @@ -1334,8 +1363,8 @@ func printDemoTryIt(u *ui.UI, name, typeName, price, chain, tunnelURL string) { u.Blank() u.Dim(" • A request without payment returns HTTP 402 with pricing details") u.Dim(" • The 402 body contains an 'accepts' array with payment requirements:") - u.Dim(" scheme, network (CAIP-2), amount (atomic USDC), asset, payTo address") - u.Dim(" • The buyer signs an ERC-3009 TransferWithAuthorization off-chain") + u.Dim(" scheme, network (CAIP-2), amount (atomic units), asset, payTo address") + u.Dim(fmt.Sprintf(" • The buyer signs a %s off-chain", transferMethod)) u.Dim(" • The signed authorization is base64-encoded and sent as X-PAYMENT header") u.Dim(" • The x402 facilitator verifies the signature and settles on-chain") u.Dim(" • See https://www.x402.org for the full protocol specification") @@ -1344,7 +1373,7 @@ func printDemoTryIt(u *ui.UI, name, typeName, price, chain, tunnelURL string) { u.Printf(" 4. Ask your AI agent:") u.Blank() u.Dim(fmt.Sprintf(` "Call the paid service at %s`, endpoint)) - u.Dim(fmt.Sprintf(` using x402 payment. It costs %s USDC per request on %s.`, price, chain)) + u.Dim(fmt.Sprintf(` using x402 payment. It costs %s %s per request on %s.`, price, symbol, chain)) u.Dim(` Report what it returns."`) u.Blank() diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index f5ad88f2..d2b9c8c0 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -7,6 +7,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "github.com/ObolNetwork/obol-stack/internal/schemas" x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" "github.com/urfave/cli/v3" ) @@ -276,8 +277,9 @@ func TestSellDemo_Flags(t *testing.T) { demo := findSubcommand(t, cmd, "demo") flags := flagMap(demo) - requireFlags(t, flags, "wallet", "chain", "price", "name") + requireFlags(t, flags, "wallet", "chain", "token", "price", "name") assertStringDefault(t, flags, "chain", "base") + assertStringDefault(t, flags, "token", "USDC") } func TestSellStop_Structure(t *testing.T) { @@ -427,6 +429,59 @@ func TestDemoRPCNetwork(t *testing.T) { } } +func TestBuildDemoServiceOffer_USDCOmitsAssetBlock(t *testing.T) { + // USDC is the chain default; AssetTerms is zero, so the manifest must NOT + // include a payment.asset block (the verifier falls back to chain default). + manifest := buildDemoServiceOffer( + "demo-hello", "demo", "base-sepolia", + "0x1111111111111111111111111111111111111111", + "0.00001", + demoSpec{Type: "hello", Description: "echo"}, + schemas.AssetTerms{}, + ) + payment := manifest["spec"].(map[string]any)["payment"].(map[string]any) + if _, ok := payment["asset"]; ok { + t.Fatalf("expected no payment.asset block when asset is zero, got: %v", payment["asset"]) + } + if payment["network"] != "base-sepolia" { + t.Errorf("network = %v, want base-sepolia", payment["network"]) + } +} + +func TestBuildDemoServiceOffer_OBOLIncludesAssetBlock(t *testing.T) { + // Selling for OBOL on Ethereum mainnet must populate the full asset block + // so the verifier and storefront know which token to enforce. + asset := schemas.AssetTerms{ + Address: "0x0B010000b7624eb9B3DfBC279673C76E9D29D5F7", + Symbol: "OBOL", + Decimals: 18, + TransferMethod: schemas.AssetTransferMethodPermit2, + EIP712Name: "Obol Network", + EIP712Version: "1", + } + manifest := buildDemoServiceOffer( + "demo-oracle", "demo", "ethereum", + "0x2222222222222222222222222222222222222222", + "0.001", + demoSpec{Type: "oracle", Description: "chain analysis"}, + asset, + ) + payment := manifest["spec"].(map[string]any)["payment"].(map[string]any) + got, ok := payment["asset"].(schemas.AssetTerms) + if !ok { + t.Fatalf("payment.asset missing or wrong type: %T %v", payment["asset"], payment["asset"]) + } + if got.Symbol != "OBOL" { + t.Errorf("asset.Symbol = %q, want OBOL", got.Symbol) + } + if got.TransferMethod != schemas.AssetTransferMethodPermit2 { + t.Errorf("asset.TransferMethod = %q, want %q", got.TransferMethod, schemas.AssetTransferMethodPermit2) + } + if payment["network"] != "ethereum" { + t.Errorf("network = %v, want ethereum", payment["network"]) + } +} + func TestBuildDemoResources_UsesImportedImageAndERPCPath(t *testing.T) { resources := buildDemoResources("demo-blocks", demoSpec{Type: "blocks", NeedsERPC: true}, "base-sepolia") deploy := resources[1] diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index e45c839c..740b0e45 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -991,13 +991,17 @@ func decimalToAtomicString(amount string, decimals int) string { } func describeOfferPrice(offer *monetizeapi.ServiceOffer) string { + symbol := offer.Spec.Payment.Asset.Symbol + if symbol == "" { + symbol = "USDC" + } switch { case offer.Spec.Payment.Price.PerRequest != "": - return offer.Spec.Payment.Price.PerRequest + " USDC/request" + return offer.Spec.Payment.Price.PerRequest + " " + symbol + "/request" case offer.Spec.Payment.Price.PerMTok != "": - return offer.Spec.Payment.Price.PerMTok + " USDC/MTok" + return offer.Spec.Payment.Price.PerMTok + " " + symbol + "/MTok" case offer.Spec.Payment.Price.PerHour != "": - return offer.Spec.Payment.Price.PerHour + " USDC/hour" + return offer.Spec.Payment.Price.PerHour + " " + symbol + "/hour" default: return "—" } diff --git a/internal/serviceoffercontroller/render_builders_test.go b/internal/serviceoffercontroller/render_builders_test.go index 45956f76..37b70a65 100644 --- a/internal/serviceoffercontroller/render_builders_test.go +++ b/internal/serviceoffercontroller/render_builders_test.go @@ -271,6 +271,45 @@ func TestDescribeOfferPrice(t *testing.T) { spec: monetizeapi.ServiceOfferSpec{}, want: "—", }, + { + name: "OBOL symbol surfaces in per-request label", + spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{ + Asset: monetizeapi.ServiceOfferAsset{Symbol: "OBOL"}, + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + }, + want: "0.001 OBOL/request", + }, + { + name: "OBOL symbol surfaces in per-mtok label", + spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{ + Asset: monetizeapi.ServiceOfferAsset{Symbol: "OBOL"}, + Price: monetizeapi.ServiceOfferPriceTable{PerMTok: "5.00"}, + }, + }, + want: "5.00 OBOL/MTok", + }, + { + name: "OBOL symbol surfaces in per-hour label", + spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{ + Asset: monetizeapi.ServiceOfferAsset{Symbol: "OBOL"}, + Price: monetizeapi.ServiceOfferPriceTable{PerHour: "2.5"}, + }, + }, + want: "2.5 OBOL/hour", + }, + { + name: "empty asset symbol falls back to USDC", + spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{ + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + }, + want: "0.001 USDC/request", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 5fda5b10e135e201b104093ad46059ec4897fd30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Sat, 2 May 2026 20:13:54 +0100 Subject: [PATCH 2/3] Update public store --- .gitignore | 4 + cmd/demo-server/main.go | 8 +- cmd/obol/sell.go | 221 ++++++++++++------ cmd/obol/sell_test.go | 50 +++- internal/demo/demo_test.go | 8 +- internal/demo/handlers_extra_test.go | 6 +- internal/demo/{oracle.go => quant.go} | 8 +- ...le_errors_test.go => quant_errors_test.go} | 18 +- ..._helpers_test.go => quant_helpers_test.go} | 0 web/public-storefront/public/favicon.png | Bin 0 -> 19783 bytes web/public-storefront/public/obol-logo.png | Bin 0 -> 19783 bytes .../public/obol-stack-logo.png | Bin 0 -> 2561 bytes web/public-storefront/src/app/globals.css | 40 +++- web/public-storefront/src/app/layout.tsx | 24 +- web/public-storefront/src/app/page.tsx | 119 ++++------ .../src/components/Header.tsx | 18 ++ .../src/components/PaymentFlow.tsx | 101 ++++---- .../src/components/ServiceCard.tsx | 217 ++++++++++++----- .../src/components/ServicesList.tsx | 80 +++++++ web/public-storefront/tsconfig.json | 24 +- 20 files changed, 629 insertions(+), 317 deletions(-) rename internal/demo/{oracle.go => quant.go} (95%) rename internal/demo/{oracle_errors_test.go => quant_errors_test.go} (94%) rename internal/demo/{oracle_helpers_test.go => quant_helpers_test.go} (100%) create mode 100644 web/public-storefront/public/favicon.png create mode 100644 web/public-storefront/public/obol-logo.png create mode 100644 web/public-storefront/public/obol-stack-logo.png create mode 100644 web/public-storefront/src/components/Header.tsx create mode 100644 web/public-storefront/src/components/ServicesList.tsx diff --git a/.gitignore b/.gitignore index 5ca98623..ec59ece2 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,10 @@ build/ node_modules/ /vendor/ +# Next.js build artifacts +.next/ +next-env.d.ts + # Environment variables .env *.env diff --git a/cmd/demo-server/main.go b/cmd/demo-server/main.go index e9603587..75223140 100644 --- a/cmd/demo-server/main.go +++ b/cmd/demo-server/main.go @@ -3,7 +3,7 @@ // The demo type is selected by the DEMO_TYPE environment variable: // - hello: proof-of-payment echo (no external dependencies) // - blocks: basic chain data from eRPC -// - oracle: chain analysis with gas statistics from eRPC +// - quant: agent-driven analysis report from eRPC (gas stats, tx volume) package main import ( @@ -31,10 +31,10 @@ func main() { handler = demo.HelloHandler() case "blocks": handler = demo.BlocksHandler(erpcURL) - case "oracle": - handler = demo.OracleHandler(erpcURL) + case "quant": + handler = demo.QuantHandler(erpcURL) default: - log.Fatalf("unknown DEMO_TYPE: %q (expected hello, blocks, or oracle)", demoType) + log.Fatalf("unknown DEMO_TYPE: %q (expected hello, blocks, or quant)", demoType) } mux := http.NewServeMux() diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 0c875e12..c20216b4 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -958,29 +958,39 @@ func serviceOfferStatusLines(namespace, name string, offer monetizeapi.ServiceOf // demoSpec describes a built-in demo type with default pricing and config. type demoSpec struct { - Type string // DEMO_TYPE env value - Price string // default per-request USDC price - Description string // human-readable one-liner - NeedsERPC bool // whether the demo queries eRPC + Type string // DEMO_TYPE env value + Price string // default per-request price (in DefaultToken units) + Description string // human-readable one-liner + NeedsERPC bool // whether the demo queries eRPC + DefaultChain string // default --chain when not explicitly set + DefaultToken string // default --token when not explicitly set } +const defaultDemoType = "hello" + var demoTypes = map[string]demoSpec{ "hello": { - Type: "hello", - Price: "0.00001", - Description: "Proof-of-payment echo service — confirms you got through the x402 gate", + Type: "hello", + Price: "1", + Description: "Proof-of-payment echo service — confirms you got through the x402 gate", + DefaultChain: "ethereum", + DefaultToken: "OBOL", }, "blocks": { - Type: "blocks", - Price: "0.0001", - Description: "Live blockchain data from a local full node (block, gas, chain ID)", - NeedsERPC: true, + Type: "blocks", + Price: "0.0001", + Description: "Live blockchain data from a local full node (block, gas, chain ID)", + NeedsERPC: true, + DefaultChain: "base-sepolia", + DefaultToken: "USDC", }, - "oracle": { - Type: "oracle", - Price: "0.001", - Description: "Chain analysis — gas statistics, tx volume, and utilization across recent blocks", - NeedsERPC: true, + "quant": { + Type: "quant", + Price: "0.01", + Description: "Agent driven analysis report", + NeedsERPC: true, + DefaultChain: "base-sepolia", + DefaultToken: "USDC", }, } @@ -990,19 +1000,22 @@ func sellDemoCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "demo", Usage: "Deploy a demo service behind x402 payment gate", - ArgsUsage: "", + ArgsUsage: "[type]", Description: `Deploys a demo HTTP server and creates a ServiceOffer to payment-gate it. The demo proves the full sell→discover→pay→receive flow works end-to-end. Types: - hello Proof-of-payment echo ($0.00001/req) — simplest, no dependencies - blocks Live blockchain data ($0.0001/req) — queries local full node via eRPC - oracle Chain analysis report ($0.001/req) — gas stats, tx volume, utilization + hello Proof-of-payment echo (default: 1 OBOL on ethereum) + blocks Live blockchain data (default: 0.0001 USDC on base-sepolia) + quant Agent driven analysis (default: 0.01 USDC on base-sepolia) + +Run with no arguments to deploy the canonical hello demo on mainnet. Example: - obol sell demo hello - obol sell demo blocks --chain base-sepolia - obol sell demo oracle --token OBOL --chain ethereum --price 0.01`, + obol sell demo # hello @ 1 OBOL on ethereum + obol sell demo blocks # blocks @ 0.0001 USDC on base-sepolia + obol sell demo quant --price 0.05 # quant @ 0.05 USDC on base-sepolia + obol sell demo hello --token USDC --chain base --price 0.001`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "wallet", @@ -1012,13 +1025,11 @@ Example: }, &cli.StringFlag{ Name: "chain", - Usage: "Payment chain (base, base-sepolia, ethereum)", - Value: "base", + Usage: "Payment chain (defaults to demo type's default chain)", }, &cli.StringFlag{ Name: "token", - Usage: "Payment token (USDC, OBOL)", - Value: "USDC", + Usage: "Payment token (defaults to demo type's default token)", }, &cli.StringFlag{ Name: "price", @@ -1032,13 +1043,19 @@ Example: Action: func(ctx context.Context, cmd *cli.Command) error { u := getUI(cmd) - if cmd.NArg() < 1 { - return fmt.Errorf("demo type required: obol sell demo ") + // Stack must be running to deploy a demo (we apply K8s resources). + if err := kubectl.EnsureCluster(cfg); err != nil { + return fmt.Errorf("Obol Stack is not running. Start it with `obol stack up` first") + } + + // Default to canonical hello demo when no type is given. + typeName := defaultDemoType + if cmd.NArg() >= 1 { + typeName = cmd.Args().First() } - typeName := cmd.Args().First() spec, ok := demoTypes[typeName] if !ok { - return fmt.Errorf("unknown demo type %q — choose: hello, blocks, oracle", typeName) + return fmt.Errorf("unknown demo type %q — choose: hello, blocks, quant", typeName) } name := cmd.String("name") @@ -1046,6 +1063,19 @@ Example: name = "demo-" + typeName } + // Apply per-type defaults when the user didn't explicitly set chain/token. + // This lets bare `obol sell demo` pick OBOL/ethereum (hello), + // `obol sell demo quant` pick USDC/base-sepolia, etc. + chain := cmd.String("chain") + chainExplicit := cmd.IsSet("chain") + if chain == "" { + chain = spec.DefaultChain + } + tokenName := cmd.String("token") + if tokenName == "" { + tokenName = spec.DefaultToken + } + // Resolve wallet. wallet := cmd.String("wallet") if wallet == "" { @@ -1054,7 +1084,7 @@ Example: u.Infof("Using wallet from remote-signer: %s", wallet) } else if u.IsTTY() { var inputErr error - wallet, inputErr = u.Input("Wallet address (USDC recipient)", "") + wallet, inputErr = u.Input("Wallet address (token recipient)", "") if inputErr != nil || wallet == "" { return fmt.Errorf("wallet required: use --wallet or set X402_WALLET") } @@ -1071,11 +1101,9 @@ Example: price = spec.Price } - chain := cmd.String("chain") - - // Resolve token metadata. resolveAssetTerms may flip chain to ethereum + // Resolve token metadata. resolveAssetTermsFor may flip chain to ethereum // for non-USDC tokens when --chain wasn't explicitly set. - assetTerms, err := resolveAssetTerms(cmd, &chain) + assetTerms, err := resolveAssetTermsFor(tokenName, &chain, chainExplicit) if err != nil { return err } @@ -1118,15 +1146,51 @@ Example: u.Successf("Tunnel active: %s", tunnelURL) } - // 4. Print try-it instructions. + // 4. Wait up to 60s for the ServiceOffer to reach Ready=True so the + // /skill.md catalog and storefront pick up the offer before we tell + // the user to try it. If it times out we still print the try-it + // block with a propagation note. + ready := waitForOfferReady(cfg, u, name, demoNamespace, 60*time.Second) + + // 5. Print try-it instructions. u.Blank() - printDemoTryIt(u, name, typeName, price, symbol, chain, tunnelURL) + printDemoTryIt(u, name, typeName, price, symbol, chain, tunnelURL, ready) return nil }, } } +// waitForOfferReady polls a ServiceOffer's Ready condition for up to `timeout`. +// Returns true if Ready=True observed, false otherwise. Uses a spinner. +func waitForOfferReady(cfg *config.Config, u *ui.UI, name, ns string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + bin, kc := kubectl.Paths(cfg) + + check := func() bool { + out, err := kubectl.Output(bin, kc, "get", "serviceoffers.obol.org", name, "-n", ns, + "-o", `jsonpath={.status.conditions[?(@.type=="Ready")].status}`) + return err == nil && strings.TrimSpace(out) == "True" + } + + if check() { + return true + } + + var ready bool + _ = u.RunWithSpinner("Waiting for service to be Ready (up to 60s)", func() error { + for time.Now().Before(deadline) { + if check() { + ready = true + return nil + } + time.Sleep(2 * time.Second) + } + return nil + }) + return ready +} + // deployDemoBackend creates the demo namespace, Deployment, and Service. func deployDemoBackend(cfg *config.Config, u *ui.UI, name string, spec demoSpec, paymentChain string) error { resources := buildDemoResources(name, spec, paymentChain) @@ -1321,60 +1385,58 @@ func buildDemoServiceOffer(name, ns, chain, wallet, price string, spec demoSpec, } // printDemoTryIt prints copy-paste instructions for calling the demo service. -func printDemoTryIt(u *ui.UI, name, typeName, price, symbol, chain, tunnelURL string) { +// `ready` indicates whether the ServiceOffer reached Ready=True before the +// caller's poll deadline; when false, we add a "may take a moment" note. +func printDemoTryIt(u *ui.UI, name, typeName, price, symbol, chain, tunnelURL string, ready bool) { endpoint := "/services/" + name if tunnelURL != "" { endpoint = tunnelURL + "/services/" + name } - // EIP-3009-native tokens (USDC, EURC) use TransferWithAuthorization. - // Other ERC-20s (e.g. OBOL) settle through Permit2. - transferMethod := "ERC-3009 TransferWithAuthorization" - if symbol != "USDC" && symbol != "EURC" { - transferMethod = "Permit2 (ERC-20 with off-chain authorization)" - } - u.Bold("── Try it ──────────────────────────────────────────────") u.Blank() u.Printf(" Demo %q is live at: %s", typeName, endpoint) u.Printf(" Price: %s %s/request on %s", price, symbol, chain) + if !ready { + u.Dim(" (still propagating — service may take a moment to appear in /skill.md)") + } u.Blank() - u.Printf(" 1. Probe for pricing (see the 402 response):") + // 1. Ask your agent — primary, recommended path. + u.Printf(" Ask your agent") u.Blank() - u.Dim(fmt.Sprintf(" curl -s %s | jq .", endpoint)) + u.Dim(fmt.Sprintf(` "Use the buy-x402 skill to call the paid service at %s.`, endpoint)) + u.Dim(fmt.Sprintf(` It costs %s %s per request on %s. Report what it returns."`, price, symbol, chain)) u.Blank() - u.Printf(" 2. Make a paid request (Python — pip install x402 httpx):") + // 2. Or check it out manually — pricing probe + paid request. + u.Printf(" Or check it out manually") u.Blank() - u.Dim(" import httpx") - u.Dim(" from x402.client import x402_client") - u.Dim("") - u.Dim(" client = x402_client(") - u.Dim(" httpx.Client(),") - u.Dim(fmt.Sprintf(` private_key="", # %s holder on %s`, symbol, chain)) - u.Dim(" )") - u.Dim(fmt.Sprintf(` resp = client.get("%s")`, endpoint)) - u.Dim(" print(resp.json())") + u.Dim(" Check the API pricing — terminal:") + u.Dim(fmt.Sprintf(" curl -s %s | jq .", endpoint)) + u.Dim(" ...or browser:") + u.Dim(fmt.Sprintf(" %s", endpoint)) u.Blank() - - u.Printf(" 3. How x402 payment works:") - u.Blank() - u.Dim(" • A request without payment returns HTTP 402 with pricing details") - u.Dim(" • The 402 body contains an 'accepts' array with payment requirements:") - u.Dim(" scheme, network (CAIP-2), amount (atomic units), asset, payTo address") - u.Dim(fmt.Sprintf(" • The buyer signs a %s off-chain", transferMethod)) - u.Dim(" • The signed authorization is base64-encoded and sent as X-PAYMENT header") - u.Dim(" • The x402 facilitator verifies the signature and settles on-chain") - u.Dim(" • See https://www.x402.org for the full protocol specification") + u.Dim(" Pay for the API call (Python — pip install x402 httpx):") + u.Dim(" import httpx") + u.Dim(" from x402.client import x402_client") + u.Dim("") + u.Dim(fmt.Sprintf(` client = x402_client(httpx.Client(), private_key="<%s holder on %s>")`, symbol, chain)) + u.Dim(fmt.Sprintf(` resp = client.get("%s")`, endpoint)) + u.Dim(" print(resp.json())") u.Blank() - u.Printf(" 4. Ask your AI agent:") + // 3. How x402 works — short prose paragraph (not numbered). + u.Printf(" How x402 works") u.Blank() - u.Dim(fmt.Sprintf(` "Call the paid service at %s`, endpoint)) - u.Dim(fmt.Sprintf(` using x402 payment. It costs %s %s per request on %s.`, price, symbol, chain)) - u.Dim(` Report what it returns."`) + u.Dim(" A request without payment returns HTTP 402 with the price and a") + u.Dim(" payment recipe. The buyer (or library) signs an off-chain") + u.Dim(" authorization, retries the request with an X-PAYMENT header, and") + u.Dim(" the seller's facilitator settles on-chain. No gas needed — the") + u.Dim(" seller covers settlement. Useful for: data feeds, AI inference,") + u.Dim(" subscriptions, digital purchases, and agent-to-agent commerce.") + u.Dim(" Spec: https://www.x402.org") u.Blank() u.Bold("─────────────────────────────────────────────────────────") @@ -2741,7 +2803,14 @@ func resolvePriceTable(cmd *cli.Command, allowPerHour bool) (schemas.PriceTable, } func resolveAssetTerms(cmd *cli.Command, chainName *string) (schemas.AssetTerms, error) { - tokenName := strings.ToUpper(strings.TrimSpace(cmd.String("token"))) + return resolveAssetTermsFor(strings.TrimSpace(cmd.String("token")), chainName, cmd.IsSet("chain")) +} + +// resolveAssetTermsFor is the cmd-free core of resolveAssetTerms — takes the +// already-resolved token and chain explicitly. Used by sell demo where chain +// and token come from per-type defaults rather than CLI flags. +func resolveAssetTermsFor(tokenName string, chainName *string, chainExplicit bool) (schemas.AssetTerms, error) { + tokenName = strings.ToUpper(tokenName) // USDC = chain default — no asset override needed. if tokenName == "USDC" { @@ -2752,11 +2821,11 @@ func resolveAssetTerms(cmd *cli.Command, chainName *string) (schemas.AssetTerms, return schemas.AssetTerms{}, fmt.Errorf("internal error: chain name pointer is nil") } - // For non-default tokens, default to ethereum when --chain is not explicit. - if !cmd.IsSet("chain") { + // For non-default tokens, default to ethereum when chain is not explicit. + if !chainExplicit { if envChain := strings.TrimSpace(os.Getenv("OBOL_TOKEN_CHAIN")); envChain != "" { *chainName = envChain - } else { + } else if *chainName == "" { *chainName = "ethereum" } } diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index d2b9c8c0..d2e04517 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -278,8 +278,50 @@ func TestSellDemo_Flags(t *testing.T) { flags := flagMap(demo) requireFlags(t, flags, "wallet", "chain", "token", "price", "name") - assertStringDefault(t, flags, "chain", "base") - assertStringDefault(t, flags, "token", "USDC") + // chain and token deliberately have no flag-level defaults so the action + // can apply per-type defaults from demoTypes (e.g. hello → OBOL/ethereum, + // quant → USDC/base-sepolia). + assertStringDefault(t, flags, "chain", "") + assertStringDefault(t, flags, "token", "") +} + +// TestDemoTypes_PerTypeDefaults locks in the canonical defaults for each demo +// type. These are the prices/chains/tokens users see when running `obol sell +// demo` with no flags — changing them changes onboarding behavior. +func TestDemoTypes_PerTypeDefaults(t *testing.T) { + tests := []struct { + demo string + chain string + token string + price string + }{ + {"hello", "ethereum", "OBOL", "1"}, + {"blocks", "base-sepolia", "USDC", "0.0001"}, + {"quant", "base-sepolia", "USDC", "0.01"}, + } + for _, tt := range tests { + t.Run(tt.demo, func(t *testing.T) { + spec, ok := demoTypes[tt.demo] + if !ok { + t.Fatalf("demoTypes[%q] missing", tt.demo) + } + if spec.DefaultChain != tt.chain { + t.Errorf("DefaultChain = %q, want %q", spec.DefaultChain, tt.chain) + } + if spec.DefaultToken != tt.token { + t.Errorf("DefaultToken = %q, want %q", spec.DefaultToken, tt.token) + } + if spec.Price != tt.price { + t.Errorf("Price = %q, want %q", spec.Price, tt.price) + } + }) + } + + // The bare `obol sell demo` (no args) defaults to hello — onboarding's + // canonical "earn 1 OBOL on mainnet" experience. + if defaultDemoType != "hello" { + t.Errorf("defaultDemoType = %q, want hello", defaultDemoType) + } } func TestSellStop_Structure(t *testing.T) { @@ -460,10 +502,10 @@ func TestBuildDemoServiceOffer_OBOLIncludesAssetBlock(t *testing.T) { EIP712Version: "1", } manifest := buildDemoServiceOffer( - "demo-oracle", "demo", "ethereum", + "demo-quant", "demo", "ethereum", "0x2222222222222222222222222222222222222222", "0.001", - demoSpec{Type: "oracle", Description: "chain analysis"}, + demoSpec{Type: "quant", Description: "agent driven analysis"}, asset, ) payment := manifest["spec"].(map[string]any)["payment"].(map[string]any) diff --git a/internal/demo/demo_test.go b/internal/demo/demo_test.go index 30bc3f88..751000fa 100644 --- a/internal/demo/demo_test.go +++ b/internal/demo/demo_test.go @@ -150,7 +150,7 @@ func TestBlocksHandler_MockRPC(t *testing.T) { } } -func TestOracleHandler_MockRPC(t *testing.T) { +func TestQuantHandler_MockRPC(t *testing.T) { callCount := 0 mockRPC := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { callCount++ @@ -180,7 +180,7 @@ func TestOracleHandler_MockRPC(t *testing.T) { })) defer mockRPC.Close() - handler := OracleHandler(mockRPC.URL) + handler := QuantHandler(mockRPC.URL) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() @@ -194,8 +194,8 @@ func TestOracleHandler_MockRPC(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } - if resp.Demo != "oracle" { - t.Errorf("expected demo=oracle, got %q", resp.Demo) + if resp.Demo != "quant" { + t.Errorf("expected demo=quant, got %q", resp.Demo) } data, ok := resp.Data.(map[string]any) diff --git a/internal/demo/handlers_extra_test.go b/internal/demo/handlers_extra_test.go index 08e76644..28553196 100644 --- a/internal/demo/handlers_extra_test.go +++ b/internal/demo/handlers_extra_test.go @@ -8,11 +8,11 @@ import ( "testing" ) -// TestOracleHandler_BlockNumberFailureEarlyReturn verifies that when +// TestQuantHandler_BlockNumberFailureEarlyReturn verifies that when // eth_blockNumber fails the handler returns an errors-only body and does // NOT attempt to compute recentBlocks/gasAnalysis/txVolume from a zero // blockNum — that would produce nonsense output for paying customers. -func TestOracleHandler_BlockNumberFailureEarlyReturn(t *testing.T) { +func TestQuantHandler_BlockNumberFailureEarlyReturn(t *testing.T) { var blockNumberCalls int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { @@ -35,7 +35,7 @@ func TestOracleHandler_BlockNumberFailureEarlyReturn(t *testing.T) { })) defer srv.Close() - handler := OracleHandler(srv.URL) + handler := QuantHandler(srv.URL) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler(w, req) diff --git a/internal/demo/oracle.go b/internal/demo/quant.go similarity index 95% rename from internal/demo/oracle.go rename to internal/demo/quant.go index d3cf0470..6b6dac7d 100644 --- a/internal/demo/oracle.go +++ b/internal/demo/quant.go @@ -9,10 +9,10 @@ import ( "time" ) -// OracleHandler returns a handler that performs chain analysis using eRPC data. +// QuantHandler returns a handler that performs chain analysis using eRPC data. // Unlike a simple RPC passthrough, it fetches multiple data points, computes // derived metrics (gas statistics, tx volume), and formats a structured report. -func OracleHandler(erpcURL string) http.HandlerFunc { +func QuantHandler(erpcURL string) http.HandlerFunc { client := &http.Client{Timeout: 15 * time.Second} return func(w http.ResponseWriter, r *http.Request) { @@ -30,7 +30,7 @@ func OracleHandler(erpcURL string) http.HandlerFunc { blockNumRaw, err := rpcCall(client, erpcURL, "eth_blockNumber", "[]") if err != nil { errs = append(errs, fmt.Sprintf("blockNumber: %v", err)) - respond(w, r, "oracle", map[string]any{"errors": errs}) + respond(w, r, "quant", map[string]any{"errors": errs}) return } report["latestBlockNumber"] = blockNumRaw @@ -124,7 +124,7 @@ func OracleHandler(erpcURL string) http.HandlerFunc { report["errors"] = errs } - respond(w, r, "oracle", report) + respond(w, r, "quant", report) } } diff --git a/internal/demo/oracle_errors_test.go b/internal/demo/quant_errors_test.go similarity index 94% rename from internal/demo/oracle_errors_test.go rename to internal/demo/quant_errors_test.go index 23f920f1..d7160f08 100644 --- a/internal/demo/oracle_errors_test.go +++ b/internal/demo/quant_errors_test.go @@ -9,10 +9,10 @@ import ( "testing" ) -// TestOracleHandler_ChainIDFailure verifies that a failing eth_chainId call is +// TestQuantHandler_ChainIDFailure verifies that a failing eth_chainId call is // captured in the "errors" array but does not short-circuit the handler — // downstream computations (recentBlocks, gasAnalysis, etc.) should still run. -func TestOracleHandler_ChainIDFailure(t *testing.T) { +func TestQuantHandler_ChainIDFailure(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { Method string `json:"method"` @@ -33,7 +33,7 @@ func TestOracleHandler_ChainIDFailure(t *testing.T) { })) defer srv.Close() - handler := OracleHandler(srv.URL) + handler := QuantHandler(srv.URL) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler(w, req) @@ -75,11 +75,11 @@ func TestOracleHandler_ChainIDFailure(t *testing.T) { } } -// TestOracleHandler_PerBlockFetchError exercises the "continue" branch inside +// TestQuantHandler_PerBlockFetchError exercises the "continue" branch inside // the per-block loop: some block fetches succeed, one returns an RPC error. // The successful blocks must still be included; the failed block must show up // in errors[]. -func TestOracleHandler_PerBlockFetchError(t *testing.T) { +func TestQuantHandler_PerBlockFetchError(t *testing.T) { var blockCallCount atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { @@ -108,7 +108,7 @@ func TestOracleHandler_PerBlockFetchError(t *testing.T) { })) defer srv.Close() - handler := OracleHandler(srv.URL) + handler := QuantHandler(srv.URL) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler(w, req) @@ -147,10 +147,10 @@ func TestOracleHandler_PerBlockFetchError(t *testing.T) { } } -// TestOracleHandler_MalformedBlockJSON exercises the json.Unmarshal error +// TestQuantHandler_MalformedBlockJSON exercises the json.Unmarshal error // branch in the per-block loop: the RPC returns valid JSON-RPC framing but // the inner "result" field doesn't match the expected block struct shape. -func TestOracleHandler_MalformedBlockJSON(t *testing.T) { +func TestQuantHandler_MalformedBlockJSON(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { Method string `json:"method"` @@ -172,7 +172,7 @@ func TestOracleHandler_MalformedBlockJSON(t *testing.T) { })) defer srv.Close() - handler := OracleHandler(srv.URL) + handler := QuantHandler(srv.URL) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler(w, req) diff --git a/internal/demo/oracle_helpers_test.go b/internal/demo/quant_helpers_test.go similarity index 100% rename from internal/demo/oracle_helpers_test.go rename to internal/demo/quant_helpers_test.go diff --git a/web/public-storefront/public/favicon.png b/web/public-storefront/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..ff9929ad22f2d653db2fd5981e98d0c549884a87 GIT binary patch literal 19783 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 &IlR#;@(AO67DaG5mtm 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 literal 0 HcmV?d00001 diff --git a/web/public-storefront/public/obol-logo.png b/web/public-storefront/public/obol-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ff9929ad22f2d653db2fd5981e98d0c549884a87 GIT binary patch literal 19783 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 &IlR#;@(AO67DaG5mtm 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 literal 0 HcmV?d00001 diff --git a/web/public-storefront/public/obol-stack-logo.png b/web/public-storefront/public/obol-stack-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c861b550066f0bb89f27fee0636761b092e56f8a GIT binary patch literal 2561 zcmV+c3jXzpP)r000;W1^@s6B`IYr00001b5ch_0Itp) z=>Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91il74k1ONa40RR917ytkO0Grge{{R39Ye_^wRA>e5T7Oa-xe}H%&RZ%; z)ibqV!`3c%PO#(zYfi9m0-O^7C%~Kl_5?U52swet3F4fCYpsMlcN2}FpeQI@UEX4%r)7jrUMwsdZUB)1_4fFrY)i4aZV-P=)Jdg(D zxZ~avNLt3|)o^jKHLe^_bPuC}v4HXfl80HG$296lpge)(F)i1_$#Nr5?gb=Lbd9j9 zVWH4?xM@F)JgEkT0_9Wt?@eRP{-g{?(Hm{n!(WPrqv*zM|J3P}jSwrsh`dmutAuF$ zHa~wf*uQykkq@r*nifGLEX+;~q|MWdQV{5}*1=J@INKT=*O!}3jQ>KfS!+wIlR`F@ zigQ!bgCA#-x7P{#jM7Uki*fT5%C~k4(`jY3-?k1s|C$y^n(d2~Kty}Sh;l3sWHq(~ z(1_!+^Olrmcd1aQ#clY#y2@LXJ5VCpMQLh4$%&|BtT+HzUW0E9&kxCMI-#ooN!~YF zVV#^tV&goNb?xbdGL6zpCQ!mfwnTxl;r~@x}>wx;JCr^GhNL%tHG@KDX5BT-C!Q2hWB+~B}=T~`=otd zVLs{qBxPqCSijf$S*CyF{kQ%+t$aU@U*u&Wd#!DYn-9L?{Bv8ZhxIsnMidR?qbZV0#A(zk)X{;sN}cCKZ&Vby1%YbpUfMuD2WT ziRvev&R&x4#=Mn1Fu)JMxLiEaQ@*e;SBoD+fN+QbjsrCH^{4fR4d z;kfTX_KJHTyHe2ywvooCzU)0(%ySEV(c7$f`aXO+i>-8><63KWgos+KzZFpWAW1l9 zWOmmCr*J!5OgNw!3@sEX<#*Zo01Wo`Np!13aZLrKY z%Y2;Qr8G833>+>X0AA$-YX`Yq$_LMNnK?vKTdoEm!gsV+;X+|+sW`vxX|aLIex5O1 z{2RQ~08o7Nxx22#???MYK8iO>o>_8`yX3iR2Nz^0KOo&2Jv+2L*IN;epsUZd1L@s6dtsVNyLcRjN-o7NtcD^<&geE^K z*4n5nrAqO~;xA{72P?9|dI0MfjwKYF!vY#tH%JeVlw2L|aiEXtuOOSujE`g})XW$7k&pfLQ{JHZj*4=So`jJgNf(4k%*gDii~WAv=!WKTwTJLO93m z_D3IqW&(vwgBQ`;X2XIO{@t-;BAmy4p74uU-H zMgBBaZ&7(BGOxp57F%h~w#DcMe!=cDt&iAt9pp9vpbD%_Eu5coby|yE9t~73l)X-6 zbKwj=8(7KLH<$yozYyRblcPvU$+fUkcX_ZoV- zU8imdG-cIW%pK13YzrIn$3*WpHglZJbRMbjvF?r_@sjd(lb%03^LLPQ3%jKLP34+Y zEb>)cpf#M=p7HS!{>3&H=?(8r*oTkf0bfa9{%`yH81qLiY!(cgq3pAcC4T^lA@1t9 z->kSJc_RzH4aYkUZ%fQGiW!V9mg)2|k^z7meeZ|PHrApQ7vX3NfC(U>9tLT7B==;G z>3xI>o{;^t1=*epC^gXmIBH>fyC|pQA(jaPwDY$pnvr6 zHt!xR$rDH(@7hl!nX@SAE8mIw-$0C8!HloFH*|Pvdc+!#_W}|+O;3M!e8kH>xoH0a Xy2z;5-DBsn00000NkvXXu0mjfG!(m& literal 0 HcmV?d00001 diff --git a/web/public-storefront/src/app/globals.css b/web/public-storefront/src/app/globals.css index 1b588c6e..c0de60a9 100644 --- a/web/public-storefront/src/app/globals.css +++ b/web/public-storefront/src/app/globals.css @@ -1,23 +1,41 @@ @import "tailwindcss"; @theme { - --color-obol-bg: #091011; - --color-obol-bg-card: #0f1c1e; - --color-obol-bg-hover: #162a2e; - --color-obol-border: #1e3a3f; + /* Background scale (dark theme) — mirrors obol-ui stitches.config.ts */ + --color-bg01: #091011; + --color-bg02: #111f22; + --color-bg03: #182d32; + --color-bg04: #243d42; + --color-bg05: #2d4d53; + + /* Brand */ --color-obol-green: #2fe4ab; --color-obol-green-dim: #1a7a5c; --color-obol-blue: #162a40; - --color-obol-text: #e0e8ea; - --color-obol-muted: #7a9a9f; - --color-obol-amber: #e89e30; - --color-obol-red: #dd603c; + --color-obol-purple: #9167e4; + + /* Text */ + --color-text-light: #d9eef3; + --color-text-body: #9cc2c9; + --color-text-muted: #475e64; + + /* Stroke / border */ + --color-stroke: #1e3a3f; - --font-sans: "DM Sans", system-ui, -apple-system, sans-serif; + /* Status */ + --color-amber: #e89e30; + --color-red: #dd603c; + + --font-sans: var(--font-dm-sans), system-ui, -apple-system, sans-serif; --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace; } body { - background-color: var(--color-obol-bg); - color: var(--color-obol-text); + background-color: var(--color-bg01); + color: var(--color-text-light); +} + +/* Subtle scrollbars on dark bg */ +* { + scrollbar-color: var(--color-bg04) var(--color-bg01); } diff --git a/web/public-storefront/src/app/layout.tsx b/web/public-storefront/src/app/layout.tsx index c25e1451..17dc50e6 100644 --- a/web/public-storefront/src/app/layout.tsx +++ b/web/public-storefront/src/app/layout.tsx @@ -1,9 +1,27 @@ import type { Metadata } from "next"; +import { DM_Sans } from "next/font/google"; import "./globals.css"; +const dmSans = DM_Sans({ + subsets: ["latin"], + display: "swap", + weight: ["400", "500", "600", "700"], + variable: "--font-dm-sans", +}); + export const metadata: Metadata = { - title: "Obol Stack", - description: "Decentralised infrastructure services via x402 micropayments", + title: "Obol Stack — Agent services", + description: + "Decentralised infrastructure services from this Obol Agent, available via x402 micropayments.", + icons: { + icon: "/favicon.png", + }, + openGraph: { + title: "Obol Stack — Agent services", + description: + "Decentralised infrastructure services via x402 micropayments.", + images: ["/obol-stack-logo.png"], + }, }; export default function RootLayout({ @@ -12,7 +30,7 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + {children} ); diff --git a/web/public-storefront/src/app/page.tsx b/web/public-storefront/src/app/page.tsx index 9e8c7b1c..2155525b 100644 --- a/web/public-storefront/src/app/page.tsx +++ b/web/public-storefront/src/app/page.tsx @@ -1,12 +1,20 @@ import type { Service } from "@/types"; -import { ServiceCard } from "@/components/ServiceCard"; +import { Header } from "@/components/Header"; +import { ServicesList } from "@/components/ServicesList"; import { PaymentFlow } from "@/components/PaymentFlow"; +// Always render fresh — newly-deployed demos must appear immediately. The +// underlying services.json is built from a Kubernetes ConfigMap that the +// controller updates on every ServiceOffer reconcile, and the client list +// then polls every 10s to surface further changes without a page reload. +export const dynamic = "force-dynamic"; +export const revalidate = 0; + async function getServices(): Promise { try { const res = await fetch( `${process.env.SERVICES_URL ?? "http://obol-skill-md.x402.svc:8080"}/api/services.json`, - { next: { revalidate: 30 } }, + { cache: "no-store" }, ); if (!res.ok) return []; return res.json(); @@ -17,88 +25,41 @@ async function getServices(): Promise { export default async function Home() { const services = await getServices(); - const demos = services.filter((s) => s.isDemo); - const others = services.filter((s) => !s.isDemo); return ( -
-
-

Obol Stack

-

- This node sells decentralised infrastructure services via{" "} - - x402 - {" "} - micropayments. -

-
- - {services.length === 0 ? ( -
-

- No services are currently available. -

-

- Run{" "} - - obol sell demo hello - {" "} - to deploy your first demo service. + <> +

+
+
+

+ Agent services +

+

+ This Obol Agent offers the following services for digital payment:

-
- ) : ( -
- {demos.length > 0 && ( -
-

- Demo Services -

-
- {demos.map((s) => ( - - ))} -
-
- )} + - {others.length > 0 && ( -
-

- Services -

-
- {others.map((s) => ( - - ))} -
-
- )} -
- )} + -
+ + + + ); } diff --git a/web/public-storefront/src/components/Header.tsx b/web/public-storefront/src/components/Header.tsx new file mode 100644 index 00000000..bc62b9be --- /dev/null +++ b/web/public-storefront/src/components/Header.tsx @@ -0,0 +1,18 @@ +import Image from "next/image"; + +export function Header() { + return ( +
+
+ Obol Stack +
+
+ ); +} diff --git a/web/public-storefront/src/components/PaymentFlow.tsx b/web/public-storefront/src/components/PaymentFlow.tsx index c3275e4b..34e657c9 100644 --- a/web/public-storefront/src/components/PaymentFlow.tsx +++ b/web/public-storefront/src/components/PaymentFlow.tsx @@ -1,63 +1,46 @@ export function PaymentFlow() { return ( -
-

- How x402 Payment Works -

-
    -
  1. - Send a request to any - service endpoint without payment headers -
  2. -
  3. - Receive HTTP 402 with{" "} - accepts{" "} - array containing payment requirements (scheme, network, amount, asset, - payTo) -
  4. -
  5. - Sign an ERC-3009{" "} - TransferWithAuthorization off-chain with your wallet -
  6. -
  7. - Base64-encode the signed payload and attach it as an{" "} - X-PAYMENT{" "} - header -
  8. -
  9. - Resend the request — the{" "} - x402 facilitator verifies and - settles on-chain -
  10. -
  11. - Receive the service response{" "} - with settlement receipt in{" "} - - X-PAYMENT-RESPONSE - -
  12. -
-

- The{" "} - - x402 Python SDK - {" "} - handles steps 2-5 automatically. See{" "} - - x402.org - {" "} - for the full protocol specification. -

-
+
+ + How x402 payments work + + ↓ + + +
+

+ A request without payment returns HTTP 402 with the price and a + payment recipe. The buyer (or their library) signs an off-chain + authorization, retries the request with an{" "} + X-PAYMENT{" "} + header, and the seller's facilitator settles on-chain. No gas + needed — the seller covers settlement. +

+

+ Useful for: data feeds, AI inference, subscriptions, digital + purchases, and agent-to-agent commerce. +

+

+ Spec:{" "} + + x402.org + {" "} + · Python SDK:{" "} + + pypi.org/project/x402 + +

+
+
); } diff --git a/web/public-storefront/src/components/ServiceCard.tsx b/web/public-storefront/src/components/ServiceCard.tsx index 1789249e..d2e9d89d 100644 --- a/web/public-storefront/src/components/ServiceCard.tsx +++ b/web/public-storefront/src/components/ServiceCard.tsx @@ -3,27 +3,24 @@ import { useState } from "react"; import type { Service } from "@/types"; -const typeLabels: Record = { - inference: "Inference", - http: "HTTP Service", - "fine-tuning": "Fine-tuning", -}; - const typeColors: Record = { - inference: "bg-obol-green/20 text-obol-green", - http: "bg-obol-blue/40 text-obol-text", - "fine-tuning": "bg-amber-900/30 text-obol-amber", + inference: "bg-obol-green/15 text-obol-green border border-obol-green/30", + http: "bg-bg03 text-text-body border border-stroke", + "fine-tuning": "bg-amber/15 text-amber border border-amber/30", }; +type Tab = "agent" | "other-ai" | "code"; + export function ServiceCard({ service }: { service: Service }) { - const [showSnippet, setShowSnippet] = useState(false); + const [open, setOpen] = useState(false); + const [tab, setTab] = useState("agent"); return ( -
+
-

+

{service.name}

{service.isDemo && ( @@ -32,34 +29,32 @@ export function ServiceCard({ service }: { service: Service }) { )}
-

{service.description}

+

{service.description}

- {typeLabels[service.type] ?? service.type} + {service.type}
- Price -

{service.price}

+ Price +

{service.price}

- Network -

{service.network}

+ Network +

{service.network}

{service.model && (
- Model -

- {service.model} -

+ Model +

{service.model}

)}
- Endpoint + Endpoint

{service.endpoint}

@@ -67,41 +62,154 @@ export function ServiceCard({ service }: { service: Service }) {
- {showSnippet && ( -
- - + + + {tab === "agent" && } + {tab === "other-ai" && } + {tab === "code" && } +
+ )} +
+ ); +} + +function TabBar({ tab, onChange }: { tab: Tab; onChange: (t: Tab) => void }) { + const tabs: { id: Tab; label: string }[] = [ + { id: "agent", label: "Ask your Obol agent" }, + { id: "other-ai", label: "Ask another AI agent" }, + { id: "code", label: "Buy with code" }, + ]; + return ( +
+ {tabs.map((t) => { + const active = t.id === tab; + return ( + + ); + })} +
+ ); +} + +function BuyViaObolAgent({ service }: { service: Service }) { + const prompt = `Use the buy-x402 skill to call the paid service at ${service.endpoint}. It costs ${service.price} on ${service.network}. Report what it returns.`; + return ( +
+

+ Paste this into your Obol agent. The agent uses the built-in{" "} + buy-x402 skill to + sign and send the payment. +

+ +
+ ); +} + +function BuyViaOtherAgent({ service }: { service: Service }) { + const prompt = `I want to purchase a service offered by an Obol Agent at ${service.endpoint} for ${service.price} on ${service.network}. Please install the run-obol-stack skill from https://github.com/ObolNetwork/skills, ask me for permission to set up the obol stack, and use the buy-x402 skill to make the purchase on my behalf.`; + return ( +
+

+ Don't have an Obol agent yet? Any AI agent can purchase this + service after installing the{" "} + run-obol-stack skill + from{" "} + + ObolNetwork/skills + + . The skill bootstraps the stack and asks for your permission before + spending. +

+ +
+ ); +} + +function BuyWithCode({ service }: { service: Service }) { + return ( +
+
+

+ 1. Check the API pricing +

+ +
+ +
+

+ 2. Pay for the service +

+ +
+
+ ); +} + +function LanguageTabs({ service }: { service: Service }) { + // Layout reserves a language selector slot for future JS/TS additions — + // Python is the only currently-supported snippet. + const [lang] = useState<"python">("python"); + + const tokenName = service.network === "ethereum" ? "OBOL" : "USDC"; + const python = `import httpx from x402.client import x402_client client = x402_client( httpx.Client(), - private_key="", + private_key="", ) resp = client.get("${service.endpoint}") -print(resp.json())`} - /> - -
- )} +print(resp.json())`; + + return ( +
+
+ + Python + + + JS/TS (soon) + +
+ {lang === "python" && }
); } -function SnippetBlock({ title, code }: { title: string; code: string }) { +function Snippet({ code }: { code: string }) { const [copied, setCopied] = useState(false); const copy = () => { @@ -111,19 +219,16 @@ function SnippetBlock({ title, code }: { title: string; code: string }) { }; return ( -
-
- {title} - -
-
+    
+
         {code}
       
+
); } diff --git a/web/public-storefront/src/components/ServicesList.tsx b/web/public-storefront/src/components/ServicesList.tsx new file mode 100644 index 00000000..ab6e83a3 --- /dev/null +++ b/web/public-storefront/src/components/ServicesList.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Service } from "@/types"; +import { ServiceCard } from "./ServiceCard"; + +const REFRESH_INTERVAL_MS = 10_000; + +export function ServicesList({ initial }: { initial: Service[] }) { + const [services, setServices] = useState(initial); + + useEffect(() => { + let cancelled = false; + const tick = async () => { + try { + const res = await fetch("/api/services.json", { cache: "no-store" }); + if (!res.ok) return; + const fresh: Service[] = await res.json(); + if (!cancelled) setServices(fresh); + } catch { + // Network blip — keep existing list, retry next tick. + } + }; + const id = setInterval(tick, REFRESH_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, []); + + if (services.length === 0) { + return ( +
+

+ No services are currently available. +

+

+ Run{" "} + + obol sell demo + {" "} + to deploy your first demo service. +

+
+ ); + } + + const demos = services.filter((s) => s.isDemo); + const others = services.filter((s) => !s.isDemo); + + return ( +
+ {demos.length > 0 && ( +
+

+ Demo services +

+
+ {demos.map((s) => ( + + ))} +
+
+ )} + + {others.length > 0 && ( +
+

+ Services +

+
+ {others.map((s) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/web/public-storefront/tsconfig.json b/web/public-storefront/tsconfig.json index 55e2a026..7bbb10fd 100644 --- a/web/public-storefront/tsconfig.json +++ b/web/public-storefront/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -11,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -19,9 +23,19 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } From e325b791666f24898328d1c5892b6b6ac4c5e77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Sat, 2 May 2026 20:29:07 +0100 Subject: [PATCH 3/3] Handle changing tunnel --- cmd/obol/sell.go | 14 ++++++++++--- cmd/obol/sell_test.go | 43 ++++++++++++++++++++++++++++++++++++++- internal/tunnel/tunnel.go | 42 +++++++++++++++++++++++++++++++++++--- 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index c20216b4..f51a57be 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -930,10 +930,14 @@ func buildSellHTTPRegistrationConfig(serviceName string, in sellHTTPRegistration return reg, true, nil } -func serviceOfferStatusLines(namespace, name string, offer monetizeapi.ServiceOffer) []string { +func serviceOfferStatusLines(namespace, name string, offer monetizeapi.ServiceOffer, baseURL string) []string { + endpoint := valueOrNone(offer.Status.Endpoint) + if baseURL != "" && offer.Status.Endpoint != "" { + endpoint = strings.TrimRight(baseURL, "/") + offer.Status.Endpoint + } lines := []string{ fmt.Sprintf("ServiceOffer: %s/%s", namespace, name), - fmt.Sprintf("Endpoint: %s", valueOrNone(offer.Status.Endpoint)), + fmt.Sprintf("Endpoint: %s", endpoint), fmt.Sprintf("Agent ID: %s", valueOrNone(offer.Status.AgentID)), fmt.Sprintf("Registration Tx: %s", valueOrNone(offer.Status.RegistrationTxHash)), "", @@ -1534,7 +1538,11 @@ func sellStatusCommand(cfg *config.Config) *cli.Command { return fmt.Errorf("parse ServiceOffer: %w", err) } - for _, line := range serviceOfferStatusLines(ns, name, offer) { + // Best-effort: pull the public tunnel URL so the Endpoint line + // shows the full URL buyers should hit, not just the path. + baseURL, _ := tunnel.GetTunnelURL(cfg) + + for _, line := range serviceOfferStatusLines(ns, name, offer, baseURL) { if line == "" { u.Blank() continue diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index d2e04517..0ccf7fe6 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -257,7 +257,7 @@ func TestServiceOfferStatusLines(t *testing.T) { }, }, } - lines := serviceOfferStatusLines("llm", "demo", offer) + lines := serviceOfferStatusLines("llm", "demo", offer, "") joined := strings.Join(lines, "\n") for _, want := range []string{ "ServiceOffer: llm/demo", @@ -271,6 +271,47 @@ func TestServiceOfferStatusLines(t *testing.T) { } } +// TestServiceOfferStatusLines_FullURL verifies that when the tunnel URL is +// passed as baseURL, the Endpoint line shows the full https://… URL buyers +// would actually hit (not just the path). Trailing slashes on the base URL +// must not produce double-slashes. +func TestServiceOfferStatusLines_FullURL(t *testing.T) { + offer := monetizeapi.ServiceOffer{ + Status: monetizeapi.ServiceOfferStatus{ + Endpoint: "/services/demo-hello", + }, + } + tests := []struct { + name string + baseURL string + want string + }{ + { + name: "with tunnel URL", + baseURL: "https://records-vast-insert-gear.trycloudflare.com", + want: "Endpoint: https://records-vast-insert-gear.trycloudflare.com/services/demo-hello", + }, + { + name: "trailing slash trimmed", + baseURL: "https://records-vast-insert-gear.trycloudflare.com/", + want: "Endpoint: https://records-vast-insert-gear.trycloudflare.com/services/demo-hello", + }, + { + name: "no tunnel URL falls back to path", + baseURL: "", + want: "Endpoint: /services/demo-hello", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + joined := strings.Join(serviceOfferStatusLines("demo", "demo-hello", offer, tt.baseURL), "\n") + if !strings.Contains(joined, tt.want) { + t.Errorf("expected %q in:\n%s", tt.want, joined) + } + }) + } +} + func TestSellDemo_Flags(t *testing.T) { cfg := newTestConfig(t) cmd := sellCommand(cfg) diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 05d52f56..76e83b53 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -284,7 +284,15 @@ func EnsureRunning(cfg *config.Config, u *ui.UI) (string, error) { return WaitReady(cfg, u) } -// Restart restarts the cloudflared deployment. +// Restart restarts the cloudflared deployment and propagates the new tunnel +// URL to dependent resources (obol-stack-config ConfigMap, agent overlay, +// storefront HTTPRoute hostname pin). Quick tunnels get a new URL on every +// restart, so dependents must be refreshed or sell flows break: +// - skill.md / services.json embed the stale base URL until the controller +// observes the ConfigMap change +// - the storefront HTTPRoute is hostname-pinned; without an update it points +// at the old tunnel hostname and traffic to the new hostname's `/` falls +// through to the frontend catch-all func Restart(cfg *config.Config, u *ui.UI) error { kubectlPath := filepath.Join(cfg.BinDir, "kubectl") kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") @@ -306,9 +314,37 @@ func Restart(cfg *config.Config, u *ui.UI) error { return fmt.Errorf("failed to restart tunnel: %w", err) } + // Wait for the rollout to complete BEFORE asking for the URL. Otherwise + // WaitReady's fast path may pick up the OLD pod's logs (still running + // during the rolling update) and return the stale URL. + rolloutCmd := exec.Command(kubectlPath, + "--kubeconfig", kubeconfigPath, + "rollout", "status", "deployment/cloudflared", + "-n", tunnelNamespace, + "--timeout=120s", + ) + if err := u.Exec(ui.ExecConfig{ + Name: "Waiting for new cloudflared pod", + Cmd: rolloutCmd, + }); err != nil { + return fmt.Errorf("rollout did not complete: %w", err) + } + + // Capture the new URL and update everything that needs the base URL. + // WaitReady also calls InjectBaseURL + SyncTunnelConfigMap. + newURL, err := WaitReady(cfg, u) + if err != nil { + return fmt.Errorf("tunnel restarted but new URL not captured: %w", err) + } + + // Refresh the storefront HTTPRoute (hostname-pinned to the tunnel domain). + if err := CreateStorefront(cfg, newURL); err != nil { + u.Warnf("could not refresh storefront for new URL: %v", err) + } + u.Blank() - u.Print("Tunnel restarting...") - u.Print("Run 'obol tunnel status' to see the URL once ready (may take 10-30 seconds).") + u.Successf("Tunnel restarted: %s", newURL) + u.Dim(" /skill.md, /api/services.json, and the storefront now reflect the new URL.") return nil }