From c55d9a0c878ff66287716efed4853d80bcb3af43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 19 Jun 2026 11:39:17 +0100 Subject: [PATCH 1/5] WIP towards multi price services (including non standard tokens) --- cmd/obol/accept.go | 398 ++++++++++++++++++ cmd/obol/accept_test.go | 113 +++++ cmd/obol/sell.go | 58 ++- cmd/obol/sell_agent.go | 146 ++++--- cmd/obol/sell_test.go | 13 + internal/embed/embed_crd_test.go | 16 +- .../base/templates/serviceoffer-crd.yaml | 101 +++++ internal/monetizeapi/types.go | 41 ++ internal/monetizeapi/types_test.go | 27 ++ internal/monetizeapi/zz_generated.deepcopy.go | 8 + internal/schemas/service-catalog.schema.json | 12 +- internal/schemas/service_catalog.go | 11 +- internal/serviceoffercontroller/render.go | 36 +- .../serviceoffercontroller/render_test.go | 4 +- internal/x402/chains.go | 44 +- internal/x402/config.go | 60 +++ internal/x402/forwardauth.go | 10 + internal/x402/serviceoffer_source.go | 90 ++-- internal/x402/verifier.go | 236 +++++++---- internal/x402/verifier_test.go | 73 ++++ web/public-storefront/src/app/layout.tsx | 6 +- .../src/components/ServiceCard.tsx | 4 +- .../src/components/ServicesList.tsx | 50 ++- web/public-storefront/src/types.ts | 7 +- 24 files changed, 1308 insertions(+), 256 deletions(-) create mode 100644 cmd/obol/accept.go create mode 100644 cmd/obol/accept_test.go diff --git a/cmd/obol/accept.go b/cmd/obol/accept.go new file mode 100644 index 00000000..ef05bdfd --- /dev/null +++ b/cmd/obol/accept.go @@ -0,0 +1,398 @@ +package main + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/ObolNetwork/obol-stack/internal/schemas" + x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" + "github.com/shopspring/decimal" + "github.com/urfave/cli/v3" +) + +// --accept lets a seller advertise multiple payment options (currencies / +// networks) for one offer. Each --accept is a comma-separated key=value list: +// +// --accept token=USDC,network=base,price=1,pay-to=0x... +// --accept token=OBOL,network=ethereum,price=10 +// --accept asset=0x...,decimals=18,transfer=permit2,eip712-name=Foo,eip712-version=1,symbol=FOO,network=base,price=0.5 +// +// `token=` resolves the full asset block from the built-in registry +// (the curated, best-in-class path). `asset=0x...` is the escape hatch for any +// ERC-20 on a supported chain: the seller supplies decimals + transfer + +// eip712-name/version themselves. The two are mutually exclusive. +// +// Arbitrary (unsupported) chains are intentionally NOT accepted yet — every +// option's network must resolve via ResolveChainInfo. Raw asset on an +// arbitrary eip155: chain is a planned follow-up (needs facilitator-support +// + buy.py chain-mapping verification). + +var evmAddressRe = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`) + +// acceptOption is one parsed, validated --accept entry. Network is normalized +// to the canonical chain name; Asset is zero for the USDC chain-default (which +// the verifier fills in), set otherwise. +type acceptOption struct { + Network string + PayTo string + PriceKey string // perRequest | perMTok | perHour | perEpoch + PriceVal string + Asset schemas.AssetTerms + // dedupKey identifies the (chain, token) pair for duplicate detection. + dedupKey string +} + +var acceptPriceKeys = map[string]string{ + "price": "perRequest", + "per-request": "perRequest", + "per-mtok": "perMTok", + "per-hour": "perHour", + "per-epoch": "perEpoch", +} + +var acceptKnownKeys = map[string]bool{ + "token": true, "network": true, "chain": true, "pay-to": true, + "price": true, "per-request": true, "per-mtok": true, "per-hour": true, "per-epoch": true, + "asset": true, "decimals": true, "transfer": true, "symbol": true, + "eip712-name": true, "eip712-version": true, "max-timeout": true, +} + +// parseAcceptKV splits a single --accept value into its key/value pairs. +func parseAcceptKV(raw string) (map[string]string, int64, error) { + out := map[string]string{} + var maxTimeout int64 + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + k, v, ok := strings.Cut(part, "=") + k = strings.ToLower(strings.TrimSpace(k)) + v = strings.TrimSpace(v) + if !ok || k == "" || v == "" { + return nil, 0, fmt.Errorf("malformed --accept segment %q (want key=value)", part) + } + if !acceptKnownKeys[k] { + return nil, 0, fmt.Errorf("unknown --accept key %q", k) + } + if k == "max-timeout" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n <= 0 { + return nil, 0, fmt.Errorf("--accept max-timeout %q must be a positive integer", v) + } + maxTimeout = n + continue + } + if _, dup := out[k]; dup { + return nil, 0, fmt.Errorf("--accept key %q given twice", k) + } + out[k] = v + } + return out, maxTimeout, nil +} + +// parseAcceptOption parses and validates one --accept value. defaultPayTo is +// the command-level --pay-to, used when the option omits pay-to. +func parseAcceptOption(raw, defaultPayTo string) (acceptOption, int64, error) { + kv, maxTimeout, err := parseAcceptKV(raw) + if err != nil { + return acceptOption{}, 0, err + } + + // Network (required) — must resolve to a supported chain. + network := kv["network"] + if network == "" { + network = kv["chain"] + } + if network == "" { + return acceptOption{}, 0, fmt.Errorf("--accept %q: network is required", raw) + } + chainInfo, err := x402verifier.ResolveChainInfo(network) + if err != nil { + return acceptOption{}, 0, fmt.Errorf("--accept %q: %w", raw, err) + } + canonicalChain := chainInfo.Name + + // payTo (required) — option-level wins, else the command default. + payTo := kv["pay-to"] + if payTo == "" { + payTo = strings.TrimSpace(defaultPayTo) + } + if !evmAddressRe.MatchString(payTo) { + return acceptOption{}, 0, fmt.Errorf("--accept %q: pay-to must be a 0x EVM address (got %q)", raw, payTo) + } + + // Exactly one price slot. + priceKey, priceVal := "", "" + for flag, slot := range acceptPriceKeys { + if v := kv[flag]; v != "" { + if priceKey != "" { + return acceptOption{}, 0, fmt.Errorf("--accept %q: set only one of price/per-request/per-mtok/per-hour/per-epoch", raw) + } + priceKey, priceVal = slot, v + } + } + if priceKey == "" { + return acceptOption{}, 0, fmt.Errorf("--accept %q: a price is required (price=, per-mtok=, …)", raw) + } + if d, derr := decimal.NewFromString(priceVal); derr != nil || d.IsNegative() { + return acceptOption{}, 0, fmt.Errorf("--accept %q: price %q must be a non-negative decimal", raw, priceVal) + } + + // Asset: token= (registry) XOR asset=0x... (raw). Default USDC + // when neither is given (matches the singular-flag default). + tokenSym := strings.TrimSpace(kv["token"]) + rawAddr := strings.TrimSpace(kv["asset"]) + opt := acceptOption{Network: canonicalChain, PayTo: payTo, PriceKey: priceKey, PriceVal: priceVal} + + switch { + case tokenSym != "" && rawAddr != "": + return acceptOption{}, 0, fmt.Errorf("--accept %q: set either token= or asset=0x..., not both", raw) + + case rawAddr != "": + if !evmAddressRe.MatchString(rawAddr) { + return acceptOption{}, 0, fmt.Errorf("--accept %q: asset must be a 0x ERC-20 address (got %q)", raw, rawAddr) + } + dec, derr := strconv.Atoi(kv["decimals"]) + if derr != nil || dec <= 0 || dec > 255 { + return acceptOption{}, 0, fmt.Errorf("--accept %q: raw asset needs decimals=<1-255>", raw) + } + transfer := strings.ToLower(kv["transfer"]) + if transfer != schemas.AssetTransferMethodEIP3009 && transfer != schemas.AssetTransferMethodPermit2 { + return acceptOption{}, 0, fmt.Errorf("--accept %q: raw asset needs transfer=eip3009|permit2", raw) + } + symbol := strings.TrimSpace(kv["symbol"]) + name := strings.TrimSpace(kv["eip712-name"]) + version := strings.TrimSpace(kv["eip712-version"]) + if symbol == "" || name == "" || version == "" { + return acceptOption{}, 0, fmt.Errorf("--accept %q: raw asset needs symbol, eip712-name and eip712-version (the token's EIP-712 signing domain)", raw) + } + opt.Asset = schemas.AssetTerms{ + Address: rawAddr, Symbol: symbol, Decimals: dec, + TransferMethod: transfer, EIP712Name: name, EIP712Version: version, + } + opt.dedupKey = canonicalChain + "\x00" + strings.ToLower(rawAddr) + + default: + // Registry shorthand. USDC is the chain default (empty asset block). + if tokenSym == "" { + tokenSym = "USDC" + } + if strings.EqualFold(tokenSym, "USDC") { + opt.dedupKey = canonicalChain + "\x00usdc" + break + } + entry, ok := x402verifier.ResolveToken(tokenSym, canonicalChain) + if !ok { + return acceptOption{}, 0, fmt.Errorf( + "--accept %q: token %s is not in the registry for %s (use asset=0x... with decimals/transfer/eip712 for an unlisted token)", + raw, tokenSym, canonicalChain) + } + opt.Asset = schemas.AssetTerms{ + Address: entry.Address, Symbol: entry.Symbol, Decimals: entry.Decimals, + TransferMethod: entry.TransferMethod, EIP712Name: entry.EIP712Name, EIP712Version: entry.EIP712Version, + } + opt.dedupKey = canonicalChain + "\x00" + strings.ToLower(entry.Address) + } + + return opt, maxTimeout, nil +} + +// paymentMap renders the option as a ServiceOffer spec.payment(s) entry. +func (o acceptOption) paymentMap(maxTimeout int64) map[string]any { + if maxTimeout <= 0 { + maxTimeout = 300 + } + m := map[string]any{ + "scheme": "exact", + "network": o.Network, + "payTo": o.PayTo, + "maxTimeoutSeconds": maxTimeout, + "price": map[string]any{o.PriceKey: o.PriceVal}, + } + if !o.Asset.IsZero() { + m["asset"] = o.Asset + } + return m +} + +// buildAcceptPayments parses every --accept value into ServiceOffer payment +// maps, rejecting duplicate (chain, token) pairs. The returned slice is the +// spec.payments[] list; payments[0] is the primary option and callers also +// write it to spec.payment. Returns (nil, nil) when no --accept was given so +// callers can fall back to the singular --chain/--token/--price flags. +func buildAcceptPayments(accepts []string, defaultPayTo string) ([]map[string]any, error) { + if len(accepts) == 0 { + return nil, nil + } + payments := make([]map[string]any, 0, len(accepts)) + seen := map[string]string{} + for _, raw := range accepts { + opt, maxTimeout, err := parseAcceptOption(raw, defaultPayTo) + if err != nil { + return nil, err + } + if prev, dup := seen[opt.dedupKey]; dup { + return nil, fmt.Errorf("--accept duplicate payment option for the same (chain, token): %q and %q", prev, raw) + } + seen[opt.dedupKey] = raw + payments = append(payments, opt.paymentMap(maxTimeout)) + } + return payments, nil +} + +// priceTableToMap renders a resolved PriceTable as the CRD price block, +// emitting whichever single slot is populated. +func priceTableToMap(pt schemas.PriceTable) map[string]any { + price := map[string]any{} + switch { + case pt.PerRequest != "": + price["perRequest"] = pt.PerRequest + case pt.PerMTok != "": + price["perMTok"] = pt.PerMTok + case pt.PerHour != "": + price["perHour"] = pt.PerHour + case pt.PerEpoch != "": + price["perEpoch"] = pt.PerEpoch + } + return price +} + +// acceptFlags returns the shared --accept / --weight / --category flags so the +// three sell creation commands stay in lockstep. allowPerHour mirrors the +// command's own price flags but does not affect --accept (which carries its +// own price keys). +func acceptFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringSliceFlag{ + Name: "accept", + Usage: "Accepted payment option (repeatable) for multi-currency offers, e.g. " + + "--accept token=OBOL,network=ethereum,price=10 --accept token=USDC,network=base,price=1. " + + "Unlisted tokens: asset=0x..,decimals=..,transfer=eip3009|permit2,eip712-name=..,eip712-version=..,symbol=... " + + "When set, --chain/--token/--price are ignored.", + }, + &cli.IntFlag{ + Name: "weight", + Usage: "Storefront ordering weight; higher sorts earlier within its category", + }, + &cli.StringFlag{ + Name: "category", + Usage: "Storefront grouping section (e.g. \"demo\")", + }, + } +} + +// resolveOfferPayments builds the ServiceOffer payment block(s) for a creation +// command. When --accept is present it returns the multi-payment list (and +// payments[0] as the primary); otherwise it falls back to the singular +// --chain/--token/--price flags and returns a nil payments list. wallet is the +// already-resolved default recipient. The returned network/payTo reflect the +// PRIMARY option so callers can drive ERC-8004 registration off it (register +// on the first payment's network — the locked decision). +func resolveOfferPayments(cmd *cli.Command, wallet string, allowPerHour bool) (payment map[string]any, payments []map[string]any, network, payTo string, err error) { + if accepts := cmd.StringSlice("accept"); len(accepts) > 0 { + payments, err = buildAcceptPayments(accepts, wallet) + if err != nil { + return nil, nil, "", "", err + } + primary := payments[0] + net, _ := primary["network"].(string) + to, _ := primary["payTo"].(string) + return primary, payments, net, to, nil + } + + priceTable, perr := resolvePriceTable(cmd, allowPerHour) + if perr != nil { + return nil, nil, "", "", perr + } + chainName := cmd.String("chain") + assetTerms, aerr := resolveAssetTerms(cmd, &chainName) + if aerr != nil { + return nil, nil, "", "", aerr + } + maxTimeout := cmd.Int("max-timeout") + if maxTimeout <= 0 { + maxTimeout = 300 + } + payment = map[string]any{ + "scheme": "exact", + "network": chainName, + "payTo": wallet, + "maxTimeoutSeconds": maxTimeout, + "price": priceTableToMap(priceTable), + } + if !assetTerms.IsZero() { + payment["asset"] = assetTerms + } + return payment, nil, chainName, wallet, nil +} + +// paymentSymbol returns the display token symbol for a payment block: the +// explicit asset symbol, or "USDC" (the chain default) when none is set. +func paymentSymbol(payment map[string]any) string { + if a, ok := payment["asset"].(schemas.AssetTerms); ok && a.Symbol != "" { + return a.Symbol + } + return "USDC" +} + +// paymentPriceValue returns the first populated price-slot value of a payment +// block (perRequest / perMTok / perHour / perEpoch). +func paymentPriceValue(payment map[string]any) string { + pt, ok := payment["price"].(map[string]any) + if !ok { + return "" + } + for _, k := range []string{"perRequest", "perMTok", "perHour", "perEpoch"} { + if v, ok := pt[k].(string); ok && v != "" { + return v + } + } + return "" +} + +// applyListingFlags writes spec.listing from --weight/--category when either is +// set. No-op otherwise so offers without listing hints serialize unchanged. +func applyListingFlags(cmd *cli.Command, spec map[string]any) { + listing := map[string]any{} + if w := cmd.Int("weight"); w != 0 { + listing["weight"] = w + } + if c := strings.TrimSpace(cmd.String("category")); c != "" { + listing["category"] = c + } + if len(listing) > 0 { + spec["listing"] = listing + } +} + +// acceptSummary renders a short "TOKEN on chain @ price" line per option for +// CLI confirmation output. Deterministic order (as given). +func acceptSummary(payments []map[string]any) string { + parts := make([]string, 0, len(payments)) + for _, p := range payments { + network, _ := p["network"].(string) + sym := "USDC" + if a, ok := p["asset"].(schemas.AssetTerms); ok && a.Symbol != "" { + sym = a.Symbol + } + price := "" + if pt, ok := p["price"].(map[string]any); ok { + keys := make([]string, 0, len(pt)) + for k := range pt { + keys = append(keys, k) + } + sort.Strings(keys) + if len(keys) > 0 { + if v, ok := pt[keys[0]].(string); ok { + price = v + } + } + } + parts = append(parts, fmt.Sprintf("%s on %s @ %s", sym, network, price)) + } + return strings.Join(parts, "; ") +} diff --git a/cmd/obol/accept_test.go b/cmd/obol/accept_test.go new file mode 100644 index 00000000..54085d91 --- /dev/null +++ b/cmd/obol/accept_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/schemas" +) + +const testPayTo = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +func TestParseAcceptOption_RegistryShorthand(t *testing.T) { + // USDC resolves to the chain default — no explicit asset block. + opt, _, err := parseAcceptOption("token=USDC,network=base,price=1,pay-to="+testPayTo, "") + if err != nil { + t.Fatalf("USDC: unexpected error: %v", err) + } + if opt.Network != "base" || opt.PriceKey != "perRequest" || opt.PriceVal != "1" { + t.Fatalf("USDC: got %+v", opt) + } + if !opt.Asset.IsZero() { + t.Errorf("USDC should use chain default (zero asset), got %+v", opt.Asset) + } + + // OBOL resolves to a full asset block from the registry. + obol, _, err := parseAcceptOption("token=OBOL,network=ethereum,price=10,pay-to="+testPayTo, "") + if err != nil { + t.Fatalf("OBOL: unexpected error: %v", err) + } + if obol.Asset.Symbol != "OBOL" || obol.Asset.TransferMethod != schemas.AssetTransferMethodPermit2 || obol.Asset.Decimals != 18 { + t.Fatalf("OBOL asset not resolved from registry: %+v", obol.Asset) + } +} + +func TestParseAcceptOption_DefaultPayToFallback(t *testing.T) { + opt, _, err := parseAcceptOption("token=USDC,network=base,price=1", testPayTo) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opt.PayTo != testPayTo { + t.Errorf("pay-to fallback = %q, want command default", opt.PayTo) + } +} + +func TestParseAcceptOption_RawAssetEscapeHatch(t *testing.T) { + raw := "asset=0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,decimals=18,transfer=permit2," + + "eip712-name=Foo Token,eip712-version=1,symbol=FOO,network=base,price=0.5,pay-to=" + testPayTo + opt, _, err := parseAcceptOption(raw, "") + if err != nil { + t.Fatalf("raw asset: unexpected error: %v", err) + } + if opt.Asset.Address != "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" || + opt.Asset.Symbol != "FOO" || opt.Asset.Decimals != 18 || + opt.Asset.TransferMethod != "permit2" || opt.Asset.EIP712Name != "Foo Token" || opt.Asset.EIP712Version != "1" { + t.Fatalf("raw asset block wrong: %+v", opt.Asset) + } +} + +func TestParseAcceptOption_Errors(t *testing.T) { + cases := []struct { + name, raw, want string + }{ + {"unknown key", "token=USDC,network=base,price=1,bogus=x,pay-to=" + testPayTo, "unknown --accept key"}, + {"no network", "token=USDC,price=1,pay-to=" + testPayTo, "network is required"}, + {"unsupported chain", "token=USDC,network=eip155:9999,price=1,pay-to=" + testPayTo, "unsupported chain"}, + {"no price", "token=USDC,network=base,pay-to=" + testPayTo, "price is required"}, + {"two prices", "token=USDC,network=base,price=1,per-mtok=2,pay-to=" + testPayTo, "only one of"}, + {"bad pay-to", "token=USDC,network=base,price=1,pay-to=nope", "pay-to must be"}, + {"token and asset", "token=USDC,asset=0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,network=base,price=1,pay-to=" + testPayTo, "not both"}, + {"raw missing meta", "asset=0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,network=base,price=1,pay-to=" + testPayTo, "decimals"}, + {"unregistered token", "token=WETH,network=base,price=1,pay-to=" + testPayTo, "not in the registry"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := parseAcceptOption(tc.raw, "") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want containing %q", err, tc.want) + } + }) + } +} + +func TestBuildAcceptPayments(t *testing.T) { + // No --accept → nil so callers fall back to singular flags. + got, err := buildAcceptPayments(nil, testPayTo) + if err != nil || got != nil { + t.Fatalf("empty accepts: got %v, %v; want nil,nil", got, err) + } + + // Two distinct options → spec.payments[] with two entries. + payments, err := buildAcceptPayments([]string{ + "token=USDC,network=base,price=1", + "token=OBOL,network=ethereum,price=10", + }, testPayTo) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(payments) != 2 { + t.Fatalf("payments len = %d, want 2", len(payments)) + } + if payments[0]["network"] != "base" || payments[1]["network"] != "ethereum" { + t.Fatalf("payments order/networks wrong: %+v", payments) + } + + // Duplicate (chain, token) is rejected. + _, err = buildAcceptPayments([]string{ + "token=USDC,network=base,price=1", + "token=USDC,network=base,price=2", + }, testPayTo) + if err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("expected duplicate error, got %v", err) + } +} diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 6e1287c7..f3c68ffa 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -672,7 +672,7 @@ Use --no-register to skip the on-chain registration step. Examples: obol sell http my-cool-api --upstream my-svc.my-namespace.svc.cluster.local --port 8080 --pay-to 0x... --price 0.01 --chain base obol sell http my-cool-api --upstream my-svc --port 8080 --pay-to 0x... --price 0.01 --chain base --no-register`, - Flags: []cli.Flag{ + Flags: append([]cli.Flag{ payToFlag("Payment recipient address"), &cli.StringFlag{ Name: "chain", @@ -771,7 +771,7 @@ Examples: Name: "from-json", Usage: "Read ServiceOffer spec from JSON file (or - for stdin) instead of flags", }, - }, + }, acceptFlags()...), Action: func(ctx context.Context, cmd *cli.Command) error { u := getUI(cmd) @@ -873,27 +873,14 @@ Examples: return fmt.Errorf("upstream port required: use --port \n\n Example: obol sell http %s --upstream my-svc --port 8080 --pay-to 0x... --chain base-sepolia --price 0.001", name) } - priceTable, err := resolvePriceTable(cmd, true) - if err != nil { - return err - } - - price := map[string]any{} - - switch { - case priceTable.PerRequest != "": - price["perRequest"] = priceTable.PerRequest - case priceTable.PerMTok != "": - price["perMTok"] = priceTable.PerMTok - case priceTable.PerHour != "": - price["perHour"] = priceTable.PerHour - } - - chainName := cmd.String("chain") - assetTerms, err := resolveAssetTerms(cmd, &chainName) + // Build the payment block(s): multi-currency via --accept, else + // the singular --chain/--token/--price flags. chainName/wallet are + // re-pointed at the primary option so registration lands on it. + paymentBlock, paymentsList, chainName, primaryPayTo, err := resolveOfferPayments(cmd, wallet, true) if err != nil { return err } + wallet = primaryPayTo spec := map[string]any{ "type": "http", @@ -903,17 +890,12 @@ Examples: "port": cmd.Int("port"), "healthPath": cmd.String("health-path"), }, - "payment": map[string]any{ - "scheme": "exact", - "network": chainName, - "payTo": wallet, - "maxTimeoutSeconds": cmd.Int("max-timeout"), - "price": price, - }, + "payment": paymentBlock, } - if !assetTerms.IsZero() { - spec["payment"].(map[string]any)["asset"] = assetTerms + if paymentsList != nil { + spec["payments"] = paymentsList } + applyListingFlags(cmd, spec) if path := cmd.String("path"); path != "" { spec["path"] = path @@ -1005,8 +987,13 @@ Examples: action = "updated" } u.Successf("ServiceOffer %s/%s %s (type: http)", ns, name, action) - if priceTable.PerMTok != "" { - u.Infof("Requests will be charged at %s", formatPriceTableSummary(priceTable, assetTerms.Symbol)) + if paymentsList != nil { + u.Infof("Accepted payments: %s", acceptSummary(paymentsList)) + } + if pm, ok := paymentBlock["price"].(map[string]any); ok { + if _, isMTok := pm["perMTok"]; isMTok { + u.Info("Per-MTok price is charged as a per-request approximation (~1000 tok/request) until exact metering ships.") + } } u.Infof("The agent will reconcile: health-check → payment gate → route") u.Infof("Check status: obol sell status %s -n %s", name, ns) @@ -1030,7 +1017,7 @@ Examples: u.Blank() u.Info("Registering seller agent on ERC-8004...") if err := autoRegisterServiceOffer(ctx, cfg, u, autoRegisterOptions{ - ChainCSV: cmd.String("chain"), + ChainCSV: chainName, Endpoint: tunnelURL, AgentName: registrationNameForPrompt(name, reg), AgentDesc: registrationDescriptionForPrompt(name, reg), @@ -1039,7 +1026,7 @@ Examples: aw, _ := hermes.ResolveWalletAddress(cfg) printRegistrationNotice(u, registrationNotice{ Mode: regNoticeAutoFailed, - Chain: cmd.String("chain"), + Chain: chainName, PayTo: wallet, AgentWallet: aw, OfferName: name, @@ -2009,6 +1996,11 @@ func buildDemoServiceOffer(name, ns, chain, wallet, price string, register bool, }, "payment": payment, "path": "/services/" + name, + // Demo services are an ordinary storefront category now, not a + // special-cased flag — the catalog/storefront group on this. + "listing": map[string]any{ + "category": "demo", + }, "registration": map[string]any{ "enabled": register, "name": name, diff --git a/cmd/obol/sell_agent.go b/cmd/obol/sell_agent.go index ccc649d9..7a8f4315 100644 --- a/cmd/obol/sell_agent.go +++ b/cmd/obol/sell_agent.go @@ -36,8 +36,9 @@ to declare the agent, then ` + "`obol sell agent `" + ` to make it sellabl Examples: obol sell agent quant --price 0.01 --token USDC --chain base-sepolia - obol sell agent quant --price 10 --token OBOL --chain ethereum --pay-to 0xColdVault`, - Flags: []cli.Flag{ + obol sell agent quant --price 10 --token OBOL --chain ethereum --pay-to 0xColdVault + obol sell agent quant --accept token=USDC,network=base,price=1 --accept token=OBOL,network=ethereum,price=10`, + Flags: append([]cli.Flag{ payToFlag("Recipient for sale revenue (defaults to the agent's own wallet when one was provisioned)"), &cli.StringFlag{ Name: "chain", @@ -79,7 +80,7 @@ Examples: Aliases: []string{"register-description"}, Usage: "Human-readable description of the service. Surfaced on the 402 payment page, in the storefront catalog, and (when registration is enabled) on the ERC-8004 registration document. Defaults to the agent's objective.", }, - }, + }, acceptFlags()...), Action: func(ctx context.Context, cmd *cli.Command) error { u := getUI(cmd) if cmd.NArg() != 1 { @@ -128,40 +129,81 @@ Examples: } } - price := strings.TrimSpace(cmd.String("price")) - if price == "" { - price = strings.TrimSpace(cmd.String("per-request")) - } - if price == "" { - return fmt.Errorf("price required: use --price or --per-request") - } - - chain := cmd.String("chain") - tokenName := cmd.String("token") - - // Resolve token metadata. resolveAssetTermsFor may flip chain - // when the token isn't supported on the requested chain. - chainExplicit := cmd.IsSet("chain") - assetTerms, err := resolveAssetTermsFor(tokenName, &chain, chainExplicit) - if err != nil { - return err - } + accepts := cmd.StringSlice("accept") + // Resolve the default revenue recipient: --pay-to → agent's own + // wallet → host remote-signer. In --accept mode this is the + // default each option inherits when it omits its own pay-to. payTo := strings.TrimSpace(cmd.String("pay-to")) if payTo == "" { - // Default order: agent's own wallet → host remote-signer. if agent.WalletAddress != "" { payTo = agent.WalletAddress u.Infof("Routing revenue to agent's own wallet: %s", payTo) - } else if resolved, err := hermes.ResolveWalletAddress(cfg); err == nil { + } else if resolved, rerr := hermes.ResolveWalletAddress(cfg); rerr == nil { payTo = resolved u.Infof("Routing revenue to host remote-signer wallet: %s", payTo) - } else { + } else if len(accepts) == 0 { return fmt.Errorf("recipient required: use --pay-to or provision a wallet at agent creation time") } } - if err := x402verifier.ValidateWallet(payTo); err != nil { - return err + if payTo != "" { + if err := x402verifier.ValidateWallet(payTo); err != nil { + return err + } + } + + // Build the payment block(s): multi-currency via --accept, else + // the singular --chain/--token/--price flags. The primary option + // (payments[0]) drives the success line + registration metadata. + var payment map[string]any + var paymentsList []map[string]any + var primaryNetwork, primaryPayTo, primarySymbol, primaryPrice string + if len(accepts) > 0 { + paymentsList, err = buildAcceptPayments(accepts, payTo) + if err != nil { + return err + } + payment = paymentsList[0] + primaryNetwork, _ = payment["network"].(string) + primaryPayTo, _ = payment["payTo"].(string) + primarySymbol = paymentSymbol(payment) + primaryPrice = paymentPriceValue(payment) + } else { + price := strings.TrimSpace(cmd.String("price")) + if price == "" { + price = strings.TrimSpace(cmd.String("per-request")) + } + if price == "" { + return fmt.Errorf("price required: use --price/--per-request, or --accept for multi-currency") + } + if payTo == "" { + return fmt.Errorf("recipient required: use --pay-to or provision a wallet at agent creation time") + } + chain := cmd.String("chain") + tokenName := cmd.String("token") + // resolveAssetTermsFor may flip chain when the token isn't + // supported on the requested chain. + assetTerms, aerr := resolveAssetTermsFor(tokenName, &chain, cmd.IsSet("chain")) + if aerr != nil { + return aerr + } + payment = map[string]any{ + "scheme": "exact", + "network": chain, + "payTo": payTo, + "maxTimeoutSeconds": cmd.Int("max-timeout"), + "price": map[string]any{"perRequest": price}, + } + if !assetTerms.IsZero() { + payment["asset"] = assetTerms + } + primaryNetwork = chain + primaryPayTo = payTo + primarySymbol = assetTerms.Symbol + if primarySymbol == "" { + primarySymbol = strings.ToUpper(tokenName) + } + primaryPrice = price } path := strings.TrimSpace(cmd.String("path")) @@ -183,18 +225,6 @@ Examples: // the controller to resolve upstream from the Agent CR; we // don't supply spec.upstream here. offerNs := agent.Namespace - payment := map[string]any{ - "scheme": "exact", - "network": chain, - "payTo": payTo, - "maxTimeoutSeconds": cmd.Int("max-timeout"), - "price": map[string]any{ - "perRequest": price, - }, - } - if !assetTerms.IsZero() { - payment["asset"] = assetTerms - } spec := map[string]any{ "type": "agent", "agent": map[string]any{ @@ -206,6 +236,10 @@ Examples: "payment": payment, "path": path, } + if paymentsList != nil { + spec["payments"] = paymentsList + } + applyListingFlags(cmd, spec) // The registration block is always set — the catalog // (/api/services.json, /skill.md) and the 402 page read // registration.description and registration.skills regardless @@ -217,16 +251,14 @@ Examples: for i, s := range agent.Skills { skills[i] = s } - symbol := assetTerms.Symbol - if symbol == "" { - symbol = strings.ToUpper(tokenName) - } spec["registration"] = map[string]any{ "enabled": register, "name": regName, "description": regDesc, "skills": skills, - "metadata": agentOfferRegistrationMetadata(agent, price, symbol, chain), + // Registration is per-chain — use the primary (first) payment + // option's network, the locked decision for multi-payment offers. + "metadata": agentOfferRegistrationMetadata(agent, primaryPrice, primarySymbol, primaryNetwork), } manifest := map[string]any{ @@ -254,7 +286,10 @@ Examples: if strings.Contains(out, "configured") || strings.Contains(out, "unchanged") { action = "updated" } - u.Successf("ServiceOffer %s/%s %s (type: agent, %s %s/req → %s)", offerNs, name, action, price, assetTerms.Symbol, payTo) + u.Successf("ServiceOffer %s/%s %s (type: agent, %s %s/req → %s)", offerNs, name, action, primaryPrice, primarySymbol, primaryPayTo) + if paymentsList != nil { + u.Infof("Accepted payments: %s", acceptSummary(paymentsList)) + } u.Infof("Reconciler will resolve agent.ref → derive upstream → publish payment gate + route") u.Infof("Check status: obol sell status %s -n %s", name, offerNs) @@ -267,7 +302,7 @@ Examples: } if !register { - u.Dim("Registration skipped (--no-register). Run `obol sell register --chain " + chain + "` later for on-chain discovery.") + u.Dim("Registration skipped (--no-register). Run `obol sell register --chain " + primaryNetwork + "` later for on-chain discovery.") } else { // sell agent is declare-only: it sets spec.registration and // relies on the controller + a manual `obol sell register`. Make @@ -281,8 +316,8 @@ Examples: } printRegistrationNotice(u, registrationNotice{ Mode: regNoticeDeclareOnly, - Chain: chain, - PayTo: payTo, + Chain: primaryNetwork, + PayTo: primaryPayTo, AgentWallet: aw, OfferName: name, Namespace: offerNs, @@ -426,6 +461,12 @@ func runAgentBackedDemo( "skills": skillsAny, "metadata": agentOfferRegistrationMetadata(agentForMetadata, price, symbol, chain), } + // Demo services are an ordinary storefront category. Agent-backed demos + // live in agent- (the controller's confused-deputy guard forces + // spec.agent.ref.namespace == offer.namespace), so the catalog can't + // infer "demo" from the namespace — listing.category is the explicit + // signal and groups them with the http demos on the storefront. + specMap["listing"] = map[string]any{"category": "demo"} soManifest := map[string]any{ "apiVersion": "obol.org/v1alpha1", @@ -433,17 +474,6 @@ func runAgentBackedDemo( "metadata": map[string]any{ "name": name, "namespace": offerNs, - // Agent-backed demos can't live in the legacy "demo" - // namespace today (the controller's confused-deputy guard at - // agent_resolver.go forces spec.agent.ref.namespace == - // offer.namespace), so the catalog renderer can't infer - // "demo" from offer.namespace alone. The obol.org/demo - // label is the explicit signal — keep it set here so quant - // and friends show up under "Demo services" on the - // storefront. Drop this once the catalog renderer's - // cross-namespace guard is relaxed to infer demo offers - // from their namespace. - "labels": map[string]any{"obol.org/demo": "true"}, }, "spec": specMap, } diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index d363a232..c9a14cd3 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -285,6 +285,8 @@ func TestSellHTTP_Flags(t *testing.T) { "namespace", "upstream", "port", "health-path", "path", "max-timeout", "register", "no-register", "register-name", "register-description", "register-image", + // multi-currency + storefront listing flags (Phase 3) + "accept", "weight", "category", ) assertStringDefault(t, flags, "chain", "base") @@ -295,6 +297,17 @@ func TestSellHTTP_Flags(t *testing.T) { assertIntDefault(t, flags, "max-timeout", 300) } +func TestSellAgent_AcceptFlags(t *testing.T) { + cfg := newTestConfig(t) + cmd := sellCommand(cfg) + agent := findSubcommand(t, cmd, "agent") + flags := flagMap(agent) + requireFlags(t, flags, + "wallet", "chain", "token", "price", "per-request", + "accept", "weight", "category", "description", + ) +} + func TestBuildSellRegistrationConfig_DefaultEnabled(t *testing.T) { reg, enabled, err := buildSellRegistrationConfig("demo", sellRegistrationInput{}) if err != nil { diff --git a/internal/embed/embed_crd_test.go b/internal/embed/embed_crd_test.go index 9964b65d..fcfa40e1 100644 --- a/internal/embed/embed_crd_test.go +++ b/internal/embed/embed_crd_test.go @@ -150,12 +150,26 @@ func TestServiceOfferCRD_Fields(t *testing.T) { // Required fields in spec (aligned with x402/ERC-8004 schema). agent // joins this list as part of the type=agent offer flow. - for _, field := range []string{"type", "agent", "model", "upstream", "payment", "path", "registration"} { + for _, field := range []string{"type", "agent", "model", "upstream", "payment", "payments", "listing", "path", "registration"} { if _, exists := pm[field]; !exists { t.Errorf("spec.properties missing field %q", field) } } + // payments[] items must carry the same required payment fields as the + // singular payment block (it advertises the x402 accepts[] array). + paymentsItems := nested(v0, "schema", "openAPIV3Schema", "properties", "spec", + "properties", "payments", "items", "properties") + if pim, ok := paymentsItems.(map[string]any); ok { + for _, field := range []string{"network", "payTo", "price", "asset", "maxTimeoutSeconds"} { + if _, exists := pim[field]; !exists { + t.Errorf("spec.payments.items.properties missing field %q", field) + } + } + } else { + t.Errorf("spec.payments.items.properties is not a map: %T", paymentsItems) + } + typeProp, _ := pm["type"].(map[string]any) enum, _ := typeProp["enum"].([]any) got := make(map[string]bool, len(enum)) diff --git a/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml b/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml index 7b67e13a..657d6e8b 100644 --- a/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml +++ b/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml @@ -97,6 +97,22 @@ spec: is honored as "tear down immediately on the next reconcile" (the equivalent of `obol sell stop --force`). type: string + listing: + description: |- + Listing controls how the offer is presented on the public storefront + (ordering weight and grouping category). Cosmetic only. + properties: + category: + description: |- + Category groups the offer into a named storefront section (e.g. + "demo"). Empty means the default/uncategorized section. + type: string + weight: + description: |- + Weight orders offers on the storefront: higher sorts earlier. + Equal weights fall back to alphabetical by name. Defaults to 0. + type: integer + type: object model: description: LLM model metadata. Required when the upstream serves an LLM. @@ -197,6 +213,91 @@ spec: - payTo - price type: object + payments: + description: |- + Payments lists every accepted payment option (one per currency/network). + When non-empty it is the source of truth for the x402 402 accepts[] array; + the buyer picks one option and the verifier settles whichever was used. + payments[0] is the primary option and MUST equal spec.payment. + items: + properties: + asset: + description: |- + Optional token metadata override for x402 settlement. When omitted, + the verifier uses the chain default asset. + properties: + address: + description: ERC-20 contract address. + pattern: ^0x[0-9a-fA-F]{40}$ + type: string + decimals: + description: Token decimals in atomic units. + format: int64 + maximum: 255 + minimum: 0 + type: integer + eip712Name: + description: EIP-712 domain name used by the token. + type: string + eip712Version: + description: EIP-712 domain version used by the token. + type: string + symbol: + description: Human-friendly token symbol (e.g. USDC, OBOL). + type: string + transferMethod: + description: x402 transfer method for the asset. + enum: + - eip3009 + - permit2 + type: string + type: object + maxTimeoutSeconds: + default: 300 + description: 'Payment validity window in seconds (x402: maxTimeoutSeconds).' + format: int64 + type: integer + network: + description: |- + Chain identifier for payments (human-friendly). Reconciler resolves + to CAIP-2 format (e.g., "base-sepolia" → "eip155:84532"). + type: string + payTo: + description: 'USDC recipient wallet address (x402: payTo).' + pattern: ^0x[0-9a-fA-F]{40}$ + type: string + price: + description: |- + Pricing table with per-unit prices in USDC (human-readable decimals). + Which fields are applicable depends on the workload type. + properties: + perEpoch: + description: Per-training-epoch price in USDC. Fine-tuning + only. + type: string + perHour: + description: Per-compute-hour price in USDC. Fine-tuning only. + type: string + perMTok: + description: Per-million-tokens price in USDC. Inference only. + type: string + perRequest: + description: Flat per-request price in USDC. Applicable to + all types. + type: string + type: object + scheme: + default: exact + description: x402 payment scheme. + enum: + - exact + type: string + required: + - network + - payTo + - price + type: object + type: array provenance: additionalProperties: type: string diff --git a/internal/monetizeapi/types.go b/internal/monetizeapi/types.go index 2efb439b..e6161093 100644 --- a/internal/monetizeapi/types.go +++ b/internal/monetizeapi/types.go @@ -117,9 +117,27 @@ type ServiceOfferSpec struct { // In-cluster service that handles the actual workload. Upstream ServiceOfferUpstream `json:"upstream,omitempty"` + // Primary accepted payment. Always set, including for multi-payment + // offers, where it MUST mirror payments[0] — discovery surfaces that + // predate multi-payment (the catalog, skill.md, kubectl printcolumns) + // read this singular block. The x402 verifier reads the full set via + // EffectivePayments. // +kubebuilder:validation:Required Payment ServiceOfferPayment `json:"payment"` + // Payments lists every accepted payment option for this offer (one per + // currency/network). When non-empty it is the source of truth for the + // x402 verifier's 402 accepts[] array: the buyer picks one option and + // the verifier settles whichever was used. payments[0] is the primary + // option and MUST equal spec.payment. Empty means a single-payment + // offer described by spec.payment alone. See EffectivePayments. + Payments []ServiceOfferPayment `json:"payments,omitempty"` + + // Listing controls how the offer is presented on the public storefront + // (ordering weight and grouping category). Cosmetic only — it does not + // affect routing, pricing, or payment. + Listing ServiceOfferListing `json:"listing,omitempty"` + // URL path prefix for the HTTPRoute, defaults to /services/. // +kubebuilder:validation:Pattern=`^/[a-zA-Z0-9/_.-]*$` Path string `json:"path,omitempty"` @@ -217,6 +235,17 @@ type ServiceOfferPayment struct { Price ServiceOfferPriceTable `json:"price"` } +// ServiceOfferListing carries storefront presentation hints. Both fields +// are optional and purely cosmetic. +type ServiceOfferListing struct { + // Weight orders offers on the storefront: higher sorts earlier. Offers + // with equal weight fall back to alphabetical by name. Defaults to 0. + Weight int `json:"weight,omitempty"` + // Category groups the offer into a named storefront section (e.g. + // "demo"). Empty means the default/uncategorized section. + Category string `json:"category,omitempty"` +} + type ServiceOfferAsset struct { // ERC-20 contract address. // +kubebuilder:validation:Pattern=`^0x[0-9a-fA-F]{40}$` @@ -412,6 +441,18 @@ func (o *ServiceOffer) EffectivePath() string { return fmt.Sprintf("/services/%s", o.Name) } +// EffectivePayments returns every accepted payment option for the offer. +// When spec.payments is populated it is returned verbatim (multi-payment +// offers); otherwise a single-element slice is synthesized from spec.payment +// so single-payment offers and pre-multi-payment CRs keep working unchanged. +// payments[0] (or spec.payment) is always the primary option. +func (o *ServiceOffer) EffectivePayments() []ServiceOfferPayment { + if len(o.Spec.Payments) > 0 { + return o.Spec.Payments + } + return []ServiceOfferPayment{o.Spec.Payment} +} + func (o *ServiceOffer) IsInference() bool { return o.Spec.Type == "" || o.Spec.Type == "inference" } diff --git a/internal/monetizeapi/types_test.go b/internal/monetizeapi/types_test.go index 77a15a90..c6927456 100644 --- a/internal/monetizeapi/types_test.go +++ b/internal/monetizeapi/types_test.go @@ -5,6 +5,33 @@ import ( "testing" ) +// TestEffectivePayments covers the single→multi-payment fallback: an offer +// with only spec.payment yields a one-element slice synthesized from it, +// while spec.payments (when set) is returned verbatim and wins over the +// singular block. Downstream (the x402 verifier) relies on this so legacy +// single-payment CRs and new multi-payment offers share one code path. +func TestEffectivePayments(t *testing.T) { + single := &ServiceOffer{Spec: ServiceOfferSpec{ + Payment: ServiceOfferPayment{Network: "base", PayTo: "0xaaa", Price: ServiceOfferPriceTable{PerRequest: "0.001"}}, + }} + got := single.EffectivePayments() + if len(got) != 1 || got[0].Network != "base" || got[0].PayTo != "0xaaa" { + t.Fatalf("single-payment fallback = %+v, want one element mirroring spec.payment", got) + } + + multi := &ServiceOffer{Spec: ServiceOfferSpec{ + Payment: ServiceOfferPayment{Network: "base", PayTo: "0xaaa", Price: ServiceOfferPriceTable{PerRequest: "1"}}, + Payments: []ServiceOfferPayment{ + {Network: "base", PayTo: "0xaaa", Price: ServiceOfferPriceTable{PerRequest: "1"}}, + {Network: "ethereum", PayTo: "0xbbb", Price: ServiceOfferPriceTable{PerRequest: "10"}}, + }, + }} + got = multi.EffectivePayments() + if len(got) != 2 || got[1].Network != "ethereum" || got[1].PayTo != "0xbbb" { + t.Fatalf("multi-payment = %+v, want the verbatim 2-element payments slice", got) + } +} + // TestPurchaseAutoRefill_JSONRoundTrip asserts every field on // PurchaseAutoRefill marshals to JSON and unmarshals back without loss. The // MaxTotal + MaxSpendPerDay fields were added to match the CRD spec; this test diff --git a/internal/monetizeapi/zz_generated.deepcopy.go b/internal/monetizeapi/zz_generated.deepcopy.go index 3c0207f3..04d524cb 100644 --- a/internal/monetizeapi/zz_generated.deepcopy.go +++ b/internal/monetizeapi/zz_generated.deepcopy.go @@ -713,6 +713,14 @@ func (in *ServiceOfferSpec) DeepCopyInto(out *ServiceOfferSpec) { out.Model = in.Model out.Upstream = in.Upstream out.Payment = in.Payment + if in.Payments != nil { + in, out := &in.Payments, &out.Payments + *out = make([]ServiceOfferPayment, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + out.Listing = in.Listing if in.Provenance != nil { in, out := &in.Provenance, &out.Provenance *out = make(map[string]string, len(*in)) diff --git a/internal/schemas/service-catalog.schema.json b/internal/schemas/service-catalog.schema.json index 5a9808d2..9b6ab6c9 100644 --- a/internal/schemas/service-catalog.schema.json +++ b/internal/schemas/service-catalog.schema.json @@ -77,8 +77,7 @@ "price", "payTo", "network", - "description", - "isDemo" + "description" ], "properties": { "name": { @@ -155,8 +154,13 @@ }, "description": "OASF skills or buy-x402 skills the agent advertises. For type=agent offers this mirrors the resolved Agent.spec.skills allow-list; for non-agent offers it mirrors spec.registration.skills." }, - "isDemo": { - "type": "boolean" + "category": { + "type": "string", + "description": "Storefront grouping section (e.g. \"demo\"). Empty/absent means the default section. Mirrors spec.listing.category." + }, + "weight": { + "type": "integer", + "description": "Orders offers within a category: higher sorts earlier. Mirrors spec.listing.weight." }, "registrationPending": { "type": "boolean" diff --git a/internal/schemas/service_catalog.go b/internal/schemas/service_catalog.go index 6423b31d..efb755a4 100644 --- a/internal/schemas/service_catalog.go +++ b/internal/schemas/service_catalog.go @@ -36,7 +36,16 @@ type ServiceCatalogEntry struct { // offers it mirrors spec.registration.skills. Surfaced as pills on // the storefront ServiceCard. Skills []string `json:"skills,omitempty"` - IsDemo bool `json:"isDemo"` + + // Category groups the offer into a named storefront section (e.g. + // "demo"). Empty means the default/uncategorized section. Demo services + // are just category="demo" — no special-casing. Mirrors + // ServiceOffer.spec.listing.category. + Category string `json:"category,omitempty"` + + // Weight orders offers within a category on the storefront: higher sorts + // earlier. Mirrors ServiceOffer.spec.listing.weight. + Weight int `json:"weight,omitempty"` // RegistrationPending is true when the offer is operationally ready // (route published, payment gate active, upstream healthy) but its diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index 3e2506c6..a68e2110 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -1035,22 +1035,23 @@ func offerOperationallyReady(offer *monetizeapi.ServiceOffer) bool { // ready but has its on-chain ERC-8004 registration still pending. Used to // flip ServiceCatalogEntry.RegistrationPending so storefront UIs can show // a "registration pending" badge alongside the usable offer. -// isDemoOffer reports whether an offer should be rendered under the -// storefront's "Demo services" group. The legacy demo path puts offers -// directly in the "demo" namespace, but the agent-backed demo path -// (`obol sell demo quant`) lands the offer in agent- because the -// controller's confused-deputy guard requires the ServiceOffer and the -// referenced Agent CR to share a namespace. To keep both paths grouping -// together on the storefront, the CLI sets obol.org/demo=true on -// agent-backed demos and we honour either signal. -func isDemoOffer(offer *monetizeapi.ServiceOffer) bool { +// offerCategory returns the storefront grouping category for an offer. +// spec.listing.category is the source of truth; demo services are just +// category="demo" like any other section. For backward compatibility with +// offers created before listing.category existed, legacy demo signals +// (namespace "demo", or the obol.org/demo=true label set on agent-backed +// demos whose offer must live in agent-) still map to "demo". +func offerCategory(offer *monetizeapi.ServiceOffer) string { if offer == nil { - return false + return "" } - if offer.Namespace == "demo" { - return true + if c := strings.TrimSpace(offer.Spec.Listing.Category); c != "" { + return c } - return offer.Labels["obol.org/demo"] == "true" + if offer.Namespace == "demo" || offer.Labels["obol.org/demo"] == "true" { + return "demo" + } + return "" } func offerAwaitingRegistration(offer *monetizeapi.ServiceOffer) bool { @@ -1096,7 +1097,13 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) ready = append(ready, offer) } } + // Higher listing weight sorts earlier; equal weights fall back to name. + // Category grouping is applied client-side on the storefront. sort.Slice(ready, func(i, j int) bool { + wi, wj := ready[i].Spec.Listing.Weight, ready[j].Spec.Listing.Weight + if wi != wj { + return wi > wj + } return ready[i].Name < ready[j].Name }) @@ -1141,7 +1148,8 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) Network: offer.Spec.Payment.Network, Description: desc, Skills: skills, - IsDemo: isDemoOffer(offer), + Category: offerCategory(offer), + Weight: offer.Spec.Listing.Weight, RegistrationPending: offerAwaitingRegistration(offer), DrainEndsAt: drainEndsAt, } diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index a68d7061..4428f066 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -775,8 +775,8 @@ func TestBuildServiceCatalogJSON(t *testing.T) { if svc.Price != "0.00001 USDC/request" { t.Errorf("price = %q, want '0.00001 USDC/request'", svc.Price) } - if !svc.IsDemo { - t.Error("expected isDemo=true for namespace=demo") + if svc.Category != "demo" { + t.Errorf("category = %q, want demo (back-compat: namespace=demo)", svc.Category) } if svc.Endpoint != "https://example.com/services/demo-hello" { t.Errorf("endpoint = %q, want https://example.com/services/demo-hello", svc.Endpoint) diff --git a/internal/x402/chains.go b/internal/x402/chains.go index c6d508da..6e652d9d 100644 --- a/internal/x402/chains.go +++ b/internal/x402/chains.go @@ -242,27 +242,43 @@ func (c ChainInfo) DefaultAsset() AssetInfo { // ResolveAssetInfo applies any route-level asset overrides on top of the // chain's default settlement asset. func ResolveAssetInfo(chain ChainInfo, rule *RouteRule) AssetInfo { - asset := chain.DefaultAsset() if rule == nil { - return asset + return chain.DefaultAsset() } - if rule.AssetAddress != "" { - asset.Address = rule.AssetAddress + // The inline asset fields describe the primary payment option. + return ResolveAssetInfoForPayment(chain, RoutePayment{ + AssetAddress: rule.AssetAddress, + AssetSymbol: rule.AssetSymbol, + AssetDecimals: rule.AssetDecimals, + AssetTransferMethod: rule.AssetTransferMethod, + EIP712Name: rule.EIP712Name, + EIP712Version: rule.EIP712Version, + }) +} + +// ResolveAssetInfoForPayment applies a single payment option's asset +// overrides on top of the chain default. Same precedence and registry-flag +// re-derivation as ResolveAssetInfo, but scoped to one accepted payment so a +// multi-payment route resolves each option independently. +func ResolveAssetInfoForPayment(chain ChainInfo, p RoutePayment) AssetInfo { + asset := chain.DefaultAsset() + if p.AssetAddress != "" { + asset.Address = p.AssetAddress } - if rule.AssetSymbol != "" { - asset.Symbol = rule.AssetSymbol + if p.AssetSymbol != "" { + asset.Symbol = p.AssetSymbol } - if rule.AssetDecimals > 0 { - asset.Decimals = rule.AssetDecimals + if p.AssetDecimals > 0 { + asset.Decimals = p.AssetDecimals } - if rule.AssetTransferMethod != "" { - asset.TransferMethod = rule.AssetTransferMethod + if p.AssetTransferMethod != "" { + asset.TransferMethod = p.AssetTransferMethod } - if rule.EIP712Name != "" { - asset.EIP712Name = rule.EIP712Name + if p.EIP712Name != "" { + asset.EIP712Name = p.EIP712Name } - if rule.EIP712Version != "" { - asset.EIP712Version = rule.EIP712Version + if p.EIP712Version != "" { + asset.EIP712Version = p.EIP712Version } // Re-derive registry-driven flags (gasless approve, etc.) from the token diff --git a/internal/x402/config.go b/internal/x402/config.go index a878ac48..78828740 100644 --- a/internal/x402/config.go +++ b/internal/x402/config.go @@ -29,9 +29,37 @@ type PricingConfig struct { Routes []RouteRule `yaml:"routes"` } +// RoutePayment is one accepted payment option for a route — a single +// (price, payTo, network, asset) tuple. A route advertising N options emits +// N entries in the 402 accepts[] array; the buyer picks one and the verifier +// settles whichever was used. The fields mirror the per-payment subset of +// RouteRule so a single-payment route synthesizes one of these from its +// inline fields (see RouteRule.PaymentOptions). +type RoutePayment struct { + Price string `yaml:"price"` + PayTo string `yaml:"payTo,omitempty"` + Network string `yaml:"network,omitempty"` + AssetAddress string `yaml:"assetAddress,omitempty"` + AssetSymbol string `yaml:"assetSymbol,omitempty"` + AssetDecimals int `yaml:"assetDecimals,omitempty"` + AssetTransferMethod string `yaml:"assetTransferMethod,omitempty"` + EIP712Name string `yaml:"eip712Name,omitempty"` + EIP712Version string `yaml:"eip712Version,omitempty"` + PriceModel string `yaml:"priceModel,omitempty"` + PerMTok string `yaml:"perMTok,omitempty"` + ApproxTokensPerRequest int `yaml:"approxTokensPerRequest,omitempty"` + MaxTimeoutSeconds int64 `yaml:"maxTimeoutSeconds,omitempty"` +} + // RouteRule maps a URL pattern to x402 payment requirements. // Per-route fields (PayTo, Network) override the global PricingConfig values // when set, enabling multiple ServiceOffers with different wallets/chains. +// +// The inline payment fields (Price, PayTo, Network, Asset*, …) describe the +// PRIMARY payment option and are always populated. Payments, when non-empty, +// holds every accepted option (incl. the primary as Payments[0]) so a single +// offer can be paid in multiple currencies/networks. Use PaymentOptions to +// read the effective list regardless of how the rule was built. type RouteRule struct { // Pattern is a path matching pattern. Supports: // - Exact match: "/health" @@ -136,6 +164,38 @@ type RouteRule struct { // minutes-to-hours here — operator-set values up to // MaxMaxTimeoutSeconds are honored verbatim. MaxTimeoutSeconds int64 `yaml:"maxTimeoutSeconds,omitempty"` + + // Payments is the full set of accepted payment options for this route. + // When non-empty it is the source of truth for the 402 accepts[] array; + // Payments[0] mirrors the inline fields above (the primary option). + // Empty means a single-payment route described by the inline fields. + Payments []RoutePayment `yaml:"payments,omitempty"` +} + +// PaymentOptions returns the route's accepted payment options. When the +// multi-payment Payments slice is populated it is returned verbatim; +// otherwise a single option is synthesized from the inline (primary) fields +// for backward compatibility with single-payment routes and static +// pricing.yaml configs. The result always has at least one element. +func (r *RouteRule) PaymentOptions() []RoutePayment { + if len(r.Payments) > 0 { + return r.Payments + } + return []RoutePayment{{ + Price: r.Price, + PayTo: r.PayTo, + Network: r.Network, + AssetAddress: r.AssetAddress, + AssetSymbol: r.AssetSymbol, + AssetDecimals: r.AssetDecimals, + AssetTransferMethod: r.AssetTransferMethod, + EIP712Name: r.EIP712Name, + EIP712Version: r.EIP712Version, + PriceModel: r.PriceModel, + PerMTok: r.PerMTok, + ApproxTokensPerRequest: r.ApproxTokensPerRequest, + MaxTimeoutSeconds: r.MaxTimeoutSeconds, + }} } // LoadConfig reads and parses a pricing configuration YAML file. diff --git a/internal/x402/forwardauth.go b/internal/x402/forwardauth.go index c465326f..4d504e32 100644 --- a/internal/x402/forwardauth.go +++ b/internal/x402/forwardauth.go @@ -48,6 +48,13 @@ type ForwardAuthConfig struct { // Nil keeps today's behaviour: every 402 is JSON. SendPaymentRequired SendPaymentRequiredFunc + // OnPaymentMatched, if non-nil, is invoked with the requirement the + // buyer's X-PAYMENT satisfied, as soon as it matches (before verify). + // Lets the caller attribute metrics to the specific payment option used + // in a multi-accept offer (OBOL vs USDC, mainnet vs Base, …). No-op when + // the offer advertises a single option. + OnPaymentMatched func(x402types.PaymentRequirements) + // SettlesInProcess marks the in-process seller-gateway path (HandleProxy / // obol sell inference) where VerifyOnly=false is correct BY DESIGN — the // middleware proxies to the real upstream and settles only after a <400 @@ -148,6 +155,9 @@ func NewForwardAuthMiddleware(cfg ForwardAuthConfig, requirements []x402types.Pa send(w, r, requirements, cfg.Extensions) return } + if cfg.OnPaymentMatched != nil { + cfg.OnPaymentMatched(matchedReq) + } // Verify with facilitator. verifyResp, err := facilitatorVerify(r.Context(), verifyClient, cfg.FacilitatorURL, payloadBytes, matchedReq) diff --git a/internal/x402/serviceoffer_source.go b/internal/x402/serviceoffer_source.go index 2983f5ba..3ea3f243 100644 --- a/internal/x402/serviceoffer_source.go +++ b/internal/x402/serviceoffer_source.go @@ -136,10 +136,22 @@ func routesFromStore(offerItems, secretItems []any) ([]RouteRule, error) { } func routeRuleFromOffer(offer *monetizeapi.ServiceOffer, upstreamAuth string) (RouteRule, error) { - price, priceModel, perMTok, approx, err := effectivePrice(offer) - if err != nil { - return RouteRule{}, err + // Build one RoutePayment per accepted payment option. EffectivePayments + // returns the multi-payment list when present, else a one-element slice + // from spec.payment — so single- and multi-payment offers share this path. + payments := offer.EffectivePayments() + routePayments := make([]RoutePayment, 0, len(payments)) + for i := range payments { + rp, err := routePaymentFromSpec(payments[i]) + if err != nil { + return RouteRule{}, err + } + routePayments = append(routePayments, rp) } + // The primary option (payments[0]) populates the inline fields so the + // HTML 402 page, metrics defaults, and any direct rule.PayTo/Network + // readers keep working unchanged for single-payment offers. + primary := routePayments[0] // Agent-type offers derive their upstream URL from the controller's // resolved view (ServiceOffer.status.agentResolution), which the @@ -159,26 +171,27 @@ func routeRuleFromOffer(offer *monetizeapi.ServiceOffer, upstreamAuth string) (R rule := RouteRule{ Pattern: strings.TrimSuffix(offer.EffectivePath(), "/") + "/*", - Price: price, + Price: primary.Price, Description: offer.Spec.Registration.Description, OfferType: offer.Spec.Type, - PayTo: offer.Spec.Payment.PayTo, - Network: NormalizeNetworkID(offer.Spec.Payment.Network), - AssetAddress: offer.Spec.Payment.Asset.Address, - AssetSymbol: offer.Spec.Payment.Asset.Symbol, - AssetDecimals: int(offer.Spec.Payment.Asset.Decimals), - AssetTransferMethod: offer.Spec.Payment.Asset.TransferMethod, - EIP712Name: offer.Spec.Payment.Asset.EIP712Name, - EIP712Version: offer.Spec.Payment.Asset.EIP712Version, + PayTo: primary.PayTo, + Network: primary.Network, + AssetAddress: primary.AssetAddress, + AssetSymbol: primary.AssetSymbol, + AssetDecimals: primary.AssetDecimals, + AssetTransferMethod: primary.AssetTransferMethod, + EIP712Name: primary.EIP712Name, + EIP712Version: primary.EIP712Version, UpstreamAuth: effectiveUpstreamAuth(offer, upstreamAuth), UpstreamURL: upstreamURL, StripPrefix: stripPrefix, - PriceModel: priceModel, - PerMTok: perMTok, - ApproxTokensPerRequest: approx, + PriceModel: primary.PriceModel, + PerMTok: primary.PerMTok, + ApproxTokensPerRequest: primary.ApproxTokensPerRequest, OfferNamespace: offer.Namespace, OfferName: offer.Name, - MaxTimeoutSeconds: offer.Spec.Payment.MaxTimeoutSeconds, + MaxTimeoutSeconds: primary.MaxTimeoutSeconds, + Payments: routePayments, } if offer.IsAgent() && offer.Status.AgentResolution != nil { @@ -194,18 +207,45 @@ func routeRuleFromOffer(offer *monetizeapi.ServiceOffer, upstreamAuth string) (R return rule, nil } -func effectivePrice(offer *monetizeapi.ServiceOffer) (price, priceModel, perMTok string, approx int, err error) { +// routePaymentFromSpec converts a single ServiceOffer payment option into a +// RoutePayment, resolving the enforced request price from whichever price +// slot is set (and approximating perMTok into a per-request charge for the +// phase-1 request-based gate). Network is normalized to CAIP-2 so the +// verifier's chain lookup resolves it. +func routePaymentFromSpec(p monetizeapi.ServiceOfferPayment) (RoutePayment, error) { + price, priceModel, perMTok, approx, err := effectivePriceForOption(p) + if err != nil { + return RoutePayment{}, err + } + return RoutePayment{ + Price: price, + PayTo: p.PayTo, + Network: NormalizeNetworkID(p.Network), + AssetAddress: p.Asset.Address, + AssetSymbol: p.Asset.Symbol, + AssetDecimals: int(p.Asset.Decimals), + AssetTransferMethod: p.Asset.TransferMethod, + EIP712Name: p.Asset.EIP712Name, + EIP712Version: p.Asset.EIP712Version, + PriceModel: priceModel, + PerMTok: perMTok, + ApproxTokensPerRequest: approx, + MaxTimeoutSeconds: p.MaxTimeoutSeconds, + }, nil +} + +func effectivePriceForOption(p monetizeapi.ServiceOfferPayment) (price, priceModel, perMTok string, approx int, err error) { switch { - case offer.Spec.Payment.Price.PerRequest != "": - return offer.Spec.Payment.Price.PerRequest, "perRequest", "", 0, nil - case offer.Spec.Payment.Price.PerMTok != "": - price, err := schemas.ApproximateRequestPriceFromPerMTok(offer.Spec.Payment.Price.PerMTok) + case p.Price.PerRequest != "": + return p.Price.PerRequest, "perRequest", "", 0, nil + case p.Price.PerMTok != "": + price, err := schemas.ApproximateRequestPriceFromPerMTok(p.Price.PerMTok) if err != nil { - return "", "", "", 0, fmt.Errorf("invalid perMTok price %q: %w", offer.Spec.Payment.Price.PerMTok, err) + return "", "", "", 0, fmt.Errorf("invalid perMTok price %q: %w", p.Price.PerMTok, err) } - return price, "perMTok", offer.Spec.Payment.Price.PerMTok, schemas.ApproxTokensPerRequest, nil - case offer.Spec.Payment.Price.PerHour != "": - return offer.Spec.Payment.Price.PerHour, "perHour", "", 0, nil + return price, "perMTok", p.Price.PerMTok, schemas.ApproxTokensPerRequest, nil + case p.Price.PerHour != "": + return p.Price.PerHour, "perHour", "", 0, nil default: return "0", "", "", 0, nil } diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index 29f4451d..264522af 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -68,19 +68,22 @@ func (v *Verifier) load(cfg *PricingConfig) error { return fmt.Errorf("resolve chain: %w", err) } - // Pre-resolve all unique chain names (global + per-route overrides) - // so HandleVerify avoids per-request chain resolution. + // Pre-resolve all unique chain names (global + every per-route payment + // option's network) so HandleVerify avoids per-request chain resolution. chains := map[string]ChainInfo{cfg.Chain: chain} - for _, r := range cfg.Routes { - if r.Network != "" { - if _, ok := chains[r.Network]; !ok { - rc, err := ResolveChainInfo(r.Network) - if err != nil { - return fmt.Errorf("resolve chain for route %q: %w", r.Pattern, err) - } - - chains[r.Network] = rc + for i := range cfg.Routes { + for _, opt := range cfg.Routes[i].PaymentOptions() { + if opt.Network == "" { + continue + } + if _, ok := chains[opt.Network]; ok { + continue } + rc, err := ResolveChainInfo(opt.Network) + if err != nil { + return fmt.Errorf("resolve chain for route %q: %w", cfg.Routes[i].Pattern, err) + } + chains[opt.Network] = rc } } @@ -107,11 +110,20 @@ func (v *Verifier) load(cfg *PricingConfig) error { // keeping alerts (e.g. "no settlements after challenge") tied to dead // labels. live := make(map[string]struct{}, len(cfg.Routes)) - for _, r := range cfg.Routes { + for i := range cfg.Routes { + r := &cfg.Routes[i] if r.OfferNamespace == "" && r.OfferName == "" { continue } - live[r.OfferNamespace+"\x00"+r.OfferName+"\x00"+r.Network+"\x00"+r.AssetSymbol] = struct{}{} + // A multi-payment offer emits one metric series per (chain, asset) + // it accepts, so register every option's labels — otherwise the + // prune step below would drop the non-primary series. Build the key + // from labelsForPaymentOption so it byte-matches the emitted labels + // (incl. the "unknown" asset fallback). + for _, opt := range r.PaymentOptions() { + l := labelsForPaymentOption(r, opt) + live[l["offer_namespace"]+"\x00"+l["offer_name"]+"\x00"+l["chain"]+"\x00"+l["asset_symbol"]] = struct{}{} + } } v.metrics.pruneSeriesNotIn(live) @@ -139,7 +151,7 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) { cfg := v.config.Load() - rule, requirement, extensions, _, chain, asset, ok := v.matchPaidRouteFull(cfg, uri) + mr, ok := v.matchPaidRouteFull(cfg, uri) if !ok { // Check if this URI is under a tracked paid prefix. If yes, // the route was supposed to match but didn't — fail closed @@ -173,23 +185,27 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) { // When the inner handler runs (payment approved), it sets the Authorization // header if the route has upstreamAuth configured. Traefik's authResponseHeaders // copies this to the forwarded request, authenticating it with the upstream. - labels := prometheusLabels(rule) - v.metrics.requestsTotal.With(labels).Inc() - - wallet := cfg.Wallet - if rule.PayTo != "" { - wallet = rule.PayTo - } - display := buildPaymentDisplay(rule, chain, asset, wallet, requirement.Amount) - + primaryLabels := mr.labels + v.metrics.requestsTotal.With(primaryLabels).Inc() + + // The HTML 402 page shows the primary payment option; the JSON accepts[] + // (mr.requirements) carries every option for programmatic buyers. Rich + // multi-option rendering on the human page is deferred to the storefront. + primary := mr.requirements[0] + display := buildPaymentDisplay(mr.rule, mr.chain, mr.asset, primary.PayTo, primary.Amount) + + // matchedLabels is updated to the option the buyer actually pays with, so + // revenue/failure metrics attribute to the right (chain, asset). + matchedLabels := primaryLabels middleware := NewForwardAuthMiddleware(ForwardAuthConfig{ FacilitatorURL: cfg.FacilitatorURL, VerifyOnly: cfg.VerifyOnly, - Extensions: extensions, + Extensions: mr.extensions, SendPaymentRequired: NewHTMLAwarePaymentRequired(display), - }, []x402types.PaymentRequirements{requirement}) + OnPaymentMatched: func(req x402types.PaymentRequirements) { matchedLabels = mr.labelsForMatched(req) }, + }, mr.requirements) - upstreamAuth := rule.UpstreamAuth + upstreamAuth := mr.rule.UpstreamAuth inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if upstreamAuth != "" { w.Header().Set("Authorization", upstreamAuth) @@ -203,13 +219,13 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) { switch { case tracker.status == http.StatusOK && r.Header.Get("X-Payment") != "": - v.metrics.paymentVerified.With(labels).Inc() - v.metrics.chargedRequests.With(labels).Inc() - v.metrics.lastPaymentSuccess.With(labels).SetToCurrentTime() + v.metrics.paymentVerified.With(matchedLabels).Inc() + v.metrics.chargedRequests.With(matchedLabels).Inc() + v.metrics.lastPaymentSuccess.With(matchedLabels).SetToCurrentTime() case tracker.status == http.StatusPaymentRequired && r.Header.Get("X-Payment") != "": - v.metrics.paymentFailed.With(labels).Inc() + v.metrics.paymentFailed.With(matchedLabels).Inc() case tracker.status == http.StatusPaymentRequired: - v.metrics.paymentRequired.With(labels).Inc() + v.metrics.paymentRequired.With(primaryLabels).Inc() } } @@ -219,27 +235,26 @@ func (v *Verifier) HandleVerify(w http.ResponseWriter, r *http.Request) { func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { cfg := v.config.Load() - rule, requirement, extensions, labels, chain, asset, ok := v.matchPaidRouteFull(cfg, r.URL.Path) + mr, ok := v.matchPaidRouteFull(cfg, r.URL.Path) if !ok { http.NotFound(w, r) return } - v.metrics.requestsTotal.With(labels).Inc() + primaryLabels := mr.labels + v.metrics.requestsTotal.With(primaryLabels).Inc() - proxy, err := buildUpstreamProxy(rule) + proxy, err := buildUpstreamProxy(mr.rule) if err != nil { - log.Printf("x402-verifier: build upstream proxy for %s/%s: %v", rule.OfferNamespace, rule.OfferName, err) + log.Printf("x402-verifier: build upstream proxy for %s/%s: %v", mr.rule.OfferNamespace, mr.rule.OfferName, err) http.Error(w, "upstream unavailable", http.StatusInternalServerError) return } - wallet := cfg.Wallet - if rule.PayTo != "" { - wallet = rule.PayTo - } - display := buildPaymentDisplay(rule, chain, asset, wallet, requirement.Amount) + primary := mr.requirements[0] + display := buildPaymentDisplay(mr.rule, mr.chain, mr.asset, primary.PayTo, primary.Amount) + matchedLabels := primaryLabels middleware := NewForwardAuthMiddleware(ForwardAuthConfig{ FacilitatorURL: cfg.FacilitatorURL, // HandleProxy is the in-process seller gateway: it proxies to the real @@ -248,9 +263,10 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { // per-request) verifyOnly=false warning on this safe path. VerifyOnly: false, SettlesInProcess: true, - Extensions: extensions, + Extensions: mr.extensions, SendPaymentRequired: NewHTMLAwarePaymentRequired(display), - }, []x402types.PaymentRequirements{requirement}) + OnPaymentMatched: func(req x402types.PaymentRequirements) { matchedLabels = mr.labelsForMatched(req) }, + }, mr.requirements) hadPayment := r.Header.Get("X-PAYMENT") != "" tracker := &statusRecorder{ResponseWriter: w, status: http.StatusOK} @@ -258,14 +274,14 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { switch { case tracker.status == http.StatusPaymentRequired && !hadPayment: - v.metrics.paymentRequired.With(labels).Inc() + v.metrics.paymentRequired.With(primaryLabels).Inc() case tracker.status == http.StatusPaymentRequired: - v.metrics.paymentFailed.With(labels).Inc() + v.metrics.paymentFailed.With(matchedLabels).Inc() case tracker.status < http.StatusBadRequest && hadPayment: - v.metrics.paymentVerified.With(labels).Inc() + v.metrics.paymentVerified.With(matchedLabels).Inc() if tracker.Header().Get("X-PAYMENT-RESPONSE") != "" { - v.metrics.chargedRequests.With(labels).Inc() - v.metrics.lastPaymentSuccess.With(labels).SetToCurrentTime() + v.metrics.chargedRequests.With(matchedLabels).Inc() + v.metrics.lastPaymentSuccess.With(matchedLabels).SetToCurrentTime() } } } @@ -300,41 +316,100 @@ func (v *Verifier) MetricsHandler() http.Handler { return v.metrics.handler() } -func (v *Verifier) matchPaidRoute(cfg *PricingConfig, uri string) (*RouteRule, x402types.PaymentRequirements, map[string]any, prometheus.Labels, bool) { - rule, req, ext, labels, _, _, ok := v.matchPaidRouteFull(cfg, uri) - return rule, req, ext, labels, ok +// matchedRoute is the resolved view of a paid route for one request: the +// rule, the full x402 accepts[] (one PaymentRequirements per accepted payment +// option, requirements[0] = primary), the top-level extensions, and the +// primary option's chain/asset/labels for the HTML 402 display + default +// metrics. optionLabels is parallel to requirements for per-option metric +// attribution once the buyer picks one. +type matchedRoute struct { + rule *RouteRule + requirements []x402types.PaymentRequirements + optionLabels []prometheus.Labels + extensions map[string]any + chain ChainInfo + asset AssetInfo + labels prometheus.Labels } -// matchPaidRouteFull is matchPaidRoute plus the resolved chain and asset, -// which the HTML 402 renderer needs for display copy. Internal-only. -func (v *Verifier) matchPaidRouteFull(cfg *PricingConfig, uri string) (*RouteRule, x402types.PaymentRequirements, map[string]any, prometheus.Labels, ChainInfo, AssetInfo, bool) { - rule := matchRoute(cfg.Routes, uri) - if rule == nil { - return nil, x402types.PaymentRequirements{}, nil, nil, ChainInfo{}, AssetInfo{}, false - } - - wallet := cfg.Wallet - if rule.PayTo != "" { - wallet = rule.PayTo +// labelsForMatched returns the metric labels for the payment option the buyer +// actually satisfied, matching by the same fields findMatchingRequirementV1 +// uses. Falls back to the primary option's labels if no match is found. +func (m *matchedRoute) labelsForMatched(req x402types.PaymentRequirements) prometheus.Labels { + for i := range m.requirements { + r := m.requirements[i] + if r.Network == req.Network && r.Asset == req.Asset && r.PayTo == req.PayTo && r.Amount == req.Amount { + return m.optionLabels[i] + } } + return m.labels +} - chainName := cfg.Chain - if rule.Network != "" { - chainName = rule.Network +// matchPaidRouteFull matches a URI to a paid route and resolves the full set +// of accepted payment options into x402 PaymentRequirements. Returns nil,false +// when no rule matches or none of its options resolve to a known chain. +func (v *Verifier) matchPaidRouteFull(cfg *PricingConfig, uri string) (*matchedRoute, bool) { + rule := matchRoute(cfg.Routes, uri) + if rule == nil { + return nil, false } chains := v.chains.Load() - chain, ok := (*chains)[chainName] - if !ok { - log.Printf("x402-verifier: chain %q not pre-resolved for route %q", chainName, rule.Pattern) - return nil, x402types.PaymentRequirements{}, nil, nil, ChainInfo{}, AssetInfo{}, false + opts := rule.PaymentOptions() + reqs := make([]x402types.PaymentRequirements, 0, len(opts)) + optLabels := make([]prometheus.Labels, 0, len(opts)) + var primaryChain ChainInfo + var primaryAsset AssetInfo + var extensions map[string]any + for i := range opts { + opt := opts[i] + chainName := cfg.Chain + if opt.Network != "" { + chainName = opt.Network + } + chain, ok := (*chains)[chainName] + if !ok { + // Skip this option rather than failing the whole route — other + // options may still be payable. (load() pre-resolves every + // option's network, so this is defensive.) + log.Printf("x402-verifier: chain %q not pre-resolved for route %q option %d", chainName, rule.Pattern, i) + continue + } + wallet := cfg.Wallet + if opt.PayTo != "" { + wallet = opt.PayTo + } + asset := ResolveAssetInfoForPayment(chain, opt) + req := BuildV2RequirementWithAsset(chain, asset, opt.Price, wallet, opt.MaxTimeoutSeconds) + mergeAgentExtras(&req, rule) + reqs = append(reqs, req) + optLabels = append(optLabels, labelsForPaymentOption(rule, opt)) + if len(reqs) == 1 { + primaryChain = chain + primaryAsset = asset + } + // Advertise gasless approve at the top level if ANY accepted option + // supports it (e.g. an OBOL/Permit2 option alongside USDC). The buyer + // only takes the permit flow when paying with the supporting asset. + if extensions == nil { + if ext := BuildExtensionsForAsset(asset); ext != nil { + extensions = ext + } + } } - - asset := ResolveAssetInfo(chain, rule) - requirement := BuildV2RequirementWithAsset(chain, asset, rule.Price, wallet, rule.MaxTimeoutSeconds) - mergeAgentExtras(&requirement, rule) - extensions := WithBazaar(BuildExtensionsForAsset(asset), rule.OfferType, rule.Model) - return rule, requirement, extensions, prometheusLabels(rule), chain, asset, true + if len(reqs) == 0 { + return nil, false + } + extensions = WithBazaar(extensions, rule.OfferType, rule.Model) + return &matchedRoute{ + rule: rule, + requirements: reqs, + optionLabels: optLabels, + extensions: extensions, + chain: primaryChain, + asset: primaryAsset, + labels: optLabels[0], + }, true } // isUnderPaidPrefix reports whether uri starts with any of the URI @@ -592,7 +667,16 @@ func prometheusLabels(rule *RouteRule) prometheus.Labels { // against the ServiceOffer CR at query time. Cardinality cost is zero // because each offer pins exactly one asset — the new dimension is // functionally constant within the existing (ns, name) group. - asset := rule.AssetSymbol + // The inline fields describe the primary payment option; delegate so + // primary and per-option labels share one definition. + return labelsForPaymentOption(rule, RoutePayment{Network: rule.Network, AssetSymbol: rule.AssetSymbol}) +} + +// labelsForPaymentOption builds the metric label set for one accepted payment +// option of a route. A multi-payment offer emits one series per option so +// revenue/charges attribute to the actual (chain, asset) the buyer used. +func labelsForPaymentOption(rule *RouteRule, opt RoutePayment) prometheus.Labels { + asset := opt.AssetSymbol if asset == "" { // Defensive: a missing symbol is operationally ugly in PromQL. // Empty-string labels are legal in Prometheus but render as a @@ -604,7 +688,7 @@ func prometheusLabels(rule *RouteRule) prometheus.Labels { return prometheus.Labels{ "offer_namespace": rule.OfferNamespace, "offer_name": rule.OfferName, - "chain": rule.Network, + "chain": opt.Network, "asset_symbol": asset, } } diff --git a/internal/x402/verifier_test.go b/internal/x402/verifier_test.go index 036a9298..f8b05e82 100644 --- a/internal/x402/verifier_test.go +++ b/internal/x402/verifier_test.go @@ -204,6 +204,79 @@ func TestVerifier_PaidRoute_ValidPayment_Returns200(t *testing.T) { } } +// TestVerifier_MultiPayment_AdvertisesAllAndSettlesChosen pins the Phase-1 +// multi-currency behaviour: a route with two accepted payment options +// advertises BOTH in the 402 accepts[] array, and a buyer paying with the +// SECOND (non-primary) option verifies + settles against that option rather +// than silently being matched to the primary. +func TestVerifier_MultiPayment_AdvertisesAllAndSettlesChosen(t *testing.T) { + fac := newMockFacilitator(t, mockFacilitatorOpts{}) + + const ( + payToPrimary = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + payToSecond = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ) + route := RouteRule{ + Pattern: "/services/multi/*", + OfferNamespace: "demo", + OfferName: "multi", + Payments: []RoutePayment{ + {Price: "0.001", PayTo: payToPrimary, Network: "base-sepolia", AssetSymbol: "USDC"}, + {Price: "0.002", PayTo: payToSecond, Network: "base-sepolia", AssetSymbol: "USDC"}, + }, + } + + // (1) No payment → 402 with BOTH options in accepts[]. + v := newTestVerifier(t, fac.URL, []RouteRule{route}) + req := httptest.NewRequest(http.MethodPost, "/verify", nil) + req.Header.Set("X-Forwarded-Uri", "/services/multi/run") + req.Header.Set("X-Forwarded-Host", "obol.stack") + w := httptest.NewRecorder() + v.HandleVerify(w, req) + if w.Code != http.StatusPaymentRequired { + t.Fatalf("expected 402, got %d", w.Code) + } + var got x402types.PaymentRequired + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("parse 402 body: %v (body=%s)", err, w.Body.String()) + } + if len(got.Accepts) != 2 { + t.Fatalf("accepts length = %d, want 2 (multi-currency offer)", len(got.Accepts)) + } + amounts := map[string]string{got.Accepts[0].PayTo: got.Accepts[0].Amount, got.Accepts[1].PayTo: got.Accepts[1].Amount} + if amounts[payToPrimary] != "1000" || amounts[payToSecond] != "2000" { + t.Fatalf("accepts amounts = %v, want primary→1000 secondary→2000", amounts) + } + + // (2) Pay with the SECOND option → settles. The buyer's X-PAYMENT carries + // option 2's payTo + atomic amount; findMatchingRequirementV1 must select + // it and the facilitator settle against it. + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer upstream.Close() + route.UpstreamURL = upstream.URL + route.StripPrefix = "/services/multi" + vp := newTestVerifier(t, fac.URL, []RouteRule{route}) + + preq := httptest.NewRequest(http.MethodPost, "/services/multi/run", strings.NewReader(`{}`)) + preq.Header.Set("Content-Type", "application/json") + preq.Header.Set("X-PAYMENT", testPaymentHeaderFor(t, payToSecond, "2000")) + pw := httptest.NewRecorder() + vp.HandleProxy(pw, preq) + + if pw.Code != http.StatusOK { + t.Fatalf("paying with second option: expected 200, got %d (body=%s)", pw.Code, pw.Body.String()) + } + if fac.settleCalls.Load() == 0 { + t.Fatal("expected the chosen (second) option to settle") + } + if pw.Header().Get("X-PAYMENT-RESPONSE") == "" { + t.Fatal("expected X-PAYMENT-RESPONSE on successful settlement of the chosen option") + } +} + func TestVerifier_PaidRoute_RejectedPayment_Returns402(t *testing.T) { fac := newMockFacilitator(t, mockFacilitatorOpts{rejectPayment: true}) v := newTestVerifier(t, fac.URL, []RouteRule{ diff --git a/web/public-storefront/src/app/layout.tsx b/web/public-storefront/src/app/layout.tsx index 0935de9b..94dca4fd 100644 --- a/web/public-storefront/src/app/layout.tsx +++ b/web/public-storefront/src/app/layout.tsx @@ -53,12 +53,8 @@ function buildDynamicCopy(services: Service[]) { if (services.length === 0) { return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION }; } - const demos = services.filter((s) => s.isDemo).length; const total = services.length; - const summary = - demos > 0 - ? `${total} service${total === 1 ? "" : "s"} (${demos} demo${demos === 1 ? "" : "s"})` - : `${total} service${total === 1 ? "" : "s"}`; + const summary = `${total} service${total === 1 ? "" : "s"}`; const title = `Obol Stack — ${summary} for sale`; const sample = services .slice(0, 3) diff --git a/web/public-storefront/src/components/ServiceCard.tsx b/web/public-storefront/src/components/ServiceCard.tsx index f2dbf032..4e866518 100644 --- a/web/public-storefront/src/components/ServiceCard.tsx +++ b/web/public-storefront/src/components/ServiceCard.tsx @@ -33,9 +33,9 @@ export function ServiceCard({ service }: { service: Service }) {

{service.name}

- {service.isDemo && ( + {service.category && ( - demo + {service.category} )} diff --git a/web/public-storefront/src/components/ServicesList.tsx b/web/public-storefront/src/components/ServicesList.tsx index ab6e83a3..81679370 100644 --- a/web/public-storefront/src/components/ServicesList.tsx +++ b/web/public-storefront/src/components/ServicesList.tsx @@ -45,36 +45,46 @@ export function ServicesList({ initial }: { initial: Service[] }) { ); } - const demos = services.filter((s) => s.isDemo); - const others = services.filter((s) => !s.isDemo); + // Group into storefront sections by category. Demo is just another + // category — no special-casing. Services arrive pre-sorted by the catalog + // (weight desc, then name), so iterating in order and emitting categories + // as first encountered makes the section order follow weight too + // (uncategorized services render under "Services"). + const sections = groupByCategory(services); return (
- {demos.length > 0 && ( -
+ {sections.map(({ category, items }) => ( +

- Demo services + {sectionTitle(category)}

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

- Services -

-
- {others.map((s) => ( - - ))} -
-
- )} + ))}
); } + +function groupByCategory(services: Service[]): { category: string; items: Service[] }[] { + const order: string[] = []; + const buckets = new Map(); + for (const s of services) { + const cat = s.category ?? ""; + if (!buckets.has(cat)) { + buckets.set(cat, []); + order.push(cat); + } + buckets.get(cat)!.push(s); + } + return order.map((category) => ({ category, items: buckets.get(category)! })); +} + +function sectionTitle(category: string): string { + if (!category) return "Services"; + return category.charAt(0).toUpperCase() + category.slice(1); +} diff --git a/web/public-storefront/src/types.ts b/web/public-storefront/src/types.ts index a4c6bccd..689c4d29 100644 --- a/web/public-storefront/src/types.ts +++ b/web/public-storefront/src/types.ts @@ -28,5 +28,10 @@ export interface Service { // offers it mirrors spec.registration.skills. Rendered as pills on // the ServiceCard, matching the 402 page. skills?: string[]; - isDemo: boolean; + // category groups the service into a storefront section (e.g. "demo"). + // Absent/empty means the default section. Mirrors spec.listing.category — + // demo services are just category="demo", not a special case. + category?: string; + // weight orders services within a category; higher sorts earlier. + weight?: number; } From c9efee31b266d7febe9e6a1373567b75711964e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 19 Jun 2026 12:01:36 +0100 Subject: [PATCH 2/5] feat(agent): improve agent factory with learnings from prior effort at making multi-service agents --- internal/embed/embed_skills_test.go | 9 +- internal/embed/skills/agent-factory/SKILL.md | 18 + .../skills/agent-factory/scripts/factory.py | 314 ++++++++++++++++-- 3 files changed, 309 insertions(+), 32 deletions(-) diff --git a/internal/embed/embed_skills_test.go b/internal/embed/embed_skills_test.go index 285d895f..b7a5ed14 100644 --- a/internal/embed/embed_skills_test.go +++ b/internal/embed/embed_skills_test.go @@ -369,8 +369,13 @@ args = SimpleNamespace( ) offer = factory.serviceoffer_resource(args, "hermes-obol-agent") registration = offer["spec"]["registration"] -if registration.get("enabled") is not True: - raise SystemExit("registration metadata did not enable registration") +# §5 decoupling: register_name/description populate the block for discovery, +# but on-chain registration (enabled) is driven ONLY by --register. Here +# register=False, so the block is present yet enabled stays False. +if registration.get("enabled") is not False: + raise SystemExit("registration.enabled must follow --register, not register-name") +if registration.get("name") != "Medical Advisor": + raise SystemExit(f"registration name not populated: {registration!r}") if registration.get("skills") != ["privacy-filter"]: raise SystemExit(f"registration skills did not inherit agent skills: {registration!r}") expected_metadata = { diff --git a/internal/embed/skills/agent-factory/SKILL.md b/internal/embed/skills/agent-factory/SKILL.md index 1808e70e..c59d0c96 100644 --- a/internal/embed/skills/agent-factory/SKILL.md +++ b/internal/embed/skills/agent-factory/SKILL.md @@ -24,6 +24,24 @@ python3 scripts/factory.py create medical-advisor \ --register-name "Medical Advisor" ``` +### Multiple currencies / networks (one agent, one offer) + +Use repeatable `--accept` instead of `--price`/`--network` to advertise several +payment options on a single offer (the buyer picks one). `token=` pulls +the asset metadata from the built-in registry; for an unlisted token supply it +inline with `asset=0x..,decimals=..,transfer=eip3009|permit2,eip712-name=..,eip712-version=..,symbol=..`: + +```bash +python3 scripts/factory.py create bankr-analyst \ + --model openrouter/auto --skills bankr-token-analysis --create-wallet \ + --accept token=OBOL,network=ethereum,price=10 \ + --accept token=USDC,network=base,price=1,pay-to=0xColdVault \ + --description "Paid token-analysis agent" +``` + +Each `--accept` may carry its own `pay-to` (else it inherits `--pay-to`). +ERC-8004 registration uses the first option's network. + ## Commands | Command | Description | diff --git a/internal/embed/skills/agent-factory/scripts/factory.py b/internal/embed/skills/agent-factory/scripts/factory.py index b082a588..1cbee9a8 100644 --- a/internal/embed/skills/agent-factory/scripts/factory.py +++ b/internal/embed/skills/agent-factory/scripts/factory.py @@ -215,9 +215,7 @@ def build_profile_archive(name, objective, skills, soul_file=None): f.write(soul) for skill in skills: - src = os.path.join(SKILLS_ROOT, skill) - if not os.path.isdir(src): - raise ValueError(f"skill {skill!r} is not available under {SKILLS_ROOT}") + src = resolve_skill_dir(skill) safe_copytree(src, os.path.join(root, "obol-skills", skill)) buf = io.BytesIO() @@ -226,6 +224,27 @@ def build_profile_archive(name, objective, skills, soul_file=None): return buf.getvalue() +def resolve_skill_dir(skill): + """Locate a skill directory, preferring the flat obol-skills layout but + falling back to one level of category subdir (skills//), + where `skill_manage` creates new skills. Raises with the exact fix when + nothing matches (handoff §6: the silent skills/ vs obol-skills/ trap).""" + flat = os.path.join(SKILLS_ROOT, skill) + if os.path.isdir(flat): + return flat + try: + for category in sorted(os.listdir(SKILLS_ROOT)): + candidate = os.path.join(SKILLS_ROOT, category, skill) + if os.path.isdir(candidate): + return candidate + except OSError: + pass + raise ValueError( + f"skill {skill!r} not found under {SKILLS_ROOT} (searched the flat layout and one " + f"category level). If you authored it with skill_manage, copy it into place: " + f"cp -r {SKILLS_ROOT}//{skill} {SKILLS_ROOT}/{skill}") + + def validate_profile_archive_bytes(archive_bytes): roots = set() with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tf: @@ -320,36 +339,230 @@ def agent_resource(args, parent_ns): } -def serviceoffer_resource(args, parent_ns): +# Supported payment chains. Mirrors internal/x402/chains.go ResolveChainInfo; +# keep in sync. CAIP-2 ids and a few human aliases normalize to the canonical +# names used as ASSET_REGISTRY keys. +KNOWN_CHAINS = { + "base", "base-sepolia", "ethereum", "polygon", "polygon-amoy", + "avalanche", "avalanche-fuji", "arbitrum-one", "arbitrum-sepolia", +} +CHAIN_ALIASES = { + "base-mainnet": "base", "ethereum-mainnet": "ethereum", "mainnet": "ethereum", + "polygon-mainnet": "polygon", "avalanche-mainnet": "avalanche", "arbitrum": "arbitrum-one", +} +CAIP2_TO_CHAIN = { + "eip155:8453": "base", "eip155:84532": "base-sepolia", "eip155:1": "ethereum", + "eip155:137": "polygon", "eip155:80002": "polygon-amoy", "eip155:43114": "avalanche", + "eip155:43113": "avalanche-fuji", "eip155:42161": "arbitrum-one", "eip155:421614": "arbitrum-sepolia", +} + +# ASSET_REGISTRY mirrors the non-USDC tokens in internal/x402/tokens.go. USDC is +# the chain default (emitted as an empty asset block, like the obol CLI), so it +# needs no entry here. Keep in sync when adding tokens to tokens.go. +ASSET_REGISTRY = { + "OBOL": { + "ethereum": {"address": "0x0B010000b7624eb9B3DfBC279673C76E9D29D5F7", "symbol": "OBOL", "decimals": 18, + "transferMethod": "permit2", "eip712Name": "Obol Network", "eip712Version": "1"}, + "base-sepolia": {"address": "0x0a09371a8b011d5110656ceBCc70603e53FD2c78", "symbol": "OBOL", "decimals": 18, + "transferMethod": "permit2", "eip712Name": "Obol Network", "eip712Version": "1"}, + }, +} + +ACCEPT_PRICE_KEYS = {"price": "perRequest", "per-request": "perRequest", "per-mtok": "perMTok", + "per-hour": "perHour", "per-epoch": "perEpoch"} +ACCEPT_KNOWN_KEYS = set(ACCEPT_PRICE_KEYS) | { + "token", "network", "chain", "pay-to", "asset", "decimals", "transfer", + "symbol", "eip712-name", "eip712-version", "max-timeout", +} + + +def resolve_chain(network): + """Normalize a chain name / CAIP-2 id to a canonical supported-chain name.""" + n = (network or "").strip().lower() + if n in CAIP2_TO_CHAIN: + return CAIP2_TO_CHAIN[n] + n = CHAIN_ALIASES.get(n, n) + if n not in KNOWN_CHAINS: + raise ValueError(f"unsupported chain: {network} (use one of {', '.join(sorted(KNOWN_CHAINS))}, or any eip155: we know)") + return n + + +def parse_accept_option(raw, default_pay_to): + """Parse one --accept value into (payment_dict, dedup_key). + + Grammar mirrors `obol sell --accept` (cmd/obol/accept.go): token= + registry shorthand XOR asset=0x.. raw escape hatch, plus network, one price + slot, optional pay-to and max-timeout. + """ + kv = {} + max_timeout = 0 + for part in raw.split(","): + part = part.strip() + if not part: + continue + if "=" not in part: + raise ValueError(f"malformed --accept segment {part!r} (want key=value)") + k, v = part.split("=", 1) + k, v = k.strip().lower(), v.strip() + if not k or not v: + raise ValueError(f"malformed --accept segment {part!r} (want key=value)") + if k not in ACCEPT_KNOWN_KEYS: + raise ValueError(f"unknown --accept key {k!r}") + if k == "max-timeout": + if not v.isdigit() or int(v) <= 0: + raise ValueError(f"--accept max-timeout {v!r} must be a positive integer") + max_timeout = int(v) + continue + if k in kv: + raise ValueError(f"--accept key {k!r} given twice") + kv[k] = v + + network = kv.get("network") or kv.get("chain") + if not network: + raise ValueError(f"--accept {raw!r}: network is required") + chain = resolve_chain(network) + + pay_to = kv.get("pay-to") or (default_pay_to or "").strip() + if not ADDR_RE.match(pay_to or ""): + raise ValueError(f"--accept {raw!r}: pay-to must be a 0x EVM address (got {pay_to!r})") + + price_key = price_val = None + for flag, slot in ACCEPT_PRICE_KEYS.items(): + if kv.get(flag): + if price_key: + raise ValueError(f"--accept {raw!r}: set only one of price/per-request/per-mtok/per-hour/per-epoch") + price_key, price_val = slot, kv[flag] + if not price_key: + raise ValueError(f"--accept {raw!r}: a price is required (price=, per-mtok=, ...)") + validate_positive_decimal(price_val, f"--accept {raw!r} price") + + token_sym = (kv.get("token") or "").strip() + raw_addr = (kv.get("asset") or "").strip() payment = { "scheme": "exact", - "network": args.network, - "payTo": args.pay_to, - "maxTimeoutSeconds": args.max_timeout, - "price": {"perRequest": args.price}, + "network": chain, + "payTo": pay_to, + "maxTimeoutSeconds": max_timeout or 300, + "price": {price_key: price_val}, } + + if token_sym and raw_addr: + raise ValueError(f"--accept {raw!r}: set either token= or asset=0x..., not both") + if raw_addr: + if not ADDR_RE.match(raw_addr): + raise ValueError(f"--accept {raw!r}: asset must be a 0x ERC-20 address (got {raw_addr!r})") + if not (kv.get("decimals") or "").isdigit() or not (0 < int(kv["decimals"]) <= 255): + raise ValueError(f"--accept {raw!r}: raw asset needs decimals=<1-255>") + transfer = (kv.get("transfer") or "").lower() + if transfer not in ("eip3009", "permit2"): + raise ValueError(f"--accept {raw!r}: raw asset needs transfer=eip3009|permit2") + symbol, name, version = kv.get("symbol", ""), kv.get("eip712-name", ""), kv.get("eip712-version", "") + if not (symbol and name and version): + raise ValueError(f"--accept {raw!r}: raw asset needs symbol, eip712-name and eip712-version (the token's EIP-712 signing domain)") + payment["asset"] = {"address": raw_addr, "symbol": symbol, "decimals": int(kv["decimals"]), + "transferMethod": transfer, "eip712Name": name, "eip712Version": version} + dedup = f"{chain}\x00{raw_addr.lower()}" + elif token_sym and token_sym.upper() != "USDC": + entry = ASSET_REGISTRY.get(token_sym.upper(), {}).get(chain) + if not entry: + raise ValueError( + f"--accept {raw!r}: token {token_sym} is not in the registry for {chain} " + f"(use asset=0x... with decimals/transfer/eip712 for an unlisted token)") + payment["asset"] = dict(entry) + dedup = f"{chain}\x00{entry['address'].lower()}" + else: + # USDC (or no token): chain default, no explicit asset block. + dedup = f"{chain}\x00usdc" + + return payment, dedup + + +def build_accept_payments(accepts, default_pay_to): + """Parse every --accept value into payment dicts, rejecting duplicate + (chain, token) pairs. Returns [] when no --accept was given.""" + if not accepts: + return [] + payments = [] + seen = {} + for raw in accepts: + payment, dedup = parse_accept_option(raw, default_pay_to) + if dedup in seen: + raise ValueError(f"--accept duplicate payment option for the same (chain, token): {seen[dedup]!r} and {raw!r}") + seen[dedup] = raw + payments.append(payment) + return payments + + +def _payment_symbol(payment): + asset = payment.get("asset") or {} + return asset.get("symbol") or "USDC" + + +def _payment_price(payment): + for k in ("perRequest", "perMTok", "perHour", "perEpoch"): + if payment.get("price", {}).get(k): + return payment["price"][k] + return "" + + +def serviceoffer_resource(args, parent_ns): + accepts = getattr(args, "accept", None) or [] + if accepts: + payments = build_accept_payments(accepts, args.pay_to) + payment = payments[0] + else: + payment = { + "scheme": "exact", + "network": args.network, + "payTo": args.pay_to, + "maxTimeoutSeconds": args.max_timeout, + "price": {"perRequest": args.price}, + } + payments = None + + primary_network = payment["network"] + primary_symbol = _payment_symbol(payment) + primary_price = _payment_price(payment) + spec = { "type": "agent", "agent": {"ref": {"name": args.name, "namespace": namespace_for(args.name)}}, "payment": payment, "path": args.path or f"/services/{args.name}", } - if args.register or args.register_name or args.register_description or args.register_skills: + if payments is not None: + spec["payments"] = payments + + listing = {} + if getattr(args, "weight", 0): + listing["weight"] = args.weight + if getattr(args, "category", None): + listing["category"] = args.category.strip() + if listing: + spec["listing"] = listing + + # registration.description / .name are useful for discovery even without + # on-chain registration, so they are written whenever provided (decoupled + # from --register). `enabled` alone controls ERC-8004 publication. + description = getattr(args, "description", None) + enabled = bool(args.register) + if enabled or args.register_name or description or args.register_skills: reg = { - "enabled": True, + "enabled": enabled, "metadata": { "runtime": "hermes", "model": args.model, "pricingUnit": "agent-turn", - "x402Price": args.price, - "x402Asset": "USDC", - "x402Network": args.network, + # Registration is per-chain — describe the primary (first) option. + "x402Price": primary_price, + "x402Asset": primary_symbol, + "x402Network": primary_network, }, } if args.register_name: reg["name"] = args.register_name - if args.register_description: - reg["description"] = args.register_description + if description: + reg["description"] = description skills = parse_skills(args.register_skills) if args.register_skills else args.skills if skills: reg["skills"] = skills @@ -397,12 +610,18 @@ def cmd_create(args, token, parent_ns, ssl_ctx): raise ValueError("--path must start with /") if args.max_timeout <= 0: raise ValueError("--max-timeout must be greater than zero") - if args.price and not args.pay_to: - raise ValueError("--pay-to is required when --price is set") - if args.price: - validate_positive_decimal(args.price, "--price") - if args.pay_to and not ADDR_RE.match(args.pay_to): - raise ValueError("--pay-to must be a 0x-prefixed EVM address") + accepts = args.accept or [] + if accepts: + # build_accept_payments validates each option (network, price, asset, + # pay-to); fail fast here so we don't create the Agent then choke. + build_accept_payments(accepts, args.pay_to) + else: + if args.price and not args.pay_to: + raise ValueError("--pay-to is required when --price is set") + if args.price: + validate_positive_decimal(args.price, "--price") + if args.pay_to and not ADDR_RE.match(args.pay_to): + raise ValueError("--pay-to must be a 0x-prefixed EVM address") ns = namespace_for(args.name) apply_resource("/api/v1/namespaces", ns, namespace_resource(args.name, parent_ns), token, ssl_ctx) @@ -433,7 +652,7 @@ def cmd_create(args, token, parent_ns, ssl_ctx): ) offer_name = None - if args.price: + if args.price or accepts: offer = serviceoffer_resource(args, parent_ns) offer_name = offer["metadata"]["name"] apply_resource( @@ -483,9 +702,7 @@ def cmd_status(args, token, parent_ns, ssl_ctx): validate_resource_name(args.offer_name, "--offer-name") ns = namespace_for(args.name) agent = api_request("GET", f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{AGENT_PLURAL}/{args.name}", token, ssl_ctx, quiet=True) - offer_name = args.offer_name or args.name - offer = api_request("GET", f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{OFFER_PLURAL}/{offer_name}", token, ssl_ctx, quiet=True) - out = {"agent": None, "serviceOffer": None} + out = {"agent": None, "serviceOffers": []} if not agent.get("_error"): out["agent"] = { "name": f"{ns}/{args.name}", @@ -494,12 +711,38 @@ def cmd_status(args, token, parent_ns, ssl_ctx): "walletAddress": agent.get("status", {}).get("walletAddress", ""), "endpoint": agent.get("status", {}).get("endpoint", ""), } - if not offer.get("_error"): - out["serviceOffer"] = { - "name": f"{ns}/{offer_name}", + + # Report every ServiceOffer in the agent's namespace (an agent can carry + # several — e.g. distinct offer names — and each offer can itself accept + # multiple currencies). --offer-name narrows to one for targeted queries. + def offer_view(offer): + spec = offer.get("spec", {}) + payments = spec.get("payments") or ([spec["payment"]] if spec.get("payment") else []) + opts = [] + for p in payments: + asset = p.get("asset") or {} + opts.append({ + "network": p.get("network", ""), + "symbol": asset.get("symbol") or "USDC", + "price": next((p["price"][k] for k in ("perRequest", "perMTok", "perHour", "perEpoch") + if p.get("price", {}).get(k)), ""), + "payTo": p.get("payTo", ""), + }) + return { + "name": f"{ns}/{offer.get('metadata', {}).get('name', '')}", "ready": condition_status(offer, "Ready")[0], "endpoint": offer.get("status", {}).get("endpoint", ""), + "payments": opts, } + + if args.offer_name: + offer = api_request("GET", f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{OFFER_PLURAL}/{args.offer_name}", token, ssl_ctx, quiet=True) + if not offer.get("_error"): + out["serviceOffers"].append(offer_view(offer)) + else: + listing = api_request("GET", f"/apis/{CRD_GROUP}/{CRD_VERSION}/namespaces/{ns}/{OFFER_PLURAL}", token, ssl_ctx, quiet=True) + for offer in listing.get("items", []) or []: + out["serviceOffers"].append(offer_view(offer)) print(json.dumps(out, indent=2)) @@ -548,14 +791,25 @@ def build_parser(): create.add_argument("--create-wallet", action="store_true") create.add_argument("--env", action="append", default=[], help="Child env Secret entry KEY=VALUE") create.add_argument("--price", help="USDC per-request price; creates ServiceOffer when set") - create.add_argument("--pay-to", help="Payment recipient wallet") + create.add_argument("--pay-to", help="Payment recipient wallet (also the default recipient for --accept options)") create.add_argument("--network", default="base-sepolia") + create.add_argument( + "--accept", action="append", default=[], + help="Accepted payment option (repeatable) for multi-currency offers, e.g. " + "--accept token=OBOL,network=ethereum,price=10 --accept token=USDC,network=base,price=1. " + "Unlisted tokens: asset=0x..,decimals=..,transfer=eip3009|permit2,eip712-name=..,eip712-version=..,symbol=... " + "When set, --price/--network are ignored.") create.add_argument("--path") create.add_argument("--offer-name") create.add_argument("--max-timeout", type=int, default=300) + create.add_argument("--weight", type=int, default=0, help="Storefront ordering weight; higher sorts earlier within its category") + create.add_argument("--category", help="Storefront grouping section (e.g. \"demo\")") create.add_argument("--register", action="store_true") create.add_argument("--register-name") - create.add_argument("--register-description") + create.add_argument( + "--description", "--register-description", dest="description", + help="Human-readable service description. Written to registration.description for " + "discovery regardless of --register; --register alone controls on-chain publication.") create.add_argument("--register-skills", action="append", default=[]) create.add_argument("--wait", action="store_true") create.add_argument("--timeout", type=int, default=180) From a81937d90a7dd64c513d2d6e8d3b672f019287d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 19 Jun 2026 12:38:42 +0100 Subject: [PATCH 3/5] feat(storefront): update storefront for multi-payment assets --- cmd/obol/accept.go | 36 ++-- cmd/obol/accept_test.go | 63 ++++++- cmd/obol/sell.go | 11 ++ cmd/obol/sell_agent.go | 7 + cmd/obol/tokenmeta.go | 156 ++++++++++++++++++ internal/schemas/service-catalog.schema.json | 56 +++++++ internal/schemas/service_catalog.go | 21 +++ internal/serviceoffercontroller/render.go | 105 ++++++++---- .../serviceoffercontroller/render_test.go | 51 ++++++ .../src/components/ServiceCard.tsx | 133 ++++++++++++--- web/public-storefront/src/types.ts | 16 ++ 11 files changed, 587 insertions(+), 68 deletions(-) create mode 100644 cmd/obol/tokenmeta.go diff --git a/cmd/obol/accept.go b/cmd/obol/accept.go index ef05bdfd..ea8fa193 100644 --- a/cmd/obol/accept.go +++ b/cmd/obol/accept.go @@ -156,23 +156,30 @@ func parseAcceptOption(raw, defaultPayTo string) (acceptOption, int64, error) { if !evmAddressRe.MatchString(rawAddr) { return acceptOption{}, 0, fmt.Errorf("--accept %q: asset must be a 0x ERC-20 address (got %q)", raw, rawAddr) } - dec, derr := strconv.Atoi(kv["decimals"]) - if derr != nil || dec <= 0 || dec > 255 { - return acceptOption{}, 0, fmt.Errorf("--accept %q: raw asset needs decimals=<1-255>", raw) + // transfer defaults to permit2 — the near-universal flow (EIP-3009 is + // effectively USDC-only). decimals/symbol/eip712-* are optional here: + // any not supplied are filled best-effort from the chain by + // autofillAcceptPayments, which errors if they still can't be resolved. + transfer := strings.ToLower(strings.TrimSpace(kv["transfer"])) + if transfer == "" { + transfer = schemas.AssetTransferMethodPermit2 } - transfer := strings.ToLower(kv["transfer"]) if transfer != schemas.AssetTransferMethodEIP3009 && transfer != schemas.AssetTransferMethodPermit2 { - return acceptOption{}, 0, fmt.Errorf("--accept %q: raw asset needs transfer=eip3009|permit2", raw) + return acceptOption{}, 0, fmt.Errorf("--accept %q: transfer must be eip3009 or permit2", raw) } - symbol := strings.TrimSpace(kv["symbol"]) - name := strings.TrimSpace(kv["eip712-name"]) - version := strings.TrimSpace(kv["eip712-version"]) - if symbol == "" || name == "" || version == "" { - return acceptOption{}, 0, fmt.Errorf("--accept %q: raw asset needs symbol, eip712-name and eip712-version (the token's EIP-712 signing domain)", raw) + dec := 0 + if d := strings.TrimSpace(kv["decimals"]); d != "" { + n, derr := strconv.Atoi(d) + if derr != nil || n <= 0 || n > 255 { + return acceptOption{}, 0, fmt.Errorf("--accept %q: decimals must be 1-255", raw) + } + dec = n } opt.Asset = schemas.AssetTerms{ - Address: rawAddr, Symbol: symbol, Decimals: dec, - TransferMethod: transfer, EIP712Name: name, EIP712Version: version, + Address: rawAddr, Symbol: strings.TrimSpace(kv["symbol"]), Decimals: dec, + TransferMethod: transfer, + EIP712Name: strings.TrimSpace(kv["eip712-name"]), + EIP712Version: strings.TrimSpace(kv["eip712-version"]), } opt.dedupKey = canonicalChain + "\x00" + strings.ToLower(rawAddr) @@ -271,8 +278,9 @@ func acceptFlags() []cli.Flag { Name: "accept", Usage: "Accepted payment option (repeatable) for multi-currency offers, e.g. " + "--accept token=OBOL,network=ethereum,price=10 --accept token=USDC,network=base,price=1. " + - "Unlisted tokens: asset=0x..,decimals=..,transfer=eip3009|permit2,eip712-name=..,eip712-version=..,symbol=... " + - "When set, --chain/--token/--price are ignored.", + "Unlisted tokens: asset=0x..,network=..,price=.. — decimals/symbol/eip712-name/eip712-version are read " + + "from the chain (EIP-5267) when omitted and transfer defaults to permit2; pass them explicitly to override " + + "or if the chain can't be reached. When set, --chain/--token/--price are ignored.", }, &cli.IntFlag{ Name: "weight", diff --git a/cmd/obol/accept_test.go b/cmd/obol/accept_test.go index 54085d91..8897c08b 100644 --- a/cmd/obol/accept_test.go +++ b/cmd/obol/accept_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "strings" "testing" @@ -67,7 +68,7 @@ func TestParseAcceptOption_Errors(t *testing.T) { {"two prices", "token=USDC,network=base,price=1,per-mtok=2,pay-to=" + testPayTo, "only one of"}, {"bad pay-to", "token=USDC,network=base,price=1,pay-to=nope", "pay-to must be"}, {"token and asset", "token=USDC,asset=0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,network=base,price=1,pay-to=" + testPayTo, "not both"}, - {"raw missing meta", "asset=0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,network=base,price=1,pay-to=" + testPayTo, "decimals"}, + {"bad decimals", "asset=0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,decimals=999,network=base,price=1,pay-to=" + testPayTo, "decimals must be"}, {"unregistered token", "token=WETH,network=base,price=1,pay-to=" + testPayTo, "not in the registry"}, } for _, tc := range cases { @@ -80,6 +81,66 @@ func TestParseAcceptOption_Errors(t *testing.T) { } } +func TestParseAcceptOption_RawAssetDefersToAutofill(t *testing.T) { + // A raw asset with just the address parses (transfer defaults to permit2); + // decimals/eip712 are left for autofill rather than erroring at parse. + opt, _, err := parseAcceptOption("asset=0x"+strings.Repeat("b", 40)+",network=base,price=1,pay-to="+testPayTo, "") + if err != nil { + t.Fatalf("partial raw asset should parse, got: %v", err) + } + if opt.Asset.Address != "0x"+strings.Repeat("b", 40) || opt.Asset.TransferMethod != schemas.AssetTransferMethodPermit2 { + t.Fatalf("partial raw asset = %+v, want address set + permit2 default", opt.Asset) + } + if opt.Asset.Decimals != 0 || opt.Asset.EIP712Name != "" { + t.Errorf("decimals/eip712 should be empty pending autofill, got %+v", opt.Asset) + } +} + +func TestAutofillAcceptPayments(t *testing.T) { + ctx := context.Background() + full := tokenMeta{Decimals: 18, Symbol: "FOO", EIP712Name: "Foo Token", EIP712Version: "1"} + + // (1) Partial raw asset → filled from chain. + pays, _ := buildAcceptPayments([]string{"asset=0x" + strings.Repeat("b", 40) + ",network=base,price=1,pay-to=" + testPayTo}, "") + calls := 0 + err := autofillAcceptPayments(ctx, pays, func(_ context.Context, _, _ string) (tokenMeta, error) { + calls++ + return full, nil + }) + if err != nil { + t.Fatalf("autofill: %v", err) + } + a := pays[0]["asset"].(schemas.AssetTerms) + if a.Decimals != 18 || a.EIP712Name != "Foo Token" || a.EIP712Version != "1" || a.Symbol != "FOO" { + t.Fatalf("autofill did not fill from chain: %+v", a) + } + + // (2) Registry (OBOL) + USDC options are already complete → no RPC call. + pays, _ = buildAcceptPayments([]string{ + "token=USDC,network=base,price=1,pay-to=" + testPayTo, + "token=OBOL,network=ethereum,price=10,pay-to=" + testPayTo, + }, "") + calls = 0 + if err := autofillAcceptPayments(ctx, pays, func(_ context.Context, _, _ string) (tokenMeta, error) { + calls++ + return tokenMeta{}, nil + }); err != nil { + t.Fatalf("autofill complete options: %v", err) + } + if calls != 0 { + t.Errorf("registry/USDC options should not hit the chain, got %d calls", calls) + } + + // (3) Chain can't resolve the signature-critical fields → error to specify. + pays, _ = buildAcceptPayments([]string{"asset=0x" + strings.Repeat("c", 40) + ",network=base,price=1,pay-to=" + testPayTo}, "") + err = autofillAcceptPayments(ctx, pays, func(_ context.Context, _, _ string) (tokenMeta, error) { + return tokenMeta{Symbol: "X"}, nil // no decimals / eip712 (token lacks EIP-5267) + }) + if err == nil || !strings.Contains(err.Error(), "could not read") { + t.Fatalf("expected unresolved-fields error, got %v", err) + } +} + func TestBuildAcceptPayments(t *testing.T) { // No --accept → nil so callers fall back to singular flags. got, err := buildAcceptPayments(nil, testPayTo) diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index f3c68ffa..85916d4f 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -880,6 +880,17 @@ Examples: if err != nil { return err } + if paymentsList != nil { + // Best-effort fill of any raw-asset metadata from the chain + // (no-op for registry/USDC options). Errors if a raw token's + // decimals/EIP-712 domain can't be resolved. + if err := autofillAcceptPayments(ctx, paymentsList, func(ctx context.Context, network, addr string) (tokenMeta, error) { + return fetchTokenMeta(ctx, cfg, network, addr) + }); err != nil { + return err + } + paymentBlock = paymentsList[0] + } wallet = primaryPayTo spec := map[string]any{ diff --git a/cmd/obol/sell_agent.go b/cmd/obol/sell_agent.go index 7a8f4315..c6bc473a 100644 --- a/cmd/obol/sell_agent.go +++ b/cmd/obol/sell_agent.go @@ -163,6 +163,13 @@ Examples: if err != nil { return err } + // Best-effort fill of raw-asset metadata from the chain + // (no-op for registry/USDC options). + if err := autofillAcceptPayments(ctx, paymentsList, func(ctx context.Context, network, addr string) (tokenMeta, error) { + return fetchTokenMeta(ctx, cfg, network, addr) + }); err != nil { + return err + } payment = paymentsList[0] primaryNetwork, _ = payment["network"].(string) primaryPayTo, _ = payment["payTo"].(string) diff --git a/cmd/obol/tokenmeta.go b/cmd/obol/tokenmeta.go new file mode 100644 index 00000000..ed458ad3 --- /dev/null +++ b/cmd/obol/tokenmeta.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/erc8004" + "github.com/ObolNetwork/obol-stack/internal/schemas" + "github.com/ObolNetwork/obol-stack/internal/stack" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +// Minimal ABI for best-effort token-metadata reads: ERC-20 decimals()/symbol() +// plus EIP-5267 eip712Domain() (authoritative signing name+version — the field +// the contract's name() can't be trusted for, e.g. Base Sepolia USDC). +const erc20MetaABI = `[ + {"name":"decimals","stateMutability":"view","type":"function","inputs":[],"outputs":[{"type":"uint8"}]}, + {"name":"symbol","stateMutability":"view","type":"function","inputs":[],"outputs":[{"type":"string"}]}, + {"name":"eip712Domain","stateMutability":"view","type":"function","inputs":[],"outputs":[ + {"name":"fields","type":"bytes1"},{"name":"name","type":"string"},{"name":"version","type":"string"}, + {"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}, + {"name":"salt","type":"bytes32"},{"name":"extensions","type":"uint256[]"}]} +]` + +// tokenMeta is the best-effort on-chain metadata for an ERC-20. Any field that +// couldn't be read is left at its zero value (independent per call). +type tokenMeta struct { + Decimals int + Symbol string + EIP712Name string + EIP712Version string +} + +// tokenMetaFetcher reads token metadata for an address on a chain. Injected so +// the autofill merge/error logic stays unit-testable offline. +type tokenMetaFetcher func(ctx context.Context, network, tokenAddr string) (tokenMeta, error) + +// erpcAliasForChain maps a payment chain to its eRPC route alias. Known +// ERC-8004 chains use their configured alias (ethereum → "mainnet"); any other +// supported chain falls back to its canonical name, which is the alias +// `obol network add ` registers. +func erpcAliasForChain(network string) string { + if net, err := erc8004.ResolveNetwork(network); err == nil { + return net.ERPCNetwork + } + return network +} + +// fetchTokenMeta best-effort reads ERC-20 + EIP-5267 metadata via the stack's +// eRPC (host-reachable Traefik route, same path `obol sell register` uses). +// Per-field: a call that reverts/empties leaves that field zero. Returns an +// error only when the RPC endpoint itself is unreachable. +func fetchTokenMeta(ctx context.Context, cfg *config.Config, network, tokenAddr string) (tokenMeta, error) { + rpcURL := strings.TrimRight(stack.LocalIngressURL(cfg), "/") + "/rpc/" + erpcAliasForChain(network) + eth, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return tokenMeta{}, fmt.Errorf("connect eRPC at %s: %w", rpcURL, err) + } + defer eth.Close() + + parsed, err := abi.JSON(strings.NewReader(erc20MetaABI)) + if err != nil { + return tokenMeta{}, err + } + c := bind.NewBoundContract(common.HexToAddress(tokenAddr), parsed, eth, eth, eth) + opts := &bind.CallOpts{Context: ctx} + + var meta tokenMeta + var out []any + if err := c.Call(opts, &out, "decimals"); err == nil && len(out) == 1 { + if d, ok := out[0].(uint8); ok { + meta.Decimals = int(d) + } + } + out = nil + if err := c.Call(opts, &out, "symbol"); err == nil && len(out) == 1 { + if s, ok := out[0].(string); ok { + meta.Symbol = strings.TrimSpace(s) + } + } + out = nil + if err := c.Call(opts, &out, "eip712Domain"); err == nil && len(out) >= 3 { + if n, ok := out[1].(string); ok { + meta.EIP712Name = strings.TrimSpace(n) + } + if v, ok := out[2].(string); ok { + meta.EIP712Version = strings.TrimSpace(v) + } + } + return meta, nil +} + +// assetComplete reports whether a raw-asset block has the signature-critical +// fields filled (decimals + EIP-712 domain). Symbol is cosmetic and excluded. +func assetComplete(a schemas.AssetTerms) bool { + return a.Decimals > 0 && a.EIP712Name != "" && a.EIP712Version != "" +} + +// autofillAcceptPayments fills missing token metadata on raw-asset payment +// options by reading the chain (best-effort, defaulting elsewhere to Permit2). +// Registry tokens and USDC chain-defaults are already complete and trigger NO +// RPC call. If the signature-critical fields still can't be resolved after the +// read, it errors telling the operator to specify them (or wire up eRPC) — +// never silently shipping a guess that would break settlement. +func autofillAcceptPayments(ctx context.Context, payments []map[string]any, fetch tokenMetaFetcher) error { + for _, p := range payments { + a, ok := p["asset"].(schemas.AssetTerms) + if !ok || assetComplete(a) { + continue + } + network, _ := p["network"].(string) + meta, err := fetch(ctx, network, a.Address) + if err != nil { + return fmt.Errorf( + "could not read token %s on %s from chain: %w\n Fix: pass decimals=,eip712-name=,eip712-version= in --accept, or run `obol network add %s` so eRPC can reach the chain", + a.Address, network, err, network) + } + if a.Decimals == 0 { + a.Decimals = meta.Decimals + } + if a.Symbol == "" { + a.Symbol = meta.Symbol + } + if a.EIP712Name == "" { + a.EIP712Name = meta.EIP712Name + } + if a.EIP712Version == "" { + a.EIP712Version = meta.EIP712Version + } + if !assetComplete(a) { + var missing []string + if a.Decimals == 0 { + missing = append(missing, "decimals") + } + if a.EIP712Name == "" { + missing = append(missing, "eip712-name") + } + if a.EIP712Version == "" { + missing = append(missing, "eip712-version") + } + return fmt.Errorf( + "token %s on %s: could not read %s from the chain (token may not implement EIP-5267) — specify them in --accept", + a.Address, network, strings.Join(missing, ", ")) + } + if a.Symbol == "" { + a.Symbol = "TOKEN" // cosmetic fallback; never affects signing + } + p["asset"] = a + } + return nil +} diff --git a/internal/schemas/service-catalog.schema.json b/internal/schemas/service-catalog.schema.json index 9b6ab6c9..9da9fa17 100644 --- a/internal/schemas/service-catalog.schema.json +++ b/internal/schemas/service-catalog.schema.json @@ -66,6 +66,54 @@ } } }, + "paymentOption": { + "type": "object", + "additionalProperties": false, + "required": [ + "price", + "payTo", + "network" + ], + "properties": { + "price": { + "type": "string", + "minLength": 1 + }, + "priceRaw": { + "$ref": "#/$defs/decimalString" + }, + "priceUnit": { + "type": "string", + "enum": [ + "perRequest", + "perMTok", + "perHour", + "perEpoch" + ] + }, + "priceAtomicUnits": { + "$ref": "#/$defs/atomicUnits" + }, + "payTo": { + "$ref": "#/$defs/evmAddress" + }, + "network": { + "type": "string", + "minLength": 1 + }, + "caip2Network": { + "type": "string", + "pattern": "^[a-z0-9]+:[0-9]+$" + }, + "chainId": { + "type": "integer", + "minimum": 1 + }, + "asset": { + "$ref": "#/$defs/asset" + } + } + }, "service": { "type": "object", "additionalProperties": false, @@ -162,6 +210,14 @@ "type": "integer", "description": "Orders offers within a category: higher sorts earlier. Mirrors spec.listing.weight." }, + "payments": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/paymentOption" + }, + "description": "Every accepted payment option (one per currency/network). payments[0] mirrors the flat price/payTo/network/asset fields." + }, "registrationPending": { "type": "boolean" }, diff --git a/internal/schemas/service_catalog.go b/internal/schemas/service_catalog.go index efb755a4..e0ebb3de 100644 --- a/internal/schemas/service_catalog.go +++ b/internal/schemas/service_catalog.go @@ -37,6 +37,12 @@ type ServiceCatalogEntry struct { // the storefront ServiceCard. Skills []string `json:"skills,omitempty"` + // Payments is every accepted payment option for this offer (one per + // currency/network). Always has at least one entry; payments[0] mirrors + // the flat Price/PayTo/Network/Asset fields above (kept for consumers + // predating multi-currency). The storefront renders one pay-row per entry. + Payments []ServiceCatalogPaymentOption `json:"payments,omitempty"` + // Category groups the offer into a named storefront section (e.g. // "demo"). Empty means the default/uncategorized section. Demo services // are just category="demo" — no special-casing. Mirrors @@ -64,6 +70,21 @@ type ServiceCatalogEntry struct { DrainEndsAt string `json:"drainEndsAt,omitempty"` } +// ServiceCatalogPaymentOption is one accepted (price, payTo, network, asset) +// combination on an offer. Same fields as the entry's flat payment block, +// repeated once per advertised currency/network. +type ServiceCatalogPaymentOption struct { + Price string `json:"price"` + PriceRaw string `json:"priceRaw,omitempty"` + PriceUnit string `json:"priceUnit,omitempty"` + PriceAtomicUnits string `json:"priceAtomicUnits,omitempty"` + PayTo string `json:"payTo"` + Network string `json:"network"` + CAIP2Network string `json:"caip2Network,omitempty"` + ChainID int64 `json:"chainId,omitempty"` + Asset *ServiceCatalogAsset `json:"asset,omitempty"` +} + // ServiceCatalogAsset describes the settlement token resolved for a catalog // entry. It mirrors the x402 asset metadata consumers need for signing. type ServiceCatalogAsset struct { diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index a68e2110..aed6b4f9 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -1170,6 +1170,10 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) } } + // Full multi-currency view (always >= 1 entry; payments[0] mirrors the + // flat fields above). The storefront renders one pay-row per option. + svc.Payments = buildCatalogPayments(offer) + services = append(services, svc) } @@ -1180,31 +1184,43 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) return string(out) } -// offerPriceRawAndUnit returns the raw decimal price string and which slot it -// occupies in the price table. Only one of perRequest / perMTok / perHour is -// expected to be set on a given offer. +// offerPriceRawAndUnit returns the raw decimal price string and slot for the +// offer's PRIMARY payment. Per-payment callers use paymentPriceRawAndUnit. func offerPriceRawAndUnit(offer *monetizeapi.ServiceOffer) (string, string) { + return paymentPriceRawAndUnit(offer.Spec.Payment) +} + +// paymentPriceRawAndUnit returns the raw decimal price string and which slot it +// occupies for a single payment option. Only one of perRequest / perMTok / +// perHour / perEpoch is expected to be set. +func paymentPriceRawAndUnit(p monetizeapi.ServiceOfferPayment) (string, string) { switch { - case offer.Spec.Payment.Price.PerRequest != "": - return offer.Spec.Payment.Price.PerRequest, "perRequest" - case offer.Spec.Payment.Price.PerMTok != "": - return offer.Spec.Payment.Price.PerMTok, "perMTok" - case offer.Spec.Payment.Price.PerHour != "": - return offer.Spec.Payment.Price.PerHour, "perHour" + case p.Price.PerRequest != "": + return p.Price.PerRequest, "perRequest" + case p.Price.PerMTok != "": + return p.Price.PerMTok, "perMTok" + case p.Price.PerHour != "": + return p.Price.PerHour, "perHour" default: return "", "" } } -// offerAssetJSON resolves the settlement asset block. If the offer carries an -// explicit asset, it is used verbatim. If only the network is set, defaults -// for USDC on that chain are filled in (this matches the verifier's behavior -// when the seller did not pass --token). +// offerAssetJSON resolves the settlement asset block for the offer's PRIMARY +// payment. Per-payment callers use paymentAssetJSON. func offerAssetJSON(offer *monetizeapi.ServiceOffer) *schemas.ServiceCatalogAsset { - a := offer.Spec.Payment.Asset + return paymentAssetJSON(offer.Spec.Payment) +} + +// paymentAssetJSON resolves the settlement asset block for a single payment +// option. If the option carries an explicit asset it is used verbatim; if only +// the network is set, defaults for USDC on that chain are filled in (matching +// the verifier's behavior when the seller did not pass --token). +func paymentAssetJSON(p monetizeapi.ServiceOfferPayment) *schemas.ServiceCatalogAsset { + a := p.Asset if a.Address == "" && a.Symbol == "" && a.EIP712Name == "" { // No explicit asset — fall back to the chain's default USDC entry. - if def, ok := defaultUSDCForNetwork(offer.Spec.Payment.Network); ok { + if def, ok := defaultUSDCForNetwork(p.Network); ok { return &def } return nil @@ -1218,7 +1234,7 @@ func offerAssetJSON(offer *monetizeapi.ServiceOffer) *schemas.ServiceCatalogAsse if a.EIP712Name != "" || a.EIP712Version != "" { out.EIP712Domain = &schemas.ServiceCatalogEIP712Domain{Name: a.EIP712Name, Version: a.EIP712Version} } - if def, ok := defaultUSDCForNetwork(offer.Spec.Payment.Network); ok { + if def, ok := defaultUSDCForNetwork(p.Network); ok { // Backfill any unset fields from chain defaults so consumers always // see a complete asset block when the network is known. if out.Address == "" { @@ -1339,14 +1355,19 @@ func decimalToAtomicString(amount string, decimals int) string { } func describeOfferPrice(offer *monetizeapi.ServiceOffer) string { - // Source the symbol from (in order): explicit asset metadata on the offer, - // the resolved chain-default settlement asset, hard-coded "USDC" only as - // the last-resort fallback for unknown chains. Mislabeling OBOL-priced - // services as "USDC" on the discovery surfaces (storefront / skill.md) - // caused buyers to queue up the wrong asset on rc7-rc9. - symbol := offer.Spec.Payment.Asset.Symbol + return describePaymentPrice(offer.Spec.Payment) +} + +// describePaymentPrice renders a single payment option as " /". +func describePaymentPrice(p monetizeapi.ServiceOfferPayment) string { + // Source the symbol from (in order): explicit asset metadata on the + // option, the resolved chain-default settlement asset, hard-coded "USDC" + // only as the last-resort fallback for unknown chains. Mislabeling + // OBOL-priced services as "USDC" on the discovery surfaces (storefront / + // skill.md) caused buyers to queue up the wrong asset on rc7-rc9. + symbol := p.Asset.Symbol if symbol == "" { - if a := offerAssetJSON(offer); a != nil && a.Symbol != "" { + if a := paymentAssetJSON(p); a != nil && a.Symbol != "" { symbol = a.Symbol } } @@ -1354,17 +1375,43 @@ func describeOfferPrice(offer *monetizeapi.ServiceOffer) string { symbol = "USDC" } switch { - case offer.Spec.Payment.Price.PerRequest != "": - return offer.Spec.Payment.Price.PerRequest + " " + symbol + "/request" - case offer.Spec.Payment.Price.PerMTok != "": - return offer.Spec.Payment.Price.PerMTok + " " + symbol + "/MTok" - case offer.Spec.Payment.Price.PerHour != "": - return offer.Spec.Payment.Price.PerHour + " " + symbol + "/hour" + case p.Price.PerRequest != "": + return p.Price.PerRequest + " " + symbol + "/request" + case p.Price.PerMTok != "": + return p.Price.PerMTok + " " + symbol + "/MTok" + case p.Price.PerHour != "": + return p.Price.PerHour + " " + symbol + "/hour" default: return "—" } } +// buildCatalogPayments renders every accepted payment option of an offer into +// catalog payment entries (one per currency/network). payments[0] is the +// primary and mirrors the entry's flat fields. +func buildCatalogPayments(offer *monetizeapi.ServiceOffer) []schemas.ServiceCatalogPaymentOption { + payments := offer.EffectivePayments() + out := make([]schemas.ServiceCatalogPaymentOption, 0, len(payments)) + for i := range payments { + p := payments[i] + opt := schemas.ServiceCatalogPaymentOption{ + Price: describePaymentPrice(p), + PayTo: p.PayTo, + Network: p.Network, + } + opt.PriceRaw, opt.PriceUnit = paymentPriceRawAndUnit(p) + opt.CAIP2Network, opt.ChainID = caip2ForNetwork(p.Network) + if asset := paymentAssetJSON(p); asset != nil { + opt.Asset = asset + if opt.PriceRaw != "" && asset.Decimals > 0 { + opt.PriceAtomicUnits = decimalToAtomicString(opt.PriceRaw, int(asset.Decimals)) + } + } + out = append(out, opt) + } + return out +} + func marshalRegistrationDocument(document erc8004.AgentRegistration) (string, string, error) { data, err := json.MarshalIndent(document, "", " ") if err != nil { diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index 4428f066..fcce6980 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -784,6 +784,57 @@ func TestBuildServiceCatalogJSON(t *testing.T) { if svc.Description != "Proof-of-payment echo service" { t.Errorf("description = %q, want 'Proof-of-payment echo service'", svc.Description) } + // Single-payment offers still expose payments[] (one entry mirroring flat). + if len(svc.Payments) != 1 || svc.Payments[0].Network != "base" { + t.Errorf("single-payment offer payments = %+v, want one base entry", svc.Payments) + } +} + +func TestBuildServiceCatalogJSON_MultiPayment(t *testing.T) { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "bankr", Namespace: "agent-bankr"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "agent", + Payment: monetizeapi.ServiceOfferPayment{Network: "base", PayTo: "0x1111111111111111111111111111111111111111", Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "1"}}, + Payments: []monetizeapi.ServiceOfferPayment{ + {Network: "base", PayTo: "0x1111111111111111111111111111111111111111", Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "1"}}, + { + Network: "ethereum", PayTo: "0x2222222222222222222222222222222222222222", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "10"}, + Asset: monetizeapi.ServiceOfferAsset{Symbol: "OBOL", Address: "0x0B010000b7624eb9B3DfBC279673C76E9D29D5F7", Decimals: 18, TransferMethod: "permit2", EIP712Name: "Obol Network", EIP712Version: "1"}, + }, + }, + Registration: monetizeapi.ServiceOfferRegistration{Description: "multi-currency agent"}, + }, + Status: monetizeapi.ServiceOfferStatus{Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "True"}}}, + } + + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + assertServiceCatalogSchema(t, jsonStr) + + var services []schemas.ServiceCatalogEntry + if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(services) != 1 { + t.Fatalf("want 1 service, got %d", len(services)) + } + pays := services[0].Payments + if len(pays) != 2 { + t.Fatalf("payments = %d, want 2", len(pays)) + } + // Flat fields mirror the primary (first) option. + if services[0].Network != "base" || services[0].PayTo != "0x1111111111111111111111111111111111111111" { + t.Errorf("flat fields should mirror primary option: %+v", services[0]) + } + // Second option resolves OBOL on ethereum with its own atomic price. + obol := pays[1] + if obol.Network != "ethereum" || obol.Asset == nil || obol.Asset.Symbol != "OBOL" { + t.Fatalf("second option = %+v, want OBOL on ethereum", obol) + } + if obol.PriceAtomicUnits != "10000000000000000000" { // 10 * 1e18 + t.Errorf("OBOL atomic price = %q, want 10e18", obol.PriceAtomicUnits) + } } func TestBuildServiceCatalogJSON_Empty(t *testing.T) { diff --git a/web/public-storefront/src/components/ServiceCard.tsx b/web/public-storefront/src/components/ServiceCard.tsx index 4e866518..6d165d89 100644 --- a/web/public-storefront/src/components/ServiceCard.tsx +++ b/web/public-storefront/src/components/ServiceCard.tsx @@ -1,7 +1,27 @@ "use client"; import { useState } from "react"; -import type { Service } from "@/types"; +import type { Service, ServicePayment } from "@/types"; + +// paymentOptions returns the service's accepted payment options, falling back +// to the flat fields for catalogs predating multi-currency. +function paymentOptions(service: Service): ServicePayment[] { + if (service.payments && service.payments.length > 0) return service.payments; + return [ + { + price: service.price, + priceRaw: service.priceRaw, + payTo: service.payTo, + network: service.network, + asset: service.asset, + }, + ]; +} + +function optionLabel(opt: ServicePayment): string { + const sym = opt.asset?.symbol ?? "USDC"; + return `${opt.price.replace(/\s.*/, "")} ${sym} · ${opt.network}`; +} const typeColors: Record = { inference: "bg-obol-green/15 text-obol-green border border-obol-green/30", @@ -24,15 +44,41 @@ type Tab = "agent" | "other-ai" | "code"; export function ServiceCard({ service }: { service: Service }) { const [open, setOpen] = useState(false); const [tab, setTab] = useState("agent"); + const [copied, setCopied] = useState(false); + + const options = paymentOptions(service); + const [optIdx, setOptIdx] = useState(0); + const opt = options[optIdx] ?? options[0]; + const multiPay = options.length > 1; + + const anchorId = `service-${service.name}`; + const copyAnchor = () => { + const url = `${window.location.origin}${window.location.pathname}#${anchorId}`; + navigator.clipboard.writeText(url); + window.location.hash = anchorId; + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; return ( -
+

{service.name}

+ {service.category && ( {service.category} @@ -61,14 +107,32 @@ export function ServiceCard({ service }: { service: Service }) {
-
- Price -

{service.price}

-
-
- Network -

{service.network}

-
+ {multiPay ? ( +
+ + Pay with {options.length} options + +
    + {options.map((o, i) => ( +
  • + {optionLabel(o)} + {i === optIdx && open ? " ←" : ""} +
  • + ))} +
+
+ ) : ( + <> +
+ Price +

{opt.price}

+
+
+ Network +

{opt.network}

+
+ + )} {service.model && (
Model @@ -95,11 +159,32 @@ export function ServiceCard({ service }: { service: Service }) { {open && (
+ {multiPay && ( +
+

Pay with

+
+ {options.map((o, i) => ( + + ))} +
+
+ )} + - {tab === "agent" && } - {tab === "other-ai" && } - {tab === "code" && } + {tab === "agent" && } + {tab === "other-ai" && } + {tab === "code" && }
)}
@@ -139,7 +224,7 @@ function TabBar({ tab, onChange }: { tab: Tab; onChange: (t: Tab) => void }) { // `pay` against chat-completions for agents, `obol buy inference` CLI // for inference). Mirrors inferenceCopy/agentCopy/httpCopy in // internal/x402/paymentrequired.go. -function BuyViaObolAgent({ service }: { service: Service }) { +function BuyViaObolAgent({ service, opt }: { service: Service; opt: ServicePayment }) { const kind = normalizeOfferType(service.type); if (kind === "inference") { @@ -179,7 +264,7 @@ function BuyViaObolAgent({ service }: { service: Service }) { } // http (default): legacy single-shot pay. - const prompt = `Use the buy-x402 skill's \`pay\` command to call ${service.endpoint} once. Pay ${service.price} on ${service.network}. Report what it returns.`; + const prompt = `Use the buy-x402 skill's \`pay\` command to call ${service.endpoint} once. Pay ${opt.price} on ${opt.network}. Report what it returns.`; return (

@@ -192,7 +277,7 @@ function BuyViaObolAgent({ service }: { service: Service }) { ); } -function BuyViaOtherAgent({ service }: { service: Service }) { +function BuyViaOtherAgent({ service, opt }: { service: Service; opt: ServicePayment }) { const kind = normalizeOfferType(service.type); let prompt: string; @@ -203,7 +288,7 @@ function BuyViaOtherAgent({ service }: { service: Service }) { const modelLine = service.model ? ` (running ${service.model})` : ""; prompt = `Read https://obol.org/llms.txt to learn how Obol's x402 micropayments work. Help me call the Obol Agent at ${service.endpoint}${modelLine} — it's an autonomous agent (tools + skills + memory), not a raw LLM. POST OpenAI-style chat-completions JSON with a real prompt in \`messages\`, attach a signed EIP-3009 or Permit2 authorisation as \`X-PAYMENT\`, and report what the agent does.`; } else { - 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.`; + prompt = `I want to purchase a service offered by an Obol Agent at ${service.endpoint} for ${opt.price} on ${opt.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 ( @@ -226,7 +311,7 @@ function BuyViaOtherAgent({ service }: { service: Service }) { ); } -function BuyWithCode({ service }: { service: Service }) { +function BuyWithCode({ service, opt }: { service: Service; opt: ServicePayment }) { const kind = normalizeOfferType(service.type); return (

@@ -251,7 +336,7 @@ function BuyWithCode({ service }: { service: Service }) {

2. Pay for the service

- +
{kind === "agent" && ( @@ -300,16 +385,16 @@ ${service.model ? ` "model": "${service.model}",\n` : ""} "messages": [ ); } -function LanguageTabs({ service }: { service: Service }) { +function LanguageTabs({ service, opt }: { service: Service; opt: ServicePayment }) { // Layout reserves a language selector slot for future JS/TS additions — // Python is the only currently-supported snippet. const [lang] = useState<"python">("python"); - // Prefer the resolved asset symbol from the catalog. The previous - // network-based heuristic mislabeled OBOL on base-sepolia as USDC and - // any non-mainnet USDC deployment as OBOL. + // Prefer the resolved asset symbol from the selected payment option. The + // previous network-based heuristic mislabeled OBOL on base-sepolia as USDC + // and any non-mainnet USDC deployment as OBOL. const tokenName = - service.asset?.symbol ?? (service.network === "ethereum" ? "OBOL" : "USDC"); + opt.asset?.symbol ?? (opt.network === "ethereum" ? "OBOL" : "USDC"); const python = `import httpx from x402.client import x402_client diff --git a/web/public-storefront/src/types.ts b/web/public-storefront/src/types.ts index 689c4d29..452529a8 100644 --- a/web/public-storefront/src/types.ts +++ b/web/public-storefront/src/types.ts @@ -5,6 +5,18 @@ export interface ServiceAsset { transferMethod?: string; } +// ServicePayment is one accepted (price, payTo, network, asset) option. +// Mirrors the catalog's ServiceCatalogPaymentOption. A service advertising +// several is paid in any one of them — the buyer picks. +export interface ServicePayment { + price: string; + priceRaw?: string; + priceUnit?: string; + payTo: string; + network: string; + asset?: ServiceAsset; +} + export interface Service { name: string; namespace: string; @@ -28,6 +40,10 @@ export interface Service { // offers it mirrors spec.registration.skills. Rendered as pills on // the ServiceCard, matching the 402 page. skills?: string[]; + // payments is every accepted payment option (one per currency/network). + // payments[0] mirrors the flat price/network/payTo/asset fields. Absent on + // catalogs predating multi-currency — fall back to the flat fields. + payments?: ServicePayment[]; // category groups the service into a storefront section (e.g. "demo"). // Absent/empty means the default section. Mirrors spec.listing.category — // demo services are just category="demo", not a special case. From 570aced0939719da310b4f7140f03b0edd465ba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Fri, 19 Jun 2026 13:19:19 +0100 Subject: [PATCH 4/5] feat(skills): buy-x402 skill supports multi currency --- cmd/obol/agent_crd.go | 10 +- cmd/obol/sell.go | 105 +++++++++-- cmd/obol/sell_test.go | 22 +++ internal/embed/skills/agent-factory/SKILL.md | 11 +- .../skills/agent-factory/scripts/factory.py | 160 ++++++++++++++-- internal/embed/skills/buy-x402/SKILL.md | 2 + internal/embed/skills/buy-x402/scripts/buy.py | 176 +++++++++++++----- 7 files changed, 405 insertions(+), 81 deletions(-) diff --git a/cmd/obol/agent_crd.go b/cmd/obol/agent_crd.go index 3f977735..14b4f38a 100644 --- a/cmd/obol/agent_crd.go +++ b/cmd/obol/agent_crd.go @@ -105,10 +105,12 @@ func createCRDAgent(cfg *config.Config, u *ui.UI, opts createCRDAgentOptions) er objective = strings.TrimSpace(promptOrDefault(u, "Objective", "You are a focused sub-agent. Answer the user's task within scope; refuse anything outside your skills.")) } - // Wallet defaults to true on the prompt because most sub-agents - // will want one; the operator can decline. - ans := strings.TrimSpace(promptOrDefault(u, "Provision a wallet for this agent? [Y/n]", "Y")) - createWallet = !strings.EqualFold(ans, "n") && !strings.EqualFold(ans, "no") + // Wallet defaults to OFF: most sub-agents don't need their own + // keystore + remote-signer — sale revenue routes to the master + // Hermes wallet by default (see `obol sell agent` payTo fallback). + // Opt in only when the sub-agent genuinely needs to hold/sign funds. + ans := strings.TrimSpace(promptOrDefault(u, "Provision a dedicated wallet for this agent? [y/N]", "N")) + createWallet = strings.EqualFold(ans, "y") || strings.EqualFold(ans, "yes") } // Fail fast on a pinned model LiteLLM doesn't serve. Without this the Agent diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 85916d4f..8d6a8f3c 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -1130,6 +1130,28 @@ func buildSellUpdatePatch(payTo, chain string, price schemas.PriceTable) (map[st }, nil } +// buildSellAcceptUpdatePatch returns a merge patch that REPLACES an offer's +// payment set with the given options. spec.payments is swapped wholesale; the +// singular spec.payment is reset to the new primary (payments[0]) so discovery +// surfaces that read it stay consistent. When the new primary is USDC (no +// explicit asset), the old asset block is nulled out so a previous non-USDC +// asset doesn't survive the merge. +func buildSellAcceptUpdatePatch(payments []map[string]any) map[string]any { + primary := map[string]any{} + for k, v := range payments[0] { + primary[k] = v + } + if _, hasAsset := primary["asset"]; !hasAsset { + primary["asset"] = nil + } + return map[string]any{ + "spec": map[string]any{ + "payments": payments, + "payment": primary, + }, + } +} + // shouldAutoRegisterSell reports whether the post-create auto-register step // must run for a freshly-applied ServiceOffer spec. Both `obol sell http` and // `obol sell inference` need the same gate: registration must be enabled AND @@ -1353,15 +1375,35 @@ func serviceOfferStatusLines(namespace, name string, offer monetizeapi.ServiceOf lines := []string{ fmt.Sprintf("ServiceOffer: %s/%s", namespace, name), fmt.Sprintf("Endpoint: %s", endpoint), - fmt.Sprintf("Network: %s", valueOrNone(offer.Spec.Payment.Network)), - fmt.Sprintf("Asset: %s", formatOfferAsset(offer.Spec.Payment.Asset)), - fmt.Sprintf("Price: %s", formatOfferPrice(offer.Spec.Payment)), - fmt.Sprintf("Pay To: %s", valueOrNone(offer.Spec.Payment.PayTo)), + } + // Show each accepted payment option. Single-payment offers keep the flat + // Network/Asset/Price/Pay To lines; multi-currency offers list every option. + payments := offer.EffectivePayments() + if len(payments) <= 1 { + p := offer.Spec.Payment + lines = append(lines, + fmt.Sprintf("Network: %s", valueOrNone(p.Network)), + fmt.Sprintf("Asset: %s", formatOfferAsset(p.Asset)), + fmt.Sprintf("Price: %s", formatOfferPrice(p)), + fmt.Sprintf("Pay To: %s", valueOrNone(p.PayTo)), + ) + } else { + lines = append(lines, fmt.Sprintf("Payments: %d accepted options", len(payments))) + for i := range payments { + p := payments[i] + lines = append(lines, + fmt.Sprintf(" - %s on %s → %s", + formatOfferPrice(p), valueOrNone(p.Network), valueOrNone(p.PayTo)), + fmt.Sprintf(" asset: %s", formatOfferAsset(p.Asset)), + ) + } + } + lines = append(lines, fmt.Sprintf("Agent ID: %s", agentID), fmt.Sprintf("Registration Tx: %s", tx), "", "Conditions:", - } + ) for _, cond := range offer.Status.Conditions { lines = append(lines, formatConditionLine(cond)) } @@ -2669,8 +2711,9 @@ so the controller picks up the new model. Examples: obol sell update my-api -n llm --per-request 0.002 obol sell update my-api -n llm --per-mtok 5.0 - obol sell update my-api -n llm --wallet 0xNew... --chain base`, - Flags: []cli.Flag{ + obol sell update my-api -n llm --wallet 0xNew... --chain base + obol sell update my-api -n llm --accept token=USDC,network=base,price=1 --accept token=OBOL,network=ethereum,price=10`, + Flags: append([]cli.Flag{ &cli.StringFlag{ Name: "namespace", Aliases: []string{"n"}, @@ -2698,11 +2741,11 @@ Examples: Name: "per-hour", Usage: "New per-compute-hour price in the selected payment token", }, - }, + }, acceptFlags()...), Action: func(ctx context.Context, cmd *cli.Command) error { u := getUI(cmd) if cmd.NArg() == 0 { - return errors.New("name required: obol sell update -n [--per-request N | --per-mtok N | --per-hour N] [--pay-to 0x...] [--chain base]") + return errors.New("name required: obol sell update -n [--per-request N | --per-mtok N | --per-hour N | --accept ...] [--pay-to 0x...] [--chain base]") } name := cmd.Args().First() @@ -2722,18 +2765,48 @@ Examples: } } - var price schemas.PriceTable - if cmd.String("price") != "" || cmd.String("per-request") != "" || cmd.String("per-mtok") != "" || cmd.String("per-hour") != "" { - resolved, err := resolvePriceTable(cmd, true) + var patch map[string]any + if accepts := cmd.StringSlice("accept"); len(accepts) > 0 { + // --accept REPLACES the whole payment set (predictable: no + // partial merge across options). spec.payments is a list, so a + // merge patch swaps it wholesale; spec.payment is reset to the + // new primary (asset cleared when the new primary is USDC). + payments, err := buildAcceptPayments(accepts, wallet) if err != nil { return err } - price = resolved + if err := autofillAcceptPayments(ctx, payments, func(ctx context.Context, network, addr string) (tokenMeta, error) { + return fetchTokenMeta(ctx, cfg, network, addr) + }); err != nil { + return err + } + patch = buildSellAcceptUpdatePatch(payments) + } else { + var price schemas.PriceTable + if cmd.String("price") != "" || cmd.String("per-request") != "" || cmd.String("per-mtok") != "" || cmd.String("per-hour") != "" { + resolved, err := resolvePriceTable(cmd, true) + if err != nil { + return err + } + price = resolved + } + p, err := buildSellUpdatePatch(wallet, cmd.String("chain"), price) + // Allow a listing-only update (weight/category) with no payment + // change: swallow the "nothing to update" error when listing + // flags are present, and start from an empty spec patch. + if err != nil { + if cmd.Int("weight") != 0 || strings.TrimSpace(cmd.String("category")) != "" { + p = map[string]any{"spec": map[string]any{}} + } else { + return err + } + } + patch = p } - patch, err := buildSellUpdatePatch(wallet, cmd.String("chain"), price) - if err != nil { - return err + // Listing flags (weight/category) layer onto whichever patch we built. + if spec, ok := patch["spec"].(map[string]any); ok { + applyListingFlags(cmd, spec) } patchBytes, err := json.Marshal(patch) if err != nil { diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index c9a14cd3..d9069170 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -375,6 +375,28 @@ func TestServiceOfferStatusLines(t *testing.T) { } } +func TestServiceOfferStatusLines_MultiPayment(t *testing.T) { + offer := monetizeapi.ServiceOffer{ + Spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{Network: "base", PayTo: "0xAAA", Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "1"}}, + Payments: []monetizeapi.ServiceOfferPayment{ + {Network: "base", PayTo: "0xAAA", Asset: monetizeapi.ServiceOfferAsset{Symbol: "USDC"}, Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "1"}}, + {Network: "ethereum", PayTo: "0xBBB", Asset: monetizeapi.ServiceOfferAsset{Symbol: "OBOL"}, Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "10"}}, + }, + }, + } + joined := strings.Join(serviceOfferStatusLines("agent-x", "x", offer, ""), "\n") + for _, want := range []string{ + "Payments: 2 accepted options", + "1 USDC per request on base → 0xAAA", + "10 OBOL per request on ethereum → 0xBBB", + } { + if !strings.Contains(joined, want) { + t.Fatalf("multi-payment status missing %q\n%s", want, joined) + } + } +} + func TestServiceOfferStatusLines_RawTxFallback(t *testing.T) { // Unknown network: fall back to raw hash (no explorer link). offer := monetizeapi.ServiceOffer{ diff --git a/internal/embed/skills/agent-factory/SKILL.md b/internal/embed/skills/agent-factory/SKILL.md index c59d0c96..b23cec24 100644 --- a/internal/embed/skills/agent-factory/SKILL.md +++ b/internal/embed/skills/agent-factory/SKILL.md @@ -39,8 +39,15 @@ python3 scripts/factory.py create bankr-analyst \ --description "Paid token-analysis agent" ``` -Each `--accept` may carry its own `pay-to` (else it inherits `--pay-to`). -ERC-8004 registration uses the first option's network. +Each `--accept` may carry its own `pay-to`; otherwise it inherits `--pay-to`, +which **defaults to the master Hermes agent's own wallet** — so a sub-agent +needn't provision its own wallet just to take payment (pass `--create-wallet` +only when it genuinely needs to hold/sign funds). For an unlisted token the +`asset=0x..` escape hatch needs only the address: `decimals`, `symbol`, and the +EIP-712 domain (`eip712-name`/`eip712-version`) are read from the chain +(EIP-5267) when omitted, and `transfer` defaults to `permit2`; the factory +errors and asks you to specify them if they can't be read. ERC-8004 +registration uses the first option's network. ## Commands diff --git a/internal/embed/skills/agent-factory/scripts/factory.py b/internal/embed/skills/agent-factory/scripts/factory.py index 1cbee9a8..20879438 100644 --- a/internal/embed/skills/agent-factory/scripts/factory.py +++ b/internal/embed/skills/agent-factory/scripts/factory.py @@ -451,16 +451,21 @@ def parse_accept_option(raw, default_pay_to): if raw_addr: if not ADDR_RE.match(raw_addr): raise ValueError(f"--accept {raw!r}: asset must be a 0x ERC-20 address (got {raw_addr!r})") - if not (kv.get("decimals") or "").isdigit() or not (0 < int(kv["decimals"]) <= 255): - raise ValueError(f"--accept {raw!r}: raw asset needs decimals=<1-255>") - transfer = (kv.get("transfer") or "").lower() + # transfer defaults to permit2 (EIP-3009 is effectively USDC-only). + # decimals/symbol/eip712-* are optional here and filled best-effort + # from the chain by autofill_accept_payments, which errors if they + # still can't be resolved. + transfer = (kv.get("transfer") or "permit2").lower() if transfer not in ("eip3009", "permit2"): - raise ValueError(f"--accept {raw!r}: raw asset needs transfer=eip3009|permit2") - symbol, name, version = kv.get("symbol", ""), kv.get("eip712-name", ""), kv.get("eip712-version", "") - if not (symbol and name and version): - raise ValueError(f"--accept {raw!r}: raw asset needs symbol, eip712-name and eip712-version (the token's EIP-712 signing domain)") - payment["asset"] = {"address": raw_addr, "symbol": symbol, "decimals": int(kv["decimals"]), - "transferMethod": transfer, "eip712Name": name, "eip712Version": version} + raise ValueError(f"--accept {raw!r}: transfer must be eip3009 or permit2") + dec = 0 + if (kv.get("decimals") or "").strip(): + if not kv["decimals"].isdigit() or not (0 < int(kv["decimals"]) <= 255): + raise ValueError(f"--accept {raw!r}: decimals must be 1-255") + dec = int(kv["decimals"]) + payment["asset"] = {"address": raw_addr, "symbol": kv.get("symbol", ""), "decimals": dec, + "transferMethod": transfer, "eip712Name": kv.get("eip712-name", ""), + "eip712Version": kv.get("eip712-version", "")} dedup = f"{chain}\x00{raw_addr.lower()}" elif token_sym and token_sym.upper() != "USDC": entry = ASSET_REGISTRY.get(token_sym.upper(), {}).get(chain) @@ -493,6 +498,121 @@ def build_accept_payments(accepts, default_pay_to): return payments +def resolve_master_wallet(): + """Best-effort: the master Hermes agent's own wallet (remote-signer key 0). + Used as the default payTo so sub-agents needn't provision their own wallet + + remote-signer. Returns "" when no signer/key is reachable, in which case + the caller falls back to requiring an explicit --pay-to.""" + base = os.environ.get("REMOTE_SIGNER_URL", "http://remote-signer:9000").rstrip("/") + headers = {} + tok = os.environ.get("REMOTE_SIGNER_TOKEN", "").strip() + if tok: + headers["Authorization"] = f"Bearer {tok}" + try: + req = urllib.request.Request(f"{base}/api/v1/keys", headers=headers) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + except Exception: + return "" + for addr in (data.get("keys") if isinstance(data, dict) else data) or []: + if isinstance(addr, str) and ADDR_RE.match(addr.strip()): + return addr.strip() + return "" + + +# In-pod eRPC for best-effort token-metadata reads. Mirrors the obol CLI's +# tokenmeta.go but over the in-cluster eRPC the agent pods already use. +ERPC_BASE = os.environ.get("ERPC_URL", "http://erpc.erpc.svc.cluster.local/rpc").rstrip("/") +# Function selectors: decimals(), symbol(), eip712Domain() (EIP-5267). +SEL_DECIMALS = "313ce567" +SEL_SYMBOL = "95d89b41" +SEL_EIP712DOMAIN = "84b0196e" + + +def _erpc_eth_call(network, to, selector): + """eth_call(to, selector) via eRPC. Returns 0x-hex result or "" on failure.""" + alias = CAIP2_TO_CHAIN.get(network, network) + alias = "mainnet" if alias == "ethereum" else alias + url = f"{ERPC_BASE}/{alias}" + payload = json.dumps({"jsonrpc": "2.0", "method": "eth_call", + "params": [{"to": to, "data": "0x" + selector}, "latest"], "id": 1}).encode() + try: + req = urllib.request.Request(url, data=payload, method="POST", + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=15) as resp: + out = json.loads(resp.read()) + if "error" in out: + return "" + return out.get("result") or "" + except Exception: + return "" + + +def _abi_string_at(hexstr, byte_offset): + """Decode an ABI string located at byte_offset within hexstr (no 0x).""" + pos = byte_offset * 2 + if pos + 64 > len(hexstr): + return "" + length = int(hexstr[pos:pos + 64], 16) + start = pos + 64 + raw = hexstr[start:start + length * 2] + try: + return bytes.fromhex(raw).decode("utf-8", "replace").strip() + except ValueError: + return "" + + +def fetch_token_meta(network, addr): + """Best-effort decimals/symbol/eip712 (name,version) from chain. Each field + is independent; unreadable ones come back empty/zero.""" + meta = {"decimals": 0, "symbol": "", "eip712Name": "", "eip712Version": ""} + dec = _erpc_eth_call(network, addr, SEL_DECIMALS) + if dec and dec != "0x": + try: + meta["decimals"] = int(dec, 16) + except ValueError: + pass + sym = _erpc_eth_call(network, addr, SEL_SYMBOL) + if sym and sym != "0x": + h = sym[2:] + if len(h) >= 64: + meta["symbol"] = _abi_string_at(h, int(h[0:64], 16)) + dom = _erpc_eth_call(network, addr, SEL_EIP712DOMAIN) + if dom and dom != "0x": + h = dom[2:] + # head: fields(0), name@word1, version@word2, ... + if len(h) >= 192: + meta["eip712Name"] = _abi_string_at(h, int(h[64:128], 16)) + meta["eip712Version"] = _abi_string_at(h, int(h[128:192], 16)) + return meta + + +def autofill_accept_payments(payments, fetch=fetch_token_meta): + """Fill missing raw-asset metadata from the chain (no-op for registry/USDC). + Errors when the signature-critical fields can't be resolved — never ships a + guess that would break settlement.""" + for p in payments: + a = p.get("asset") + if not a: + continue # USDC chain-default + if a.get("decimals") and a.get("eip712Name") and a.get("eip712Version"): + continue # registry token or fully-specified raw asset + meta = fetch(p.get("network", ""), a["address"]) + a["decimals"] = a.get("decimals") or meta.get("decimals", 0) + a["symbol"] = a.get("symbol") or meta.get("symbol", "") + a["eip712Name"] = a.get("eip712Name") or meta.get("eip712Name", "") + a["eip712Version"] = a.get("eip712Version") or meta.get("eip712Version", "") + missing = [label for key, label in + (("decimals", "decimals"), ("eip712Name", "eip712-name"), ("eip712Version", "eip712-version")) + if not a.get(key)] + if missing: + raise ValueError( + f"token {a['address']} on {p.get('network')}: could not read {', '.join(missing)} " + f"from the chain (token may not implement EIP-5267) — specify them in --accept") + if not a.get("symbol"): + a["symbol"] = "TOKEN" + + def _payment_symbol(payment): asset = payment.get("asset") or {} return asset.get("symbol") or "USDC" @@ -508,7 +628,11 @@ def _payment_price(payment): def serviceoffer_resource(args, parent_ns): accepts = getattr(args, "accept", None) or [] if accepts: - payments = build_accept_payments(accepts, args.pay_to) + # Reuse the payments built (and autofilled) in cmd_create when present, + # so the resolved on-chain metadata isn't discarded by a rebuild. + payments = getattr(args, "_payments", None) + if payments is None: + payments = build_accept_payments(accepts, args.pay_to) payment = payments[0] else: payment = { @@ -610,14 +734,22 @@ def cmd_create(args, token, parent_ns, ssl_ctx): raise ValueError("--path must start with /") if args.max_timeout <= 0: raise ValueError("--max-timeout must be greater than zero") + # Default the recipient to the master Hermes agent's own wallet so a + # sub-agent needn't provision its own wallet + remote-signer just to sell. + if not args.pay_to: + args.pay_to = resolve_master_wallet() + accepts = args.accept or [] + args._payments = None if accepts: - # build_accept_payments validates each option (network, price, asset, - # pay-to); fail fast here so we don't create the Agent then choke. - build_accept_payments(accepts, args.pay_to) + # Build once here (fail fast before the Agent is created) and reuse in + # serviceoffer_resource. Autofill reads raw-asset metadata from chain. + payments = build_accept_payments(accepts, args.pay_to) + autofill_accept_payments(payments) + args._payments = payments else: if args.price and not args.pay_to: - raise ValueError("--pay-to is required when --price is set") + raise ValueError("--pay-to is required when --price is set (or provision the master wallet)") if args.price: validate_positive_decimal(args.price, "--price") if args.pay_to and not ADDR_RE.match(args.pay_to): diff --git a/internal/embed/skills/buy-x402/SKILL.md b/internal/embed/skills/buy-x402/SKILL.md index ca4e9c5c..a3e02a5d 100644 --- a/internal/embed/skills/buy-x402/SKILL.md +++ b/internal/embed/skills/buy-x402/SKILL.md @@ -15,6 +15,8 @@ Purchase access to remote x402-gated services. There are two flows, picked by us Both flows auto-detect the token + transfer method from the seller's 402 response. Currently supported: **USDC via EIP-3009** (Base Sepolia, Base Mainnet, Ethereum Mainnet) and **OBOL via Permit2** (Ethereum Mainnet). +**Multi-currency offers (pick what you pay with).** A seller can advertise several payment options (e.g. *1 USDC on Base* OR *10 OBOL on Ethereum*) in the 402 `accepts[]` array. `probe` lists them all. For `pay`, `pay-agent`, and `buy`, choose one with `--token ` (e.g. `--token OBOL`), `--network `, and/or `--payment-option ` (the 1-based index from `probe`). With a single option the choice is automatic; with several and no selector, the command errors and lists the options (or, on a TTY, prompts). `--token`/`--network` also act as a guard — if the filter matches no advertised option, it aborts rather than paying the wrong asset. + **Auth expiry (`OBOL_X402_AUTH_TTL` / `--auth-ttl`).** A pre-signed pool is spent over time, so each auth carries a *spendability* deadline — distinct from the per-request settle window (`maxTimeoutSeconds`). One knob controls **both** payment methods (Permit2 `deadline` and ERC-3009 `validBefore`): default **30 days (1 month)**; pass a number of seconds (floored at 600s = the verifier's default settle window, so an auth cannot expire between request acceptance and settlement); or pass **`never`** (also `0`/`none`) for a non-expiring pool (mapped to the uint sentinel `4294967295`, ~year 2106, which both contracts accept). Set per-buy with `--auth-ttl ` or globally via the `OBOL_X402_AUTH_TTL` env. A too-short value silently expires the pool minutes after buy. Chain names follow the eRPC project aliases: `mainnet`, `base`, `base-sepolia`. CAIP-2 strings (`eip155:1`, `eip155:8453`, `eip155:84532`) and the alias `ethereum` are accepted on input and normalized internally. Unknown chains fail loudly with the supported list — buy.py will not silently sign against base-sepolia when the seller is on mainnet. diff --git a/internal/embed/skills/buy-x402/scripts/buy.py b/internal/embed/skills/buy-x402/scripts/buy.py index 53c5ae0a..603f05f7 100644 --- a/internal/embed/skills/buy-x402/scripts/buy.py +++ b/internal/embed/skills/buy-x402/scripts/buy.py @@ -1477,6 +1477,110 @@ def _probe_endpoint(endpoint_url, model_id="test", kind="inference", method=None return None +# --------------------------------------------------------------------------- +# Payment-option selection (multi-currency offers) +# --------------------------------------------------------------------------- + +def _option_symbol(acc): + """Best-effort token symbol for one 402 accepts[] entry (e.g. USDC, OBOL).""" + sym, _, _ = _asset_display_meta(acc.get("asset"), acc.get("extra")) + return sym + + +def _option_chain(acc): + """Canonical chain name for one accepts[] entry, or the raw value.""" + try: + return _normalize_chain_name(acc.get("network")) + except ValueError: + return (acc.get("network") or "").strip() + + +def _option_matches(acc, token, network): + if token and _option_symbol(acc).upper() != token.strip().upper(): + return False + if network: + try: + want = _resolve_chain(network) + except ValueError: + want = network.strip() + if _option_chain(acc) != want: + return False + return True + + +def _print_options(accepts, stream): + for i, acc in enumerate(accepts): + amount = acc.get("amount", acc.get("maxAmountRequired", "?")) + price = _format_amount(amount, acc.get("asset"), acc.get("extra")) if amount != "?" else "?" + print(f" [{i + 1}] {price} on {_option_chain(acc)} (payTo {acc.get('payTo', '?')})", file=stream) + + +def _filter_desc(token, network): + bits = [] + if token: + bits.append(f"token={token}") + if network: + bits.append(f"network={network}") + return " ".join(bits) or "the given filter" + + +def _select_payment(accepts, token=None, network=None, index=None): + """Choose one payment option from a 402 accepts[] list. + + Selection precedence: + - --payment-option : pick that 1-based entry. + - --token / --network: filter; must resolve to exactly one (applied even + for a single-option offer, so it doubles as a safety guard). + - single option: returned as-is. + - multiple options, no selector: prompt on a TTY; otherwise error and + list the options so the agent can re-run with a selector. + Exits with a helpful message on ambiguity / no match. + """ + if not accepts: + print("No payment options in 402 response.", file=sys.stderr) + sys.exit(1) + + if index is not None: + try: + idx = int(index) + except (TypeError, ValueError): + print(f"Error: --payment-option must be a number 1-{len(accepts)}", file=sys.stderr) + sys.exit(1) + if idx < 1 or idx > len(accepts): + print(f"Error: --payment-option {idx} out of range (1-{len(accepts)})", file=sys.stderr) + sys.exit(1) + return accepts[idx - 1] + + if token or network: + matches = [a for a in accepts if _option_matches(a, token, network)] + if len(matches) == 1: + return matches[0] + if not matches: + print(f"Error: no advertised payment option matches {_filter_desc(token, network)}.", file=sys.stderr) + else: + print(f"Error: {_filter_desc(token, network)} matches {len(matches)} options — " + f"narrow it (use --token AND --network, or --payment-option N).", file=sys.stderr) + _print_options(accepts, sys.stderr) + sys.exit(1) + + if len(accepts) == 1: + return accepts[0] + + if sys.stdin.isatty(): + print(f"This service accepts {len(accepts)} payment options:") + _print_options(accepts, sys.stdout) + while True: + ans = input(f"Choose payment option [1-{len(accepts)}]: ").strip() + if ans.isdigit() and 1 <= int(ans) <= len(accepts): + return accepts[int(ans) - 1] + print(" Enter a number from the list.") + + print(f"Error: this service accepts {len(accepts)} payment options — choose one with " + f"--token , --network , or --payment-option :", file=sys.stderr) + _print_options(accepts, sys.stderr) + sys.exit(1) + + def cmd_probe(endpoint_url, model_id=None, kind="inference", method=None): """Probe an endpoint for x402 pricing and print results.""" pricing = _probe_endpoint(endpoint_url, model_id, kind=kind, method=method) @@ -1524,6 +1628,10 @@ def cmd_probe(endpoint_url, model_id=None, kind="inference", method=None): print(f" eip712: {domain.get('name', '?')} / {domain.get('version', '?')} (signing domain)") print() + if len(pricing.get("accepts", [])) > 1: + print("Multiple payment options — pick one when paying with " + "--token , --network , or --payment-option .") + return pricing @@ -1542,11 +1650,12 @@ def cmd_buy(name, endpoint, model_id, budget=None, count=None, opts=None): sys.exit(1) accepts = pricing.get("accepts", []) - if not accepts: - print("No payment options in 402 response.", file=sys.stderr) - sys.exit(1) - - payment = accepts[0] + payment = _select_payment( + accepts, + token=opts.get("token"), + network=opts.get("network"), + index=opts.get("payment_option"), + ) pay_to = payment.get("payTo", "") try: chain = _normalize_chain_name(payment.get("network", DEFAULT_CHAIN)) @@ -2115,7 +2224,7 @@ def cmd_balance(chain=None): # Pay (single-shot HTTP/x402 purchase) # --------------------------------------------------------------------------- -def cmd_pay(url, method="GET", data=None, kind="http", network=None, timeout=None): +def cmd_pay(url, method="GET", data=None, kind="http", network=None, timeout=None, token=None, payment_option=None): """Single-shot paid HTTP request: probe → pre-sign one auth → send with X-PAYMENT. Stateless. Does not create a PurchaseRequest, does not touch the buyer @@ -2145,30 +2254,15 @@ def cmd_pay(url, method="GET", data=None, kind="http", network=None, timeout=Non sys.exit(1) accepts = pricing.get("accepts", []) - if not accepts: - print("No payment options in 402 response.", file=sys.stderr) - sys.exit(1) - - payment = accepts[0] + # --network/--token now SELECT among advertised options (and still guard: + # an unmatched filter errors with the option list rather than signing). + payment = _select_payment(accepts, token=token, network=network, index=payment_option) pay_to = payment.get("payTo", "") try: chain = _normalize_chain_name(payment.get("network", DEFAULT_CHAIN)) except ValueError as exc: print(f"Error: {exc}", file=sys.stderr) sys.exit(1) - if network: - try: - requested = _resolve_chain(network) - except ValueError as exc: - print(f"Error: --network: {exc}", file=sys.stderr) - sys.exit(1) - if requested != chain: - print( - f"Error: seller is on {chain} but --network {network} was requested.\n" - f"Drop --network to accept the seller's chain, or pick a different endpoint.", - file=sys.stderr, - ) - sys.exit(1) price = str(payment.get("amount", payment.get("maxAmountRequired", "0"))) asset = payment.get("asset") or _canonical_usdc(chain) @@ -2261,7 +2355,7 @@ def cmd_pay(url, method="GET", data=None, kind="http", network=None, timeout=Non sys.exit(1) -def cmd_pay_agent(url, messages=None, model_id=None, network=None, timeout=None, body=None): +def cmd_pay_agent(url, messages=None, model_id=None, network=None, timeout=None, body=None, token=None, payment_option=None): """Single-shot paid streaming agent call: probe -> sign one auth -> SSE-stream. Sibling of `cmd_pay` for `type=agent` ServiceOffers. Differences from @@ -2329,29 +2423,13 @@ def cmd_pay_agent(url, messages=None, model_id=None, network=None, timeout=None, sys.exit(1) accepts = pricing.get("accepts", []) - if not accepts: - print("No payment options in 402 response.", file=sys.stderr) - sys.exit(1) - - payment = accepts[0] + payment = _select_payment(accepts, token=token, network=network, index=payment_option) pay_to = payment.get("payTo", "") try: chain = _normalize_chain_name(payment.get("network", DEFAULT_CHAIN)) except ValueError as exc: print(f"Error: {exc}", file=sys.stderr) sys.exit(1) - if network: - try: - requested = _resolve_chain(network) - except ValueError as exc: - print(f"Error: --network: {exc}", file=sys.stderr) - sys.exit(1) - if requested != chain: - print( - f"Error: seller is on {chain} but --network {network} was requested.", - file=sys.stderr, - ) - sys.exit(1) price = str(payment.get("amount", payment.get("maxAmountRequired", "0"))) asset = payment.get("asset") or _canonical_usdc(chain) if not pay_to: @@ -2608,15 +2686,19 @@ def usage(): print("Commands:") print(" probe [--model ] [--type http|inference|agent] [--method GET|POST]") print(" Probe x402 pricing (default --type inference)") - print(" pay [--type http|inference] [--method GET|POST] [--data ''] [--network ] [--timeout ]") + print(" pay [--type http|inference] [--method GET|POST] [--data ''] [--timeout ]") + print(" [--token ] [--network ] [--payment-option ]") print(" Single-shot paid request (sign 1 auth, attach X-PAYMENT)") - print(" --network is a guard: aborts if seller is on a different chain") - print(" pay-agent --model [--message '' | --data ''] [--network ] [--timeout ]") + print(" Multi-currency offers: pick which asset/price to pay with") + print(" --token/--network/--payment-option (probe to see options)") + print(" pay-agent --model [--message '' | --data ''] [--timeout ]") + print(" [--token ] [--network ] [--payment-option ]") print(" Single-shot paid streaming agent call (POST /v1/chat/completions,") print(" stream: true). Each SSE event flushes to stdout so a calling") print(" agent can re-emit the stream to its own user. Default timeout 1h.") print(" buy --endpoint --model Pre-sign + configure paid/") print(" [--budget ] [--count ]") + print(" [--token ] [--network ] [--payment-option ] pick the asset/price on multi-currency offers") print(" [--auto-refill[=true|false]] [--refill-threshold ]") print(" [--refill-count ] [--cost-cap ]") print(" [--auth-ttl ] [--set-default]") @@ -2681,6 +2763,8 @@ def usage(): kind=kind, network=opts.get("network"), timeout=timeout, + token=opts.get("token"), + payment_option=opts.get("payment_option"), ) elif cmd == "pay-agent": @@ -2708,6 +2792,8 @@ def usage(): network=opts.get("network"), timeout=timeout, body=opts.get("data"), + token=opts.get("token"), + payment_option=opts.get("payment_option"), ) elif cmd == "buy": From 59b9e577c0564e55410e76f5da1d5aa444789e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Sun, 21 Jun 2026 21:23:04 +0100 Subject: [PATCH 5/5] finish skill.md and multi currency services --- cmd/obol/sell.go | 145 ++++++++++++------ cmd/obol/sell_agent.go | 20 ++- cmd/obol/sell_test.go | 4 +- internal/serviceoffercontroller/controller.go | 2 +- internal/serviceoffercontroller/openapi.go | 50 ++++-- .../serviceoffercontroller/openapi_test.go | 48 ++++++ .../registration_message_test.go | 14 +- internal/serviceoffercontroller/render.go | 143 +++++++++++++++-- .../serviceoffercontroller/render_test.go | 2 +- internal/x402/paymentrequired.go | 58 ++++--- internal/x402/paymentrequired_test.go | 14 +- .../src/components/ServiceCard.tsx | 18 ++- 12 files changed, 408 insertions(+), 110 deletions(-) diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 8d6a8f3c..2ea01bba 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -100,7 +100,7 @@ Buyers pay per-request in the selected x402 token to access inference endpoints. Examples: obol sell inference my-qwen --model qwen3.5:4b --pay-to 0x... --price 0.001 - obol sell inference my-llama --model llama3:8b --pay-to 0x... --chain base`, + obol sell inference my-llama --model llama3:8b --pay-to 0x... --network base`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "model", @@ -120,9 +120,10 @@ Examples: Usage: "Per-million-tokens price in the selected payment token (charged as an approximation at 1000 tok/request)", }, &cli.StringFlag{ - Name: "chain", - Usage: "Payment chain (base, base-sepolia, ethereum)", - Value: "base", + Name: "network", + Aliases: []string{"chain"}, + Usage: "Payment network (base, base-sepolia, ethereum)", + Value: "base", }, &cli.StringFlag{ Name: "token", @@ -555,7 +556,7 @@ Connect a buyer at http://localhost:/mcp (streamable HTTP). Examples: # Front a weather API as a paid MCP tool (the canonical x402 paid-MCP shape): - obol sell mcp weather --pay-to 0x... --price 0.001 --chain base-sepolia \ + obol sell mcp weather --pay-to 0x... --price 0.001 --network base-sepolia \ --tool-name get_weather \ --description 'Current weather for a city. Args: {city}' \ --upstream https://your-weather-service/current @@ -567,9 +568,10 @@ Examples: Flags: []cli.Flag{ payToFlag("Payment recipient address"), &cli.StringFlag{ - Name: "chain", - Usage: "Payment chain (base, base-sepolia, ethereum, polygon)", - Value: "base-sepolia", + Name: "network", + Aliases: []string{"chain"}, + Usage: "Payment network (base, base-sepolia, ethereum, polygon)", + Value: "base-sepolia", }, &cli.StringFlag{ Name: "price", @@ -670,14 +672,15 @@ By default it also registers the seller agent on ERC-8004 after the route is liv Use --no-register to skip the on-chain registration step. Examples: - obol sell http my-cool-api --upstream my-svc.my-namespace.svc.cluster.local --port 8080 --pay-to 0x... --price 0.01 --chain base - obol sell http my-cool-api --upstream my-svc --port 8080 --pay-to 0x... --price 0.01 --chain base --no-register`, + obol sell http my-cool-api --upstream my-svc.my-namespace.svc.cluster.local --port 8080 --pay-to 0x... --price 0.01 --network base + obol sell http my-cool-api --upstream my-svc --port 8080 --pay-to 0x... --price 0.01 --network base --no-register`, Flags: append([]cli.Flag{ payToFlag("Payment recipient address"), &cli.StringFlag{ - Name: "chain", - Usage: "Payment chain (base, base-sepolia, ethereum)", - Value: "base", + Name: "network", + Aliases: []string{"chain"}, + Usage: "Payment network (base, base-sepolia, ethereum)", + Value: "base", }, &cli.StringFlag{ Name: "token", @@ -830,10 +833,10 @@ Examples: var err error name, err = u.Input("Service name", "") if err != nil || name == "" { - return fmt.Errorf("name required: obol sell http --pay-to --chain ") + return fmt.Errorf("name required: obol sell http --pay-to --network ") } } else { - return fmt.Errorf("name required: obol sell http --pay-to --chain ") + return fmt.Errorf("name required: obol sell http --pay-to --network ") } } if err := validate.Name(name); err != nil { @@ -867,10 +870,10 @@ Examples: ns := cmd.String("namespace") if cmd.String("upstream") == "" { - return fmt.Errorf("upstream service name required: use --upstream \n\n Example: obol sell http %s --upstream my-svc --port 8080 --pay-to 0x... --chain base-sepolia --price 0.001", name) + return fmt.Errorf("upstream service name required: use --upstream \n\n Example: obol sell http %s --upstream my-svc --port 8080 --pay-to 0x... --network base-sepolia --price 0.001", name) } if cmd.Int("port") == 0 { - return fmt.Errorf("upstream port required: use --port \n\n Example: obol sell http %s --upstream my-svc --port 8080 --pay-to 0x... --chain base-sepolia --price 0.001", name) + return fmt.Errorf("upstream port required: use --port \n\n Example: obol sell http %s --upstream my-svc --port 8080 --pay-to 0x... --network base-sepolia --price 0.001", name) } // Build the payment block(s): multi-currency via --accept, else @@ -1120,7 +1123,7 @@ func buildSellUpdatePatch(payTo, chain string, price schemas.PriceTable) (map[st } if len(payment) == 0 { - return nil, errors.New("nothing to update: pass at least one of --per-request / --per-mtok / --per-hour / --pay-to / --chain") + return nil, errors.New("nothing to update: pass at least one of --per-request / --per-mtok / --per-hour / --pay-to / --network") } return map[string]any{ @@ -1380,7 +1383,7 @@ func serviceOfferStatusLines(namespace, name string, offer monetizeapi.ServiceOf // Network/Asset/Price/Pay To lines; multi-currency offers list every option. payments := offer.EffectivePayments() if len(payments) <= 1 { - p := offer.Spec.Payment + p := effectivePaymentAsset(offer.Spec.Payment) lines = append(lines, fmt.Sprintf("Network: %s", valueOrNone(p.Network)), fmt.Sprintf("Asset: %s", formatOfferAsset(p.Asset)), @@ -1388,9 +1391,9 @@ func serviceOfferStatusLines(namespace, name string, offer monetizeapi.ServiceOf fmt.Sprintf("Pay To: %s", valueOrNone(p.PayTo)), ) } else { - lines = append(lines, fmt.Sprintf("Payments: %d accepted options", len(payments))) + lines = append(lines, fmt.Sprintf("Payments: %d accepted options (buyer picks one)", len(payments))) for i := range payments { - p := payments[i] + p := effectivePaymentAsset(payments[i]) lines = append(lines, fmt.Sprintf(" - %s on %s → %s", formatOfferPrice(p), valueOrNone(p.Network), valueOrNone(p.PayTo)), @@ -1410,6 +1413,33 @@ func serviceOfferStatusLines(namespace, name string, offer monetizeapi.ServiceOf return lines } +// effectivePaymentAsset fills a payment's Asset block from the chain's +// default settlement asset (USDC) when the offer left it implicit — i.e. +// the seller passed --token USDC (or nothing) and never wrote an explicit +// asset. This mirrors what the 402 wire and storefront catalog advertise, +// so `obol sell status` shows "USDC (0x833…)" / "3 USDC per request" instead +// of "(not set)" / "3 per request". Offers that already carry an explicit +// asset (e.g. OBOL) are returned unchanged. +func effectivePaymentAsset(p monetizeapi.ServiceOfferPayment) monetizeapi.ServiceOfferPayment { + if p.Asset.Symbol != "" || p.Asset.Address != "" { + return p + } + chain, err := x402verifier.ResolveChainInfo(p.Network) + if err != nil { + return p + } + def := chain.DefaultAsset() + p.Asset = monetizeapi.ServiceOfferAsset{ + Address: def.Address, + Symbol: def.Symbol, + Decimals: int64(def.Decimals), + TransferMethod: def.TransferMethod, + EIP712Name: def.EIP712Name, + EIP712Version: def.EIP712Version, + } + return p +} + // formatOfferAsset renders the payment asset as "SYMBOL" or // "SYMBOL (0xaddr)" when the contract address is known. func formatOfferAsset(asset monetizeapi.ServiceOfferAsset) string { @@ -1515,7 +1545,7 @@ type demoSpec struct { 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 + DefaultChain string // default --network when not explicitly set DefaultToken string // default --token when not explicitly set // Agent is set on demo types that resolve to an agent-backed offer @@ -1589,12 +1619,13 @@ Example: obol sell demo # hello @ 1 OBOL on ethereum obol sell demo blocks # blocks @ 0.0001 USDC on base-sepolia obol sell demo quant --price 5 # quant @ 5 OBOL on ethereum - obol sell demo hello --token USDC --chain base --price 0.001`, + obol sell demo hello --token USDC --network base --price 0.001`, Flags: []cli.Flag{ payToFlag("Token recipient address"), &cli.StringFlag{ - Name: "chain", - Usage: "Payment chain (defaults to demo type's default chain)", + Name: "network", + Aliases: []string{"chain"}, + Usage: "Payment network (defaults to demo type's default chain)", }, &cli.StringFlag{ Name: "token", @@ -1679,7 +1710,7 @@ Example: } // Resolve token metadata. resolveAssetTermsFor may flip chain to ethereum - // for non-USDC tokens when --chain wasn't explicitly set. + // for non-USDC tokens when --network wasn't explicitly set. assetTerms, err := resolveAssetTermsFor(tokenName, &chain, chainExplicit) if err != nil { return err @@ -1708,7 +1739,7 @@ Example: // the previous default (auto-register on every demo) caused // repeated `setMetadata` calls to revert at the contract once the // agent already had x402 metadata, and required the demo wallet - // to hold ETH for gas. Operators run `obol sell register --chain ...` + // to hold ETH for gas. Operators run `obol sell register --network ...` // when they actually want on-chain discovery. register := cmd.Bool("register") soManifest := buildDemoServiceOffer(name, demoNamespace, chain, wallet, price, register, spec, assetTerms) @@ -1774,7 +1805,7 @@ Example: autoRegisterDemo(ctx, cfg, u, chain, tunnelURL) } else { u.Info("Registration skipped (default for demos). The offer will still reach Ready.") - u.Dim(" Run on-chain discovery later: obol sell register --chain " + chain) + u.Dim(" Run on-chain discovery later: obol sell register --network " + chain) } // 6. Print try-it instructions. @@ -1797,7 +1828,7 @@ func autoRegisterDemo(ctx context.Context, cfg *config.Config, u *ui.UI, chain, skipHint := func(reason string) { u.Warnf("Skipping auto-register: %s", reason) - u.Dim(" You can run it manually later: obol sell register --chain " + chain) + u.Dim(" You can run it manually later: obol sell register --network " + chain) } if tunnelURL == "" { @@ -1822,7 +1853,7 @@ func autoRegisterDemo(ctx context.Context, cfg *config.Config, u *ui.UI, chain, agentURI := strings.TrimRight(tunnelURL, "/") + "/.well-known/agent-registration.json" if registerAgentOnNetworks(ctx, cfg, u, agentURI, signerNS, []erc8004.NetworkConfig{net}) == 0 { u.Warn("Auto-register did not succeed.") - u.Dim(" Retry with: obol sell register --chain " + chain) + u.Dim(" Retry with: obol sell register --network " + chain) return } u.Successf("Agent registered on %s.", net.Name) @@ -2711,7 +2742,7 @@ so the controller picks up the new model. Examples: obol sell update my-api -n llm --per-request 0.002 obol sell update my-api -n llm --per-mtok 5.0 - obol sell update my-api -n llm --wallet 0xNew... --chain base + obol sell update my-api -n llm --wallet 0xNew... --network base obol sell update my-api -n llm --accept token=USDC,network=base,price=1 --accept token=OBOL,network=ethereum,price=10`, Flags: append([]cli.Flag{ &cli.StringFlag{ @@ -2722,8 +2753,9 @@ Examples: }, payToFlag("New payment recipient address"), &cli.StringFlag{ - Name: "chain", - Usage: "New payment chain (base, base-sepolia, ethereum)", + Name: "network", + Aliases: []string{"chain"}, + Usage: "New payment network (base, base-sepolia, ethereum)", }, &cli.StringFlag{ Name: "price", @@ -2745,7 +2777,7 @@ Examples: Action: func(ctx context.Context, cmd *cli.Command) error { u := getUI(cmd) if cmd.NArg() == 0 { - return errors.New("name required: obol sell update -n [--per-request N | --per-mtok N | --per-hour N | --accept ...] [--pay-to 0x...] [--chain base]") + return errors.New("name required: obol sell update -n [--per-request N | --per-mtok N | --per-hour N | --accept ...] [--pay-to 0x...] [--network base]") } name := cmd.Args().First() @@ -2958,9 +2990,10 @@ Reloads the payment verifier when configuration is changed.`, Flags: []cli.Flag{ payToFlag("Payment recipient address"), &cli.StringFlag{ - Name: "chain", - Usage: "Payment chain (base, base-sepolia, ethereum)", - Value: "base", + Name: "network", + Aliases: []string{"chain"}, + Usage: "Payment network (base, base-sepolia, ethereum)", + Value: "base", }, &cli.StringFlag{ Name: "facilitator-url", @@ -3028,13 +3061,14 @@ the target chain (~$0.20–$0.50 of native gas typically suffices). Examples: obol sell register # defaults to mainnet - obol sell register --chain base # register on base - obol sell register --chain base-sepolia # add a Base Sepolia registration`, + obol sell register --network base # register on base + obol sell register --network base-sepolia # add a Base Sepolia registration`, Flags: []cli.Flag{ &cli.StringFlag{ - Name: "chain", - Usage: "Registration chain (mainnet, base, base-sepolia)", - Value: "mainnet", + Name: "network", + Aliases: []string{"chain"}, + Usage: "Registration network (mainnet, base, base-sepolia)", + Value: "mainnet", }, &cli.StringFlag{ Name: "endpoint", @@ -3296,7 +3330,7 @@ func registerDirectViaSigner(ctx context.Context, cfg *config.Config, u *ui.UI, identity.Status = monetizeapi.UpsertAgentIdentityRegistration(identity.Status, net.Name, agentID.String()) if err := applyAgentIdentity(cfg, identity); err != nil { - return fmt.Errorf("persist AgentIdentity registration %s on %s: %w\n\n The on-chain registration succeeded; recover with `obol sell identity import --chain %s --agent-id %s`.", agentID, net.Name, err, net.Name, agentID) + return fmt.Errorf("persist AgentIdentity registration %s on %s: %w\n\n The on-chain registration succeeded; recover with `obol sell identity import --network %s --agent-id %s`.", agentID, net.Name, err, net.Name, agentID) } return nil } @@ -3629,6 +3663,29 @@ func kubectlApplyOutput(cfg *config.Config, manifest interface{}) (string, error return kubectl.ApplyOutput(bin, kc, raw) } +// confirmOfferReplace guards against silently overwriting an existing +// ServiceOffer. `obol sell agent ` and `obol sell demo ` can +// resolve to the same offer name+namespace (one offer per agent path), so a +// second create would `kubectl apply` over the first with no warning. When an +// offer already exists and we're on an interactive terminal, prompt before +// replacing; the caller aborts on a "no". Non-interactive callers (resume, +// flows, JSON) keep the idempotent apply behaviour and never block. +// +// Returns true to proceed with the apply, false to abort. +func confirmOfferReplace(cfg *config.Config, u *ui.UI, namespace, name string) bool { + if !u.IsTTY() || u.IsJSON() { + return true + } + out, err := kubectlOutput(cfg, "get", "serviceoffer", name, "-n", namespace, "--ignore-not-found", "-o", "name") + if err != nil || strings.TrimSpace(out) == "" { + // Not found (or lookup failed) — nothing to replace; proceed. + return true + } + u.Warnf("A ServiceOffer named %q already exists in namespace %s.", name, namespace) + u.Dim(" Continuing will replace its payment terms, path, and registration with the new ones.") + return u.Confirm(fmt.Sprintf("Replace ServiceOffer %s/%s?", namespace, name), false) +} + func kubectlOutput(cfg *config.Config, args ...string) (string, error) { if err := kubectl.EnsureCluster(cfg); err != nil { return "", err @@ -4383,7 +4440,7 @@ func buildResumeGatewayArgs(d *inference.Deployment) []string { args = append(args, "--listen", d.ListenAddr) } if d.Chain != "" { - args = append(args, "--chain", d.Chain) + args = append(args, "--network", d.Chain) } if d.AssetSymbol != "" { args = append(args, "--token", d.AssetSymbol) diff --git a/cmd/obol/sell_agent.go b/cmd/obol/sell_agent.go index c6bc473a..25f3503d 100644 --- a/cmd/obol/sell_agent.go +++ b/cmd/obol/sell_agent.go @@ -35,15 +35,16 @@ Run ` + "`obol agent new --skills ... --model ... --create-wallet`" + ` f to declare the agent, then ` + "`obol sell agent `" + ` to make it sellable. Examples: - obol sell agent quant --price 0.01 --token USDC --chain base-sepolia - obol sell agent quant --price 10 --token OBOL --chain ethereum --pay-to 0xColdVault + obol sell agent quant --price 0.01 --token USDC --network base-sepolia + obol sell agent quant --price 10 --token OBOL --network ethereum --pay-to 0xColdVault obol sell agent quant --accept token=USDC,network=base,price=1 --accept token=OBOL,network=ethereum,price=10`, Flags: append([]cli.Flag{ payToFlag("Recipient for sale revenue (defaults to the agent's own wallet when one was provisioned)"), &cli.StringFlag{ - Name: "chain", - Usage: "Payment chain (base, base-sepolia, ethereum)", - Value: "base", + Name: "network", + Aliases: []string{"chain"}, + Usage: "Payment network (base, base-sepolia, ethereum)", + Value: "base", }, &cli.StringFlag{ Name: "token", @@ -282,6 +283,11 @@ Examples: return err } + if !confirmOfferReplace(cfg, u, offerNs, name) { + u.Dim("Aborted; existing offer left unchanged.") + return nil + } + out, err := kubectlApplyOutput(cfg, manifest) if err != nil { return fmt.Errorf("apply ServiceOffer: %w", err) @@ -309,7 +315,7 @@ Examples: } if !register { - u.Dim("Registration skipped (--no-register). Run `obol sell register --chain " + primaryNetwork + "` later for on-chain discovery.") + u.Dim("Registration skipped (--no-register). Run `obol sell register --network " + primaryNetwork + "` later for on-chain discovery.") } else { // sell agent is declare-only: it sets spec.registration and // relies on the controller + a manual `obol sell register`. Make @@ -512,7 +518,7 @@ func runAgentBackedDemo( autoRegisterDemo(ctx, cfg, u, chain, tunnelURL) } else { u.Info("Registration skipped. The offer will still reach Ready when the agent is provisioned.") - u.Dim(" Run on-chain discovery later: obol sell register --chain " + chain) + u.Dim(" Run on-chain discovery later: obol sell register --network " + chain) } ready := waitForOfferReady(cfg, u, name, offerNs, 2*time.Minute) diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index d9069170..a06a716e 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -1268,7 +1268,7 @@ func TestBuildSellUpdatePatch_NoFieldsErrors(t *testing.T) { if err == nil { t.Fatal("expected error when no update flags are set") } - for _, sub := range []string{"--per-request", "--per-mtok", "--per-hour", "--pay-to", "--chain"} { + for _, sub := range []string{"--per-request", "--per-mtok", "--per-hour", "--pay-to", "--network"} { if !strings.Contains(err.Error(), sub) { t.Errorf("error must name flag %q so the operator learns the surface; got: %v", sub, err) } @@ -1449,7 +1449,7 @@ func TestBuildResumeGatewayArgs(t *testing.T) { "--upstream", "http://127.0.0.1:8000", "--pay-to", "0xeFAb75b7b199bf8512e2d5b379374Cb94dfdBA47", "--listen", "0.0.0.0:8402", - "--chain", "base-sepolia", + "--network", "base-sepolia", "--token", "OBOL", "--per-mtok", "23", "--facilitator", "https://x402.gcp.obol.tech", diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index be6b7cfe..3c0c26e5 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -1433,7 +1433,7 @@ func truncateMessage(message string) string { func awaitingExternalRegistrationMessage(chain string) string { cmd := "obol sell register" if chain = strings.TrimSpace(chain); chain != "" { - cmd += " --chain " + chain + cmd += " --network " + chain } return truncateMessage("Awaiting external ERC-8004 registration tx — submit with `" + cmd + "`; offer already serves paid traffic") } diff --git a/internal/serviceoffercontroller/openapi.go b/internal/serviceoffercontroller/openapi.go index 5e6a9688..4af732c3 100644 --- a/internal/serviceoffercontroller/openapi.go +++ b/internal/serviceoffercontroller/openapi.go @@ -351,17 +351,53 @@ func openAPIGenericSuccessResponse(description string) map[string]any { // per-request approximation the verifier enforces on the 402 wire, so this // metadata never promises a cheaper call than the runtime charges. func offerPaymentInfoExtension(offer *monetizeapi.ServiceOffer) map[string]any { - price := map[string]any{"mode": "fixed"} + payments := offer.EffectivePayments() + + info := map[string]any{ + // `price` stays the PRIMARY option (payments[0]) for single-price + // indexers that read only this field. + "price": paymentInfoPrice(payments[0]), + "protocols": []any{map[string]any{"x402": map[string]any{}}}, + } + + // For multi-currency offers, advertise every accepted option so indexers + // can surface the cheapest / a buyer's preferred chain. Each entry carries + // the same {mode,currency,amount} shape as `price`, plus the CAIP-2 chain. + // Omitted for single-payment offers — `price` already says everything. + if len(payments) > 1 { + accepts := make([]any, 0, len(payments)) + for i := range payments { + entry := paymentInfoPrice(payments[i]) + if net := strings.TrimSpace(payments[i].Network); net != "" { + if caip, _ := caip2ForNetwork(net); caip != "" { + entry["network"] = caip + } else { + entry["network"] = net + } + } + accepts = append(accepts, entry) + } + info["accepts"] = accepts + } - if asset := offerAssetJSON(offer); asset != nil && asset.Symbol != "" { + return info +} + +// paymentInfoPrice renders one payment option as an x402scan-style price +// object: {mode:"fixed", currency, amount}. USDC-settled options advertise +// ISO-4217 "USD" (1:1); other assets advertise their token symbol. perMTok +// prices collapse to the same per-request approximation the verifier enforces +// on the wire, so this metadata never undercuts the runtime charge. +func paymentInfoPrice(p monetizeapi.ServiceOfferPayment) map[string]any { + price := map[string]any{"mode": "fixed"} + if asset := paymentAssetJSON(p); asset != nil && asset.Symbol != "" { if strings.EqualFold(asset.Symbol, "USDC") { price["currency"] = "USD" } else { price["currency"] = asset.Symbol } } - - if amount, unit := offerPriceRawAndUnit(offer); amount != "" { + if amount, unit := paymentPriceRawAndUnit(p); amount != "" { if unit == "perMTok" { if approx, err := schemas.ApproximateRequestPriceFromPerMTok(amount); err == nil { amount = approx @@ -369,11 +405,7 @@ func offerPaymentInfoExtension(offer *monetizeapi.ServiceOffer) map[string]any { } price["amount"] = amount } - - return map[string]any{ - "price": price, - "protocols": []any{map[string]any{"x402": map[string]any{}}}, - } + return price } // operationTagsForOffer combines the offer's coarse type tag with any diff --git a/internal/serviceoffercontroller/openapi_test.go b/internal/serviceoffercontroller/openapi_test.go index 37c88539..a9ca1a8e 100644 --- a/internal/serviceoffercontroller/openapi_test.go +++ b/internal/serviceoffercontroller/openapi_test.go @@ -197,6 +197,54 @@ func TestBuildOpenAPIDocument_InferenceOffer(t *testing.T) { } } +// TestBuildOpenAPIDocument_MultiPaymentAdvertisesAllOptions locks in the +// multi-currency x-payment-info contract: `price` stays the primary option +// (for single-price indexers), and `accepts[]` lists every option with its +// currency and CAIP-2 network so indexers can surface the cheapest. +func TestBuildOpenAPIDocument_MultiPaymentAdvertisesAllOptions(t *testing.T) { + offer := readyOfferWithSpec("dual", "llm", monetizeapi.ServiceOfferSpec{ + Type: "inference", + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base", PayTo: "0x1111111111111111111111111111111111111111", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "1"}, + }, + Payments: []monetizeapi.ServiceOfferPayment{ + { + Network: "base", PayTo: "0x1111111111111111111111111111111111111111", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "1"}, + }, + { + Network: "ethereum", PayTo: "0x2222222222222222222222222222222222222222", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "10"}, + Asset: monetizeapi.ServiceOfferAsset{Symbol: "OBOL", Address: "0x0B010000b7624eb9B3DfBC279673C76E9D29D5F7", Decimals: 18, TransferMethod: "permit2", EIP712Name: "Obol Network", EIP712Version: "1"}, + }, + }, + }) + + doc := parseOpenAPI(t, buildOpenAPIDocument([]*monetizeapi.ServiceOffer{offer}, "")) + op := dig(t, doc, "paths", "/services/dual/v1/chat/completions", "post") + xpay, _ := op.(map[string]any)["x-payment-info"].(map[string]any) + if xpay == nil { + t.Fatalf("x-payment-info missing") + } + // Primary price = first option (USDC on base → USD). + if price, _ := xpay["price"].(map[string]any); price["currency"] != "USD" || price["amount"] != "1" { + t.Errorf("primary price = %v, want USD/1", xpay["price"]) + } + accepts, _ := xpay["accepts"].([]any) + if len(accepts) != 2 { + t.Fatalf("accepts = %v, want 2 options", xpay["accepts"]) + } + a0 := accepts[0].(map[string]any) + if a0["currency"] != "USD" || a0["network"] != "eip155:8453" { + t.Errorf("accepts[0] = %v, want USD on eip155:8453", a0) + } + a1 := accepts[1].(map[string]any) + if a1["currency"] != "OBOL" || a1["amount"] != "10" || a1["network"] != "eip155:1" { + t.Errorf("accepts[1] = %v, want OBOL/10 on eip155:1", a1) + } +} + // TestBuildOpenAPIDocument_AgentOfferSameShapeAsInference locks in the // user-confirmed decision: agent-type offers ship the OpenAI chat // completions endpoint, identical to inference. Renderers don't need diff --git a/internal/serviceoffercontroller/registration_message_test.go b/internal/serviceoffercontroller/registration_message_test.go index 7220f8d2..67909342 100644 --- a/internal/serviceoffercontroller/registration_message_test.go +++ b/internal/serviceoffercontroller/registration_message_test.go @@ -13,12 +13,12 @@ func TestAwaitingExternalRegistrationMessage(t *testing.T) { tests := []struct { name string chain string - wantChain string // substring that must appear ("" => no --chain flag) - wantNoChain bool // when true, "--chain" must NOT appear + wantChain string // substring that must appear ("" => no --network flag) + wantNoChain bool // when true, "--network" must NOT appear }{ - {name: "mainnet chain", chain: "ethereum", wantChain: "--chain ethereum"}, - {name: "testnet chain", chain: "base-sepolia", wantChain: "--chain base-sepolia"}, - {name: "whitespace trimmed", chain: " base ", wantChain: "--chain base"}, + {name: "mainnet chain", chain: "ethereum", wantChain: "--network ethereum"}, + {name: "testnet chain", chain: "base-sepolia", wantChain: "--network base-sepolia"}, + {name: "whitespace trimmed", chain: " base ", wantChain: "--network base"}, {name: "empty chain omits flag", chain: "", wantNoChain: true}, } @@ -33,8 +33,8 @@ func TestAwaitingExternalRegistrationMessage(t *testing.T) { t.Errorf("message must reassure the offer still serves traffic; got %q", got) } if tt.wantNoChain { - if strings.Contains(got, "--chain") { - t.Errorf("empty chain must omit the --chain flag; got %q", got) + if strings.Contains(got, "--network") { + t.Errorf("empty chain must omit the --network flag; got %q", got) } } else if !strings.Contains(got, tt.wantChain) { t.Errorf("message must embed %q; got %q", tt.wantChain, got) diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index aed6b4f9..f41c4509 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -935,20 +935,25 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin lines := []string{ "# Obol Stack Service Catalog", "", - fmt.Sprintf("> Generated from %d ready ServiceOffer(s).", len(ready)), + fmt.Sprintf("> Generated from %d ready ServiceOffer(s). Every service below is gated by [x402](https://www.x402.org) micropayments — no API key, no signup, no subscription.", len(ready)), "", - fmt.Sprintf("> For machine-readable agent identity, see [/.well-known/agent-registration.json](%s/.well-known/agent-registration.json).", baseURL), + "> **Machine-readable:** " + + fmt.Sprintf("OpenAPI 3.1 (Swagger) at [`%s/openapi.json`](%s/openapi.json) · ", baseURL, baseURL) + + fmt.Sprintf("catalog feed at [`%s/api/services.json`](%s/api/services.json) · ", baseURL, baseURL) + + fmt.Sprintf("agent identity at [`%s/.well-known/agent-registration.json`](%s/.well-known/agent-registration.json).", baseURL, baseURL), "", } + lines = append(lines, skillCatalogHowToPay(baseURL)...) + if len(ready) == 0 { - lines = append(lines, "**No services currently available.**", "") + lines = append(lines, "## Services", "", "**No services currently available.**", "") return strings.Join(lines, "\n") } lines = append(lines, "## Services", "") - lines = append(lines, "| Service | Type | Model | Price | Status | Endpoint |") - lines = append(lines, "|---------|------|-------|-------|--------|----------|") + lines = append(lines, "| Service | Type | Model | Pay with | Status | Endpoint |") + lines = append(lines, "|---------|------|-------|----------|--------|----------|") for _, offer := range ready { modelName := offer.Spec.Model.Name if modelName == "" { @@ -964,7 +969,7 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin offer.Name, fallbackOfferType(offer), modelName, - describeOfferPrice(offer), + describeOfferPaymentsInline(offer), status, baseURL, offer.EffectivePath(), @@ -973,15 +978,23 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin lines = append(lines, "", "## Service Details", "") for _, offer := range ready { modelName := offer.Spec.Model.Name + endpoint := baseURL + offer.EffectivePath() lines = append(lines, fmt.Sprintf("### %s", offer.Name)) - lines = append(lines, fmt.Sprintf("- **Endpoint**: `%s%s`", baseURL, offer.EffectivePath())) + lines = append(lines, fmt.Sprintf("- **Endpoint**: `%s`", endpoint)) + lines = append(lines, fmt.Sprintf("- **Call**: %s", offerCallHint(offer, endpoint))) lines = append(lines, fmt.Sprintf("- **Type**: %s", fallbackOfferType(offer))) if modelName != "" { lines = append(lines, fmt.Sprintf("- **Model**: %s", modelName)) } - lines = append(lines, fmt.Sprintf("- **Price**: %s", describeOfferPrice(offer))) - lines = append(lines, fmt.Sprintf("- **Pay To**: `%s`", firstNonEmpty(offer.Spec.Payment.PayTo, "—"))) - lines = append(lines, fmt.Sprintf("- **Network**: %s", firstNonEmpty(offer.Spec.Payment.Network, "—"))) + payments := offer.EffectivePayments() + if len(payments) == 1 { + lines = append(lines, fmt.Sprintf("- **Payment**: %s", describePaymentDetail(payments[0]))) + } else { + lines = append(lines, "- **Payment options** (pick one):") + for i := range payments { + lines = append(lines, fmt.Sprintf(" %d. %s", i+1, describePaymentDetail(payments[i]))) + } + } if offer.IsDraining() { lines = append(lines, fmt.Sprintf("- **Drain ends at**: %s", offer.DrainEndsAt().UTC().Format(time.RFC3339))) } @@ -995,6 +1008,43 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin return strings.Join(lines, "\n") } +// skillCatalogHowToPay returns the self-contained "How to pay" section. It +// is written so any LLM agent — not just one running on Obol Stack — can +// pay these endpoints by following the x402 v2 loop, without first reading +// any external doc. baseURL points the reader at the machine-readable specs. +func skillCatalogHowToPay(baseURL string) []string { + return []string{ + "## How to pay (x402)", + "", + "Calling any endpoint below follows the same five steps. No wallet onboarding " + + "beyond holding the settlement token — payment is per-request and gasless.", + "", + "1. **Call the endpoint with no payment.** You get `402 Payment Required` with a JSON " + + "body whose `accepts[]` array lists every payment the operator will take — each entry " + + "carries the price in atomic units (`maxAmountRequired`), the CAIP-2 chain id (`network`), " + + "the settlement token contract (`asset`), the recipient (`payTo`), and the transfer scheme.", + "2. **Pick one `accepts[]` entry** whose token + chain you can pay on. Sellers may advertise " + + "several (e.g. USDC on Base *or* OBOL on Ethereum); they are alternatives, you satisfy one.", + "3. **Sign an authorization** matching that entry — an EIP-3009 `TransferWithAuthorization` " + + "(USDC) or a Permit2 witness (most other ERC-20s, signalled by `extra.assetTransferMethod`). " + + "This is an off-chain signature; **no ETH/gas needed** — the operator's facilitator submits " + + "and pays for the on-chain settlement.", + "4. **Retry the identical request** with the signed payload base64-encoded in the `X-PAYMENT` header.", + "5. **On success** you get your `200` plus settlement metadata in the `X-PAYMENT-RESPONSE` header. " + + "For chat-completions endpoints, pass `\"stream\": true` for long-running calls.", + "", + fmt.Sprintf("**Exact request shapes:** the OpenAPI 3.1 document at [`%s/openapi.json`](%s/openapi.json) "+ + "describes every operation's path, method, request/response body, and per-operation pricing "+ + "(`x-payment-info`). Load it into any OpenAPI-aware client to generate a typed caller.", baseURL, baseURL), + "", + "**Already on Obol Stack?** The `buy-x402` skill automates the whole loop: " + + "`buy.py pay ` for one-shot calls (add `--token ` / `--network ` to " + + "choose among multi-currency options), or `buy.py buy --endpoint --model ` to " + + "pre-authorize a batch of paid inference.", + "", + } +} + // offerOperationallyReady reports whether an offer is usable for x402 // payments today. This is intentionally LOOSER than the controller's // Ready=True condition: ModelReady + UpstreamHealthy + PaymentGateReady @@ -1358,6 +1408,79 @@ func describeOfferPrice(offer *monetizeapi.ServiceOffer) string { return describePaymentPrice(offer.Spec.Payment) } +// describeOfferPaymentsInline renders every accepted payment option of an +// offer for the compact catalog table, e.g. +// "1 USDC/request on base · 10 OBOL/request on ethereum". Buyers satisfy +// any one of the listed options. +func describeOfferPaymentsInline(offer *monetizeapi.ServiceOffer) string { + payments := offer.EffectivePayments() + parts := make([]string, 0, len(payments)) + for i := range payments { + parts = append(parts, describePaymentInline(payments[i])) + } + if len(parts) == 0 { + return "—" + } + return strings.Join(parts, " · ") +} + +// describePaymentInline is one option in compact form: " on ". +func describePaymentInline(p monetizeapi.ServiceOfferPayment) string { + return describePaymentPrice(p) + " on " + firstNonEmpty(p.Network, "—") +} + +// describePaymentDetail is one option fully expanded for the per-service +// detail block, e.g. +// "1 USDC per request on `base` (eip155:8453) — pay to `0x…`; token `0x833…` (USDC, 6 decimals, eip3009)". +func describePaymentDetail(p monetizeapi.ServiceOfferPayment) string { + var b strings.Builder + // describePaymentPrice yields "1 USDC/request"; spell the unit out for prose. + b.WriteString(strings.Replace(describePaymentPrice(p), "/", " per ", 1)) + b.WriteString(" on `") + b.WriteString(firstNonEmpty(p.Network, "—")) + b.WriteString("`") + if caip, _ := caip2ForNetwork(p.Network); caip != "" { + b.WriteString(" (" + caip + ")") + } + if p.PayTo != "" { + b.WriteString(" — pay to `" + p.PayTo + "`") + } + if a := paymentAssetJSON(p); a != nil && (a.Address != "" || a.Symbol != "") { + b.WriteString("; token") + if a.Address != "" { + b.WriteString(" `" + a.Address + "`") + } + meta := make([]string, 0, 3) + if a.Symbol != "" { + meta = append(meta, a.Symbol) + } + if a.Decimals > 0 { + meta = append(meta, fmt.Sprintf("%d decimals", a.Decimals)) + } + if a.TransferMethod != "" { + meta = append(meta, a.TransferMethod) + } + if len(meta) > 0 { + b.WriteString(" (" + strings.Join(meta, ", ") + ")") + } + } + return b.String() +} + +// offerCallHint returns a one-line "how to invoke" hint for the service +// detail block, derived from the offer type. inference/agent both speak the +// OpenAI chat-completions wire format; http is operator-defined. +func offerCallHint(offer *monetizeapi.ServiceOffer, endpoint string) string { + switch { + case offer.IsInference(), offer.IsAgent(): + return fmt.Sprintf("`POST %s/v1/chat/completions` — OpenAI-compatible chat completions (supports `stream: true`)", endpoint) + case strings.EqualFold(offer.Spec.Type, "fine-tuning"): + return fmt.Sprintf("`POST %s` — multipart fine-tuning job (operator-defined payload)", endpoint) + default: + return fmt.Sprintf("`%s` — operator-defined request shape; see `/openapi.json`", endpoint) + } +} + // describePaymentPrice renders a single payment option as " /". func describePaymentPrice(p monetizeapi.ServiceOfferPayment) string { // Source the symbol from (in order): explicit asset metadata on the diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index fcce6980..7c5a8ccd 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -691,7 +691,7 @@ func TestBuildSkillCatalogMarkdown_DrainAdditiveDetail(t *testing.T) { if strings.Contains(content, "- **Available**:") { t.Errorf("markdown contains `- **Available**:` bullet; drain wire is additive (drainEndsAt only):\n%s", content) } - if !strings.Contains(content, "| [alpha](#alpha) | http | — | 0.001 USDC/request | available |") { + if !strings.Contains(content, "| [alpha](#alpha) | http | — | 0.001 USDC/request on base | available |") { t.Errorf("active offer status missing `available` table signal:\n%s", content) } if !strings.Contains(content, "- **Drain ends at**:") { diff --git a/internal/x402/paymentrequired.go b/internal/x402/paymentrequired.go index 81853874..aa7e3152 100644 --- a/internal/x402/paymentrequired.go +++ b/internal/x402/paymentrequired.go @@ -336,14 +336,33 @@ func buildTypeCopy(siteURL, endpoint string, d PaymentDisplay) typeCopy { url := siteURL + endpoint switch normalizeOfferType(d.OfferType) { case "inference": - return inferenceCopy(url, d) + return inferenceCopy(url, siteURL, d) case "agent": - return agentCopy(url, d) + return agentCopy(url, siteURL, d) default: - return httpCopy(url, d) + return httpCopy(url, siteURL, d) } } +// x402GuideRef returns the self-contained "how to pay" pointer interpolated +// into the "other AI agent" copy prompts. Rather than send a foreign agent +// to the broad obol.org/llms.txt, we point it at THIS operator's own +// catalog (`/skill.md`, human + agent readable, with the full x402 v2 loop) +// and OpenAPI document (`/openapi.json`, exact request shapes) — both served +// over the same tunnel as the paid endpoint, so a single fetch is enough to +// learn how to pay. siteURL is the public origin (scheme://host); when empty +// the prompt degrades to a generic x402 mention. +func x402GuideRef(siteURL string) string { + siteURL = strings.TrimRight(siteURL, "/") + if siteURL == "" { + return "x402 micropayments (see https://www.x402.org)" + } + return fmt.Sprintf( + "x402 micropayments — read %s/skill.md for the full payment flow and %s/openapi.json for the exact request shapes", + siteURL, siteURL, + ) +} + // normalizeOfferType collapses the spec.type values into the three render // branches. Empty falls back to "inference" historically (the original // default), but the storefront defaults new offers to "http" — match that @@ -365,7 +384,7 @@ func normalizeOfferType(t string) string { // local LiteLLM gateway. Secondary cards still expose the agent-prompt and // raw-JSON paths, but reframed so users understand they're buying remote // model time, not an agent with tools/memory. -func inferenceCopy(url string, d PaymentDisplay) typeCopy { +func inferenceCopy(url, siteURL string, d PaymentDisplay) typeCopy { model := sanitizeDisplayToken(d.Model, "") // Positional seller URL, no required --model/--budget. Identity check @@ -382,11 +401,10 @@ func inferenceCopy(url string, d PaymentDisplay) typeCopy { ) other := fmt.Sprintf( - "Read https://obol.org/llms.txt to learn how Obol's x402 micropayments work. "+ - "I want to use the remote LLM at %s (model %s) as a paid OpenAI-compatible "+ - "chat-completions endpoint. Pre-sign a budget of EIP-3009/Permit2 authorizations "+ - "and POST chat-completions bodies with the X-PAYMENT header attached.", - url, model, + "I want to use the remote LLM at %s (model %s) as a paid OpenAI-compatible "+ + "chat-completions endpoint, paid with %s. Pre-sign a budget of EIP-3009/Permit2 "+ + "authorizations and POST chat-completions bodies with the X-PAYMENT header attached.", + url, model, x402GuideRef(siteURL), ) return typeCopy{ @@ -412,7 +430,7 @@ func inferenceCopy(url string, d PaymentDisplay) typeCopy { // Other-AI-Agent prompt cards drive the action, and a chat-completions // example sits next to the raw x402 JSON in the Pay-manually card to // make the wire shape obvious to readers walking the spec by hand. -func agentCopy(url string, d PaymentDisplay) typeCopy { +func agentCopy(url, siteURL string, d PaymentDisplay) typeCopy { model := sanitizeDisplayToken(d.Model, "") modelClause := "" modelLine := "" @@ -440,11 +458,11 @@ X-PAYMENT: ) other := fmt.Sprintf( - "Read https://obol.org/llms.txt to learn how Obol's x402 micropayments work. "+ - "Help me call the Obol Agent at %s%s — it's an autonomous agent (tools + skills + memory), "+ - "not a raw LLM. POST OpenAI-style chat-completions JSON with a real prompt in `messages`, "+ - "attach a signed EIP-3009/Permit2 authorization as `X-PAYMENT`, and report what the agent does.", - url, modelLine, + "Help me call the Obol Agent at %s%s — it's an autonomous agent (tools + skills + memory), "+ + "not a raw LLM. It's gated by %s. POST OpenAI-style chat-completions JSON with a real "+ + "prompt in `messages`, attach a signed EIP-3009/Permit2 authorization as `X-PAYMENT`, "+ + "and report what the agent does.", + url, modelLine, x402GuideRef(siteURL), ) return typeCopy{ @@ -468,7 +486,7 @@ X-PAYMENT: // httpCopy: legacy default. Stateless single-shot pay; no model, no // pre-payment, no LiteLLM mounting. Matches the pre-existing copy. -func httpCopy(url string, d PaymentDisplay) typeCopy { +func httpCopy(url, siteURL string, d PaymentDisplay) typeCopy { priceClause := "" if d.PriceDisplay != "" { priceClause = " Pay " + d.PriceDisplay + "." @@ -488,11 +506,9 @@ func httpCopy(url string, d PaymentDisplay) typeCopy { onNet = " on " + d.NetworkLabel } other := fmt.Sprintf( - "Read https://obol.org/llms.txt and skim https://github.com/ObolNetwork/skills "+ - "to learn how Obol Agents pay for x402 services. Then help me buy access to %s "+ - "for %s%s. Sign the EIP-3009 or Permit2 authorization and call the endpoint "+ - "with the X-PAYMENT header.", - url, priceWord, onNet, + "Help me buy access to %s for %s%s, paid with %s. Sign the EIP-3009 or Permit2 "+ + "authorization and call the endpoint with the X-PAYMENT header.", + url, priceWord, onNet, x402GuideRef(siteURL), ) return typeCopy{ diff --git a/internal/x402/paymentrequired_test.go b/internal/x402/paymentrequired_test.go index 4105c629..daf41a2c 100644 --- a/internal/x402/paymentrequired_test.go +++ b/internal/x402/paymentrequired_test.go @@ -136,14 +136,16 @@ func TestHTMLAware_RendersHTMLOnTextHTML(t *testing.T) { mustContain(t, body, `href="https://sepolia.basescan.org/address/`+testPayTo+`"`) mustContain(t, body, "View on block explorer") - // All three "ways to pay" prompts must be present, including the agent - // instructions referencing the buy-x402 skill, llms.txt, and the public - // skills repo. + // All three "ways to pay" prompts must be present. The "other AI agent" + // prompt now points at THIS operator's own self-contained docs (served + // over the same tunnel) rather than the broad obol.org/llms.txt: the + // /skill.md catalog (full x402 payment flow) and /openapi.json (exact + // request shapes). mustContain(t, body, "Pay with your Obol Agent") mustContain(t, body, "buy-x402 skill") mustContain(t, body, "Pay with another AI agent") - mustContain(t, body, "https://obol.org/llms.txt") - mustContain(t, body, "https://github.com/ObolNetwork/skills") + mustContain(t, body, "/skill.md") + mustContain(t, body, "/openapi.json") mustContain(t, body, "Pay manually (raw HTTP 402)") // Footer link back to the same tunnel root (storefront). @@ -426,7 +428,7 @@ func TestInferenceCopy_StripsShellMetacharsFromCommand(t *testing.T) { Model: "x; rm -rf ~", OfferName: "a && curl evil", } - c := inferenceCopy("https://agent.example.tunnel.dev/services/x", d) + c := inferenceCopy("https://agent.example.tunnel.dev/services/x", "https://agent.example.tunnel.dev", d) for _, bad := range []string{"rm -rf", "&&", "curl evil", ";"} { if strings.Contains(c.PrimaryPayload, bad) { t.Fatalf("hostile token leaked into command payload %q (contains %q)", c.PrimaryPayload, bad) diff --git a/web/public-storefront/src/components/ServiceCard.tsx b/web/public-storefront/src/components/ServiceCard.tsx index 6d165d89..34d9f796 100644 --- a/web/public-storefront/src/components/ServiceCard.tsx +++ b/web/public-storefront/src/components/ServiceCard.tsx @@ -23,6 +23,20 @@ function optionLabel(opt: ServicePayment): string { return `${opt.price.replace(/\s.*/, "")} ${sym} · ${opt.network}`; } +// docsRef points a foreign agent at THIS operator's own self-contained docs +// (served over the same tunnel as the endpoint) instead of the broad +// obol.org/llms.txt: /skill.md carries the full x402 payment flow and +// /openapi.json the exact request shapes. Falls back to a generic x402 +// pointer if the endpoint origin can't be parsed. +function docsRef(endpoint: string): string { + try { + const origin = new URL(endpoint).origin; + return `Read ${origin}/skill.md for the x402 payment flow and ${origin}/openapi.json for the exact request shapes.`; + } catch { + return "See https://www.x402.org for how x402 micropayments work."; + } +} + const typeColors: Record = { inference: "bg-obol-green/15 text-obol-green border border-obol-green/30", agent: "bg-obol-green/15 text-obol-green border border-obol-green/30", @@ -283,10 +297,10 @@ function BuyViaOtherAgent({ service, opt }: { service: Service; opt: ServicePaym let prompt: string; if (kind === "inference") { const model = service.model || "the advertised model"; - prompt = `Read https://obol.org/llms.txt to learn how Obol's x402 micropayments work. I want to use the remote LLM at ${service.endpoint} (model ${model}) as a paid OpenAI-compatible chat-completions endpoint. Pre-sign a budget of EIP-3009 or Permit2 authorisations and POST chat-completions bodies with the X-PAYMENT header attached.`; + prompt = `${docsRef(service.endpoint)} I want to use the remote LLM at ${service.endpoint} (model ${model}) as a paid OpenAI-compatible chat-completions endpoint. Pre-sign a budget of EIP-3009 or Permit2 authorisations and POST chat-completions bodies with the X-PAYMENT header attached.`; } else if (kind === "agent") { const modelLine = service.model ? ` (running ${service.model})` : ""; - prompt = `Read https://obol.org/llms.txt to learn how Obol's x402 micropayments work. Help me call the Obol Agent at ${service.endpoint}${modelLine} — it's an autonomous agent (tools + skills + memory), not a raw LLM. POST OpenAI-style chat-completions JSON with a real prompt in \`messages\`, attach a signed EIP-3009 or Permit2 authorisation as \`X-PAYMENT\`, and report what the agent does.`; + prompt = `${docsRef(service.endpoint)} Help me call the Obol Agent at ${service.endpoint}${modelLine} — it's an autonomous agent (tools + skills + memory), not a raw LLM. POST OpenAI-style chat-completions JSON with a real prompt in \`messages\`, attach a signed EIP-3009 or Permit2 authorisation as \`X-PAYMENT\`, and report what the agent does.`; } else { prompt = `I want to purchase a service offered by an Obol Agent at ${service.endpoint} for ${opt.price} on ${opt.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.`; }