From 9a78421a46283636af1ba25a522299e79042df67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ois=C3=ADn=20Kyne?= Date: Wed, 6 May 2026 14:12:16 +0100 Subject: [PATCH] Fix base registrations, pretty cli sell status --- cmd/obol/sell.go | 257 ++++++++++++++++++++++++++++++++--- cmd/obol/sell_test.go | 206 +++++++++++++++++++++++++++- internal/erc8004/abi.go | 4 + internal/erc8004/networks.go | 6 +- 4 files changed, 449 insertions(+), 24 deletions(-) diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 582ba18..42fb8a2 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -936,27 +936,130 @@ func serviceOfferStatusLines(namespace, name string, offer monetizeapi.ServiceOf if baseURL != "" && offer.Status.Endpoint != "" { endpoint = strings.TrimRight(baseURL, "/") + offer.Status.Endpoint } + + tx := valueOrNone(offer.Status.RegistrationTxHash) + if url := explorerTxURL(offer.Spec.Payment.Network, offer.Status.RegistrationTxHash); url != "" { + tx = url + } + + agentID := valueOrNone(offer.Status.AgentID) + if url := agentRegistryNFTURL(offer.Spec.Payment.Network, offer.Status.AgentID); url != "" { + agentID = fmt.Sprintf("%s (%s)", offer.Status.AgentID, url) + } + lines := []string{ fmt.Sprintf("ServiceOffer: %s/%s", namespace, name), fmt.Sprintf("Endpoint: %s", endpoint), - fmt.Sprintf("Agent ID: %s", valueOrNone(offer.Status.AgentID)), - fmt.Sprintf("Registration Tx: %s", valueOrNone(offer.Status.RegistrationTxHash)), + 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)), + fmt.Sprintf("Agent ID: %s", agentID), + fmt.Sprintf("Registration Tx: %s", tx), "", "Conditions:", } for _, cond := range offer.Status.Conditions { - lines = append(lines, fmt.Sprintf(" - type: %s", cond.Type)) - lines = append(lines, fmt.Sprintf(" status: %q", cond.Status)) - if cond.Reason != "" { - lines = append(lines, fmt.Sprintf(" reason: %s", cond.Reason)) - } - if cond.Message != "" { - lines = append(lines, fmt.Sprintf(" message: %s", cond.Message)) - } + lines = append(lines, formatConditionLine(cond)) } return lines } +// formatOfferAsset renders the payment asset as "SYMBOL" or +// "SYMBOL (0xaddr)" when the contract address is known. +func formatOfferAsset(asset monetizeapi.ServiceOfferAsset) string { + symbol := strings.TrimSpace(asset.Symbol) + addr := strings.TrimSpace(asset.Address) + switch { + case symbol == "" && addr == "": + return "(not set)" + case symbol == "": + return addr + case addr == "": + return symbol + default: + return fmt.Sprintf("%s (%s)", symbol, addr) + } +} + +// formatOfferPrice renders the price line for a ServiceOffer payment block, +// preferring per-request, then per-MTok, then per-hour. Asset symbol is +// included when available. +func formatOfferPrice(p monetizeapi.ServiceOfferPayment) string { + symbol := strings.TrimSpace(p.Asset.Symbol) + suffix := "" + if symbol != "" { + suffix = " " + symbol + } + switch { + case p.Price.PerRequest != "": + return fmt.Sprintf("%s%s per request", p.Price.PerRequest, suffix) + case p.Price.PerMTok != "": + return fmt.Sprintf("%s%s per MTok", p.Price.PerMTok, suffix) + case p.Price.PerHour != "": + return fmt.Sprintf("%s%s per hour", p.Price.PerHour, suffix) + default: + return "(not set)" + } +} + +// explorerTxURL returns the block-explorer URL for a transaction hash on the +// given network. Returns "" when the network is unknown or hash is empty. +func explorerTxURL(network, txHash string) string { + hash := strings.TrimSpace(txHash) + if hash == "" { + return "" + } + net, err := erc8004.ResolveNetwork(network) + if err != nil { + return "" + } + base := explorerBaseURL(net.Name) + if base == "" { + return "" + } + return fmt.Sprintf("%s/tx/%s", base, hash) +} + +// formatConditionLine renders a single condition with a status icon followed +// by the type, reason, and message. Icons: +// +// ✓ Status=True (success) — green check +// ℹ Status=True with Reason "Skipped" — informational +// or "Disabled", non-failure paths +// ⚠ Status=False — failure / blocked +// ⏳ Status=Unknown / empty — pending +func formatConditionLine(cond monetizeapi.Condition) string { + icon := conditionIcon(cond) + parts := []string{cond.Type} + if cond.Reason != "" { + parts = append(parts, cond.Reason) + } + header := strings.Join(parts, ": ") + if cond.Message != "" { + header = header + " — " + cond.Message + } + return fmt.Sprintf(" %s %s", icon, header) +} + +// conditionIcon picks a glyph based on the condition's status + reason. The +// glyphs are plain unicode (no lipgloss) so the function is safe to call from +// pure unit tests; coloring is applied at print time via the ui package. +func conditionIcon(cond monetizeapi.Condition) string { + switch cond.Status { + case "True": + switch cond.Reason { + case "Skipped", "Disabled": + return "ℹ" + } + return "✓" + case "False": + return "⚠" + default: + return "⏳" + } +} + // --------------------------------------------------------------------------- // sell demo — deploy a demo service behind x402 payment gate // --------------------------------------------------------------------------- @@ -1633,16 +1736,22 @@ func sellStatusCommand(cfg *config.Config) *cli.Command { return sellStatusGlobalJSON(cfg, u) } + tunnelURL, _ := tunnel.GetTunnelURL(cfg) + defaultWallet, _ := hermes.ResolveWalletAddress(cfg) + pricingCfg, err := x402verifier.GetPricingConfig(cfg) if err != nil { u.Warnf("Payment configuration not available (%v)", err) } else { u.Printf("Payment Configuration:") - u.Printf(" Wallet: %s", valueOrNone(pricingCfg.Wallet)) - u.Printf(" Chain: %s", valueOrNone(pricingCfg.Chain)) - u.Printf(" Facilitator: %s", valueOrNone(pricingCfg.FacilitatorURL)) - u.Printf(" Verify Only: %v", pricingCfg.VerifyOnly) - u.Printf(" Routes: %d", len(pricingCfg.Routes)) + if tunnelURL != "" { + u.Printf(" Tunnel URL: %s", tunnelURL) + } + u.Printf(" Default Wallet: %s", valueOrNone(defaultWallet)) + u.Printf(" Chain: %s", valueOrNone(pricingCfg.Chain)) + u.Printf(" Facilitator: %s", valueOrNone(pricingCfg.FacilitatorURL)) + u.Printf(" Verify Only: %v", pricingCfg.VerifyOnly) + u.Printf(" Routes: %d", len(pricingCfg.Routes)) for _, r := range pricingCfg.Routes { desc := r.Description if desc == "" { @@ -1657,11 +1766,59 @@ func sellStatusCommand(cfg *config.Config) *cli.Command { } } - u.Blank() + offers, offersErr := listServiceOffers(cfg) + if offersErr != nil { + u.Blank() + u.Warnf("Could not list services (%v)", offersErr) + } else { + u.Blank() + u.Printf("Agent Registrations:") + printed := 0 + for _, o := range offers { + if o.Status.AgentID == "" && o.Status.RegistrationTxHash == "" { + continue + } + tx := valueOrNone(o.Status.RegistrationTxHash) + if url := explorerTxURL(o.Spec.Payment.Network, o.Status.RegistrationTxHash); url != "" { + tx = url + } + line := fmt.Sprintf(" %s/%s agent=%s tx=%s", + o.Namespace, o.Name, + valueOrNone(o.Status.AgentID), + tx) + if url := agentRegistryNFTURL(o.Spec.Payment.Network, o.Status.AgentID); url != "" { + line = line + " " + url + } + u.Printf(line) + printed++ + } + if printed == 0 { + u.Printf(" (no agents registered)") + } - u.Printf("ERC-8004 Agent Registration:") - kubectlRun(cfg, "get", "serviceoffers.obol.org", "-A", - "-o", "custom-columns=NAMESPACE:.metadata.namespace,NAME:.metadata.name,AGENT_ID:.status.agentId,TX:.status.registrationTxHash,REGISTERED:.status.conditions[?(@.type=='Registered')].status") + u.Blank() + u.Printf("Services:") + if len(offers) == 0 { + u.Printf(" (no services published)") + } else { + for _, o := range offers { + registered := isConditionTrue(o.Status.Conditions, "Registered") + mark := "✗" + if registered { + mark = "✓" + } + tx := valueOrNone(o.Status.RegistrationTxHash) + if url := explorerTxURL(o.Spec.Payment.Network, o.Status.RegistrationTxHash); url != "" { + tx = url + } + u.Printf(" %s/%s registered=%s tx=%s", + o.Namespace, o.Name, mark, tx) + } + u.Blank() + u.Dim(fmt.Sprintf("See detailed service information with e.g. `obol sell status %s -n %s`", + offers[0].Name, offers[0].Namespace)) + } + } // Also show local inference gateway deployments. store := inference.NewStore(cfg.ConfigDir) @@ -1681,6 +1838,68 @@ func sellStatusCommand(cfg *config.Config) *cli.Command { } } +// agentRegistryNFTURL returns the block-explorer URL for an ERC-8004 agent +// registration NFT — `/nft//` — on the given +// network. The registry address is sourced from erc8004.ResolveNetwork: Base +// mainnet and Ethereum mainnet share one address (CREATE2), Base Sepolia is +// a separate deployment. Returns "" when the network is unknown or the +// agent ID is empty. +func agentRegistryNFTURL(network, agentID string) string { + if strings.TrimSpace(agentID) == "" { + return "" + } + net, err := erc8004.ResolveNetwork(network) + if err != nil { + return "" + } + base := explorerBaseURL(net.Name) + if base == "" { + return "" + } + return fmt.Sprintf("%s/nft/%s/%s", base, net.RegistryAddress, agentID) +} + +// explorerBaseURL maps a canonical network name to its block explorer base. +// Returns "" for networks without a public explorer (e.g. local Anvil). +func explorerBaseURL(canonicalName string) string { + switch canonicalName { + case "ethereum": + return "https://etherscan.io" + case "base": + return "https://basescan.org" + case "base-sepolia": + return "https://sepolia.basescan.org" + } + return "" +} + +// isConditionTrue reports whether the named condition exists with Status=True. +func isConditionTrue(conds []monetizeapi.Condition, name string) bool { + for _, c := range conds { + if c.Type == name { + return c.Status == "True" + } + } + return false +} + +// listServiceOffers fetches all ServiceOffers across all namespaces and parses +// them into typed structs. Returns a nil slice when the cluster is unreachable +// or the CRD is not installed. +func listServiceOffers(cfg *config.Config) ([]monetizeapi.ServiceOffer, error) { + raw, err := kubectlOutput(cfg, "get", "serviceoffers.obol.org", "-A", "-o", "json") + if err != nil { + return nil, err + } + var list struct { + Items []monetizeapi.ServiceOffer `json:"items"` + } + if err := json.Unmarshal([]byte(raw), &list); err != nil { + return nil, fmt.Errorf("parse ServiceOffer list: %w", err) + } + return list.Items, nil +} + // sellStatusGlobalJSON outputs the global sell status as JSON. func sellStatusGlobalJSON(cfg *config.Config, u *ui.UI) error { type routeJSON struct { diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index 1d96242..7f58b58 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -248,6 +248,17 @@ func TestBuildSellHTTPRegistrationConfig_NoRegisterConflicts(t *testing.T) { func TestServiceOfferStatusLines(t *testing.T) { offer := monetizeapi.ServiceOffer{ + Spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base-sepolia", + PayTo: "0xd0391eedc3268f3deef1f05fff5d7aef82f64ccf", + Asset: monetizeapi.ServiceOfferAsset{ + Symbol: "USDC", + Address: "0x036C...", + }, + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + }, Status: monetizeapi.ServiceOfferStatus{ Endpoint: "/services/demo", AgentID: "5008", @@ -261,9 +272,14 @@ func TestServiceOfferStatusLines(t *testing.T) { joined := strings.Join(lines, "\n") for _, want := range []string{ "ServiceOffer: llm/demo", - "Agent ID: 5008", - "Registration Tx: 0xabc", - "type: Registered", + "Network: base-sepolia", + "Asset: USDC (0x036C...)", + "Price: 0.001 USDC per request", + "Pay To: 0xd0391eedc3268f3deef1f05fff5d7aef82f64ccf", + "Agent ID: 5008 (https://sepolia.basescan.org/nft/0x8004A818BFB912233c491871b3d84c89A494BD9e/5008)", + "Registration Tx: https://sepolia.basescan.org/tx/0xabc", + "✓ Registered", + "Published registration document and recorded agent 5008", } { if !strings.Contains(joined, want) { t.Fatalf("status lines missing %q\n%s", want, joined) @@ -271,6 +287,190 @@ func TestServiceOfferStatusLines(t *testing.T) { } } +func TestServiceOfferStatusLines_RawTxFallback(t *testing.T) { + // Unknown network: fall back to raw hash (no explorer link). + offer := monetizeapi.ServiceOffer{ + Spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{Network: "polygon"}, + }, + Status: monetizeapi.ServiceOfferStatus{RegistrationTxHash: "0xdeadbeef"}, + } + lines := serviceOfferStatusLines("llm", "demo", offer, "") + joined := strings.Join(lines, "\n") + if !strings.Contains(joined, "Registration Tx: 0xdeadbeef") { + t.Fatalf("expected raw tx fallback, got:\n%s", joined) + } + if strings.Contains(joined, "https://") { + t.Fatalf("unexpected URL in tx line for unknown network:\n%s", joined) + } +} + +func TestFormatOfferAsset(t *testing.T) { + tests := []struct { + name string + asset monetizeapi.ServiceOfferAsset + want string + }{ + {"both", monetizeapi.ServiceOfferAsset{Symbol: "USDC", Address: "0x036C"}, "USDC (0x036C)"}, + {"symbol only", monetizeapi.ServiceOfferAsset{Symbol: "OBOL"}, "OBOL"}, + {"address only", monetizeapi.ServiceOfferAsset{Address: "0xabc"}, "0xabc"}, + {"empty", monetizeapi.ServiceOfferAsset{}, "(not set)"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatOfferAsset(tt.asset); got != tt.want { + t.Errorf("formatOfferAsset = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormatOfferPrice(t *testing.T) { + tests := []struct { + name string + p monetizeapi.ServiceOfferPayment + want string + }{ + { + "per request with symbol", + monetizeapi.ServiceOfferPayment{ + Asset: monetizeapi.ServiceOfferAsset{Symbol: "USDC"}, + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + "0.001 USDC per request", + }, + { + "per request no symbol", + monetizeapi.ServiceOfferPayment{ + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + "0.001 per request", + }, + { + "per MTok", + monetizeapi.ServiceOfferPayment{ + Asset: monetizeapi.ServiceOfferAsset{Symbol: "USDC"}, + Price: monetizeapi.ServiceOfferPriceTable{PerMTok: "5"}, + }, + "5 USDC per MTok", + }, + { + "per hour", + monetizeapi.ServiceOfferPayment{ + Asset: monetizeapi.ServiceOfferAsset{Symbol: "USDC"}, + Price: monetizeapi.ServiceOfferPriceTable{PerHour: "1"}, + }, + "1 USDC per hour", + }, + { + "empty", + monetizeapi.ServiceOfferPayment{}, + "(not set)", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatOfferPrice(tt.p); got != tt.want { + t.Errorf("formatOfferPrice = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExplorerTxURL(t *testing.T) { + tests := []struct { + name string + network string + hash string + want string + }{ + {"ethereum", "ethereum", "0xabc", "https://etherscan.io/tx/0xabc"}, + {"base", "base", "0xabc", "https://basescan.org/tx/0xabc"}, + {"base-sepolia", "base-sepolia", "0xabc", "https://sepolia.basescan.org/tx/0xabc"}, + {"unknown network", "polygon", "0xabc", ""}, + {"empty hash", "ethereum", "", ""}, + {"whitespace hash", "ethereum", " ", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := explorerTxURL(tt.network, tt.hash); got != tt.want { + t.Errorf("explorerTxURL(%q, %q) = %q, want %q", tt.network, tt.hash, got, tt.want) + } + }) + } +} + +func TestConditionIcon(t *testing.T) { + tests := []struct { + name string + cond monetizeapi.Condition + want string + }{ + {"true succeeded", monetizeapi.Condition{Status: "True", Reason: "Reconciled"}, "✓"}, + {"true skipped", monetizeapi.Condition{Status: "True", Reason: "Skipped"}, "ℹ"}, + {"true disabled", monetizeapi.Condition{Status: "True", Reason: "Disabled"}, "ℹ"}, + {"false failed", monetizeapi.Condition{Status: "False", Reason: "Unhealthy"}, "⚠"}, + {"unknown pending", monetizeapi.Condition{Status: "Unknown"}, "⏳"}, + {"empty pending", monetizeapi.Condition{}, "⏳"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := conditionIcon(tt.cond); got != tt.want { + t.Errorf("conditionIcon = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAgentRegistryNFTURL(t *testing.T) { + const ( + mainnetReg = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432" + sepoliaReg = "0x8004A818BFB912233c491871b3d84c89A494BD9e" + ) + tests := []struct { + name string + network string + agentID string + want string + }{ + {"ethereum", "ethereum", "32117", "https://etherscan.io/nft/" + mainnetReg + "/32117"}, + {"mainnet alias", "mainnet", "32117", "https://etherscan.io/nft/" + mainnetReg + "/32117"}, + {"base shares mainnet registry", "base", "42", "https://basescan.org/nft/" + mainnetReg + "/42"}, + {"base-sepolia distinct registry", "base-sepolia", "1", "https://sepolia.basescan.org/nft/" + sepoliaReg + "/1"}, + {"unknown network", "polygon", "1", ""}, + {"empty network", "", "1", ""}, + {"empty agent id", "ethereum", "", ""}, + {"whitespace agent id", "ethereum", " ", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := agentRegistryNFTURL(tt.network, tt.agentID); got != tt.want { + t.Errorf("agentRegistryNFTURL(%q, %q) = %q, want %q", tt.network, tt.agentID, got, tt.want) + } + }) + } +} + +func TestIsConditionTrue(t *testing.T) { + conds := []monetizeapi.Condition{ + {Type: "Ready", Status: "True"}, + {Type: "Registered", Status: "False"}, + {Type: "Pending", Status: "Unknown"}, + } + if !isConditionTrue(conds, "Ready") { + t.Error("Ready should be true") + } + if isConditionTrue(conds, "Registered") { + t.Error("Registered should be false") + } + if isConditionTrue(conds, "Pending") { + t.Error("Pending should be false") + } + if isConditionTrue(conds, "Missing") { + t.Error("Missing should be false") + } +} + // TestServiceOfferStatusLines_FullURL verifies that when the tunnel URL is // passed as baseURL, the Endpoint line shows the full https://… URL buyers // would actually hit (not just the path). Trailing slashes on the base URL diff --git a/internal/erc8004/abi.go b/internal/erc8004/abi.go index e0a7eb5..7598181 100644 --- a/internal/erc8004/abi.go +++ b/internal/erc8004/abi.go @@ -9,6 +9,10 @@ const ( // IdentityRegistryBaseSepolia is the ERC-8004 Identity Registry on Base Sepolia. IdentityRegistryBaseSepolia = "0x8004A818BFB912233c491871b3d84c89A494BD9e" + // IdentityRegistryMainnet is the ERC-8004 Identity Registry on Ethereum + // mainnet and Base mainnet (deployed at the same address via CREATE2). + IdentityRegistryMainnet = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432" + // ReputationRegistryBaseSepolia is the ERC-8004 Reputation Registry on Base Sepolia. ReputationRegistryBaseSepolia = "0x8004B663056A597Dffe9eCcC1965A193B7388713" diff --git a/internal/erc8004/networks.go b/internal/erc8004/networks.go index 5ce9a4d..20351be 100644 --- a/internal/erc8004/networks.go +++ b/internal/erc8004/networks.go @@ -35,17 +35,19 @@ var ( ERPCNetwork: "base-sepolia", } + // Base mainnet and Ethereum mainnet share the same Identity Registry + // address (deployed via CREATE2). Base Sepolia is a separate deployment. Base = NetworkConfig{ Name: "base", ChainID: 8453, - RegistryAddress: IdentityRegistryBaseSepolia, // CREATE2 — same address across chains + RegistryAddress: IdentityRegistryMainnet, ERPCNetwork: "base", } Ethereum = NetworkConfig{ Name: "ethereum", ChainID: 1, - RegistryAddress: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432", + RegistryAddress: IdentityRegistryMainnet, ERPCNetwork: "mainnet", } )