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 a07bd713..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)), "", @@ -958,29 +962,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,34 +1004,40 @@ 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 - obol sell demo oracle --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", 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{ 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 (defaults to demo type's default token)", }, &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", @@ -1027,13 +1047,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") @@ -1041,6 +1067,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 == "" { @@ -1049,7 +1088,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") } @@ -1066,7 +1105,16 @@ Example: price = spec.Price } - chain := cmd.String("chain") + // Resolve token metadata. resolveAssetTermsFor may flip chain to ethereum + // for non-USDC tokens when --chain wasn't explicitly set. + assetTerms, err := resolveAssetTermsFor(tokenName, &chain, chainExplicit) + if err != nil { + return err + } + symbol := assetTerms.Symbol + if symbol == "" { + symbol = "USDC" + } u.Infof("Deploying demo %q (%s)", typeName, spec.Description) @@ -1076,7 +1124,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 +1133,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) @@ -1102,15 +1150,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, 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) @@ -1263,7 +1347,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 +1376,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,7 +1389,9 @@ 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) { +// `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 @@ -1310,42 +1401,46 @@ func printDemoTryIt(u *ui.UI, name, typeName, price, chain, tunnelURL string) { 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):") - 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(` private_key="", # USDC holder on ` + chain) - u.Dim(" )") - u.Dim(fmt.Sprintf(` resp = client.get("%s")`, endpoint)) - u.Dim(" print(resp.json())") + // 2. Or check it out manually — pricing probe + paid request. + u.Printf(" Or check it out manually") u.Blank() - - u.Printf(" 3. How x402 payment works:") + 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.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(" • 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 USDC per request on %s.`, price, 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("─────────────────────────────────────────────────────────") @@ -1443,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 @@ -2712,7 +2811,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" { @@ -2723,11 +2829,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 f5ad88f2..0ccf7fe6 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" ) @@ -256,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", @@ -270,14 +271,98 @@ 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) demo := findSubcommand(t, cmd, "demo") flags := flagMap(demo) - requireFlags(t, flags, "wallet", "chain", "price", "name") - assertStringDefault(t, flags, "chain", "base") + requireFlags(t, flags, "wallet", "chain", "token", "price", "name") + // 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) { @@ -427,6 +512,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-quant", "demo", "ethereum", + "0x2222222222222222222222222222222222222222", + "0.001", + demoSpec{Type: "quant", Description: "agent driven 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/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/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) { 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 } diff --git a/web/public-storefront/public/favicon.png b/web/public-storefront/public/favicon.png new file mode 100644 index 00000000..ff9929ad Binary files /dev/null and b/web/public-storefront/public/favicon.png differ diff --git a/web/public-storefront/public/obol-logo.png b/web/public-storefront/public/obol-logo.png new file mode 100644 index 00000000..ff9929ad Binary files /dev/null and b/web/public-storefront/public/obol-logo.png differ 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 00000000..c861b550 Binary files /dev/null and b/web/public-storefront/public/obol-stack-logo.png differ 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" + ] }