diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 6ba344ed..724a14bf 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -184,6 +184,34 @@ Examples: Name: "provenance-file", Usage: "Path to JSON file with provenance metadata (e.g. autoresearch experiment results)", }, + &cli.BoolFlag{ + Name: "no-register", + Usage: "Skip ERC-8004 registration (no /.well-known/agent-registration.json HTTPRoute is published)", + }, + &cli.StringFlag{ + Name: "register-name", + Usage: "Agent name for ERC-8004 registration (defaults to the offer name)", + }, + &cli.StringFlag{ + Name: "register-description", + Usage: "Agent description for ERC-8004 registration", + }, + &cli.StringFlag{ + Name: "register-image", + Usage: "Agent image URL for ERC-8004 registration", + }, + &cli.StringSliceFlag{ + Name: "register-skills", + Usage: "OASF skill tags for ERC-8004 registration (repeatable)", + }, + &cli.StringSliceFlag{ + Name: "register-domains", + Usage: "OASF domain tags for ERC-8004 registration (repeatable)", + }, + &cli.StringSliceFlag{ + Name: "register-metadata", + Usage: "Additional registration metadata as key=value pairs (repeatable, e.g. gpu=A100-80GB)", + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { u := getUI(cmd) @@ -384,7 +412,19 @@ Examples: d.NoPaymentGate = false } else { // Create a ServiceOffer CR pointing at the host service. - soSpec, err := buildInferenceServiceOfferSpec(d, priceTable, svcNs, port, assetTerms) + reg, _, regErr := buildSellRegistrationConfig(name, sellRegistrationInput{ + NoRegister: cmd.Bool("no-register"), + Name: cmd.String("register-name"), + Description: cmd.String("register-description"), + Image: cmd.String("register-image"), + Skills: cmd.StringSlice("register-skills"), + Domains: cmd.StringSlice("register-domains"), + MetadataPairs: cmd.StringSlice("register-metadata"), + }) + if regErr != nil { + return regErr + } + soSpec, err := buildInferenceServiceOfferSpec(d, priceTable, svcNs, port, assetTerms, modelFlag, reg) if err != nil { return err } @@ -408,12 +448,36 @@ Examples: u.Blank() u.Info("Ensuring tunnel is active for public access...") - if tunnelURL, tErr := tunnel.EnsureTunnelForSell(cfg, u); tErr != nil { + tunnelURL := "" + if url, tErr := tunnel.EnsureTunnelForSell(cfg, u); tErr != nil { u.Warnf("Tunnel not started: %v", tErr) u.Dim(" Start manually with: obol tunnel restart") } else { + tunnelURL = url u.Successf("Tunnel active: %s", tunnelURL) } + + // Auto-register the seller on ERC-8004, mirroring the + // `obol sell http` path. Without this step the offer + // stays in Registered=False AwaitingExternalRegistration + // forever, which makes Ready=False and excludes the + // offer from /api/services.json (the storefront feed + // only includes Ready=True offers). + if shouldAutoRegisterSell(soSpec, tunnelURL) { + reg, _ := soSpec["registration"].(map[string]any) + u.Blank() + u.Info("Registering seller agent on ERC-8004...") + if err := autoRegisterServiceOffer(ctx, cfg, u, autoRegisterOptions{ + ChainCSV: cmd.String("chain"), + Endpoint: tunnelURL, + AgentName: registrationNameForPrompt(name, reg), + AgentDesc: registrationDescriptionForPrompt(name, reg), + ExpectedOwner: wallet, + }); err != nil { + u.Warnf("automatic sell registration failed: %v", err) + u.Dim(" Re-run later with: obol sell register " + name + " -n " + svcNs) + } + } } } } @@ -701,7 +765,7 @@ Examples: prov.Framework, prov.MetricName, prov.MetricValue, prov.ParamCount) } - reg, registerEnabled, err := buildSellHTTPRegistrationConfig(name, sellHTTPRegistrationInput{ + reg, registerEnabled, err := buildSellRegistrationConfig(name, sellRegistrationInput{ NoRegister: cmd.Bool("no-register"), Register: cmd.Bool("register"), Name: cmd.String("register-name"), @@ -799,6 +863,103 @@ Examples: } } +// signerPayeeDelegationNote returns a human-readable note explaining the +// ownership delegation when the agent's on-chain registration signer differs +// from the offer's payment payTo wallet, and "" when they match (or either +// is empty). Used by the auto-register flow to surface the split as an +// informational message rather than blocking the registration outright — +// ERC-8004 explicitly supports this separation via setAgentWallet, and x402 +// settlement uses the offer's payTo regardless of the registry's wallet. +// +// The previous behavior (returning fmt.Errorf("registration signer ... does +// not match the payment wallet ...")) made it look like an ERC-8004 spec +// constraint when it was purely an obol-CLI policy choice. See PR notes for +// the full rationale. +func signerPayeeDelegationNote(signer, payTo string) string { + s := strings.TrimSpace(signer) + p := strings.TrimSpace(payTo) + if s == "" || p == "" || strings.EqualFold(s, p) { + return "" + } + return fmt.Sprintf( + "Agent owner (registration signer) %s differs from offer payTo %s. "+ + "ERC-8004 allows this via setAgentWallet; x402 settlement honors payTo regardless. "+ + "Re-point payments later with: obol sell update --pay-to ", + s, p, + ) +} + +// buildSellUpdatePatch assembles the JSON-merge patch body for +// `obol sell update`. Extracted from the Action so the patch shape — the +// thing that actually hits the cluster — is testable without a live ServiceOffer. +// +// Returns the patch map and an error when nothing was provided (the Action +// surfaces this as "nothing to update: pass at least one of ..."). +// +// When --pay-to is set, the wallet must already have been validated by +// x402verifier.ValidateWallet at the call site; this helper does no further +// validation so it stays a pure data shape. +func buildSellUpdatePatch(payTo, chain string, price schemas.PriceTable) (map[string]any, error) { + payment := map[string]any{} + + if payTo = strings.TrimSpace(payTo); payTo != "" { + payment["payTo"] = payTo + } + if chain = strings.TrimSpace(chain); chain != "" { + payment["network"] = chain + } + + if price.PerRequest != "" || price.PerMTok != "" || price.PerHour != "" { + // Null out the unused price keys explicitly so a switch from, e.g., + // perRequest→perMTok doesn't leave the previous key stranded and + // fighting the new one through merge semantics. + p := map[string]any{ + "perRequest": nil, + "perMTok": nil, + "perHour": nil, + } + switch { + case price.PerRequest != "": + p["perRequest"] = price.PerRequest + case price.PerMTok != "": + p["perMTok"] = price.PerMTok + case price.PerHour != "": + p["perHour"] = price.PerHour + } + payment["price"] = p + } + + 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 map[string]any{ + "spec": map[string]any{ + "payment": payment, + }, + }, nil +} + +// 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 +// a tunnel URL must be available to write into the on-chain registration +// document. The decision is intentionally shared so the inference path +// cannot silently regress to "create the offer, never register" (the bug +// behind https://github.com/ObolNetwork/obol-stack/issues — Registered=False +// AwaitingExternalRegistration hiding the offer from /api/services.json). +func shouldAutoRegisterSell(spec map[string]any, tunnelURL string) bool { + if tunnelURL == "" { + return false + } + reg, ok := spec["registration"].(map[string]any) + if !ok { + return false + } + enabled, _ := reg["enabled"].(bool) + return enabled +} + func registrationNameForPrompt(fallback string, reg map[string]any) string { if reg == nil { return fallback @@ -827,7 +988,7 @@ type autoRegisterOptions struct { ExpectedOwner string } -type sellHTTPRegistrationInput struct { +type sellRegistrationInput struct { NoRegister bool Register bool Name string @@ -874,8 +1035,19 @@ func autoRegisterServiceOffer(ctx context.Context, cfg *config.Config, u *ui.UI, } signerAddr := addr.Hex() - if opts.ExpectedOwner != "" && !strings.EqualFold(strings.TrimSpace(opts.ExpectedOwner), strings.TrimSpace(signerAddr)) { - return fmt.Errorf("registration signer %s does not match the payment wallet %s.\nUse a matching signer, omit --wallet so the remote-signer wallet is used, or pass --no-register", signerAddr, opts.ExpectedOwner) + // ERC-8004 treats the agent OWNER (msg.sender at register time) and the + // agent WALLET (settable post-mint via setAgentWallet) as independent + // addresses. x402 settlement honors the offer's spec.payment.payTo + // directly — buyers pay that address regardless of what the registry's + // getAgentWallet returns. So a different signer and payTo is legitimate; + // it is the canonical pattern for "hot signer, cold/multisig payee". + // + // We surface the split as an informational note (not an error) so the + // operator can confirm the delegation was intentional, and so the obvious + // follow-up — `obol sell update --pay-to ` for the payee — is + // discoverable. + if note := signerPayeeDelegationNote(signerAddr, opts.ExpectedOwner); note != "" { + u.Info(note) } agentURI := strings.TrimRight(opts.Endpoint, "/") + "/.well-known/agent-registration.json" @@ -907,7 +1079,7 @@ func autoRegisterServiceOffer(ctx context.Context, cfg *config.Config, u *ui.UI, return nil } -func buildSellHTTPRegistrationConfig(serviceName string, in sellHTTPRegistrationInput) (map[string]any, bool, error) { +func buildSellRegistrationConfig(serviceName string, in sellRegistrationInput) (map[string]any, bool, error) { registerEnabled := !in.NoRegister if !registerEnabled && (in.Register || in.Name != "" || in.Description != "" || in.Image != "" || len(in.Skills) > 0 || len(in.Domains) > 0 || len(in.MetadataPairs) > 0) { @@ -2244,50 +2416,25 @@ Examples: return fmt.Errorf("ServiceOffer %s/%s not found: %w", ns, name, err) } - payment := map[string]any{} - - if wallet := strings.TrimSpace(cmd.String("pay-to")); wallet != "" { + wallet := strings.TrimSpace(cmd.String("pay-to")) + if wallet != "" { if err := x402verifier.ValidateWallet(wallet); err != nil { return err } - payment["payTo"] = wallet - } - - if chain := strings.TrimSpace(cmd.String("chain")); chain != "" { - payment["network"] = chain } - priceSet := cmd.String("price") != "" || cmd.String("per-request") != "" || cmd.String("per-mtok") != "" || cmd.String("per-hour") != "" - if priceSet { - priceTable, err := resolvePriceTable(cmd, true) + 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 := map[string]any{ - "perRequest": nil, - "perMTok": nil, - "perHour": nil, - } - switch { - case priceTable.PerRequest != "": - price["perRequest"] = priceTable.PerRequest - case priceTable.PerMTok != "": - price["perMTok"] = priceTable.PerMTok - case priceTable.PerHour != "": - price["perHour"] = priceTable.PerHour - } - payment["price"] = price - } - - if len(payment) == 0 { - return errors.New("nothing to update: pass at least one of --per-request / --per-mtok / --per-hour / --wallet / --chain") + price = resolved } - patch := map[string]any{ - "spec": map[string]any{ - "payment": payment, - }, + patch, err := buildSellUpdatePatch(wallet, cmd.String("chain"), price) + if err != nil { + return err } patchBytes, err := json.Marshal(patch) if err != nil { @@ -3383,7 +3530,16 @@ func resolveHostIP(cfg *config.Config) (string, error) { // buildInferenceServiceOfferSpec builds a ServiceOffer spec for a host-side // inference gateway routed through the cluster's x402 flow. -func buildInferenceServiceOfferSpec(d *inference.Deployment, pt schemas.PriceTable, ns, port string, asset schemas.AssetTerms) (map[string]any, error) { +// +// modelName is the upstream model identifier the operator passed via --model +// (e.g. "aeon-ultimate"). It is written into spec.model.name so the +// serviceoffer-controller's registration document carries the real model id +// rather than the historical hardcoded "ollama" string. +// +// registration is the operator's ERC-8004 registration block as produced by +// buildSellRegistrationConfig — pass nil (or an empty map) to skip +// registration. When non-nil it is merged verbatim into spec.registration. +func buildInferenceServiceOfferSpec(d *inference.Deployment, pt schemas.PriceTable, ns, port string, asset schemas.AssetTerms, modelName string, registration map[string]any) (map[string]any, error) { portNum, err := strconv.Atoi(port) if err != nil || portNum < 1 || portNum > 65535 { return nil, fmt.Errorf("invalid port %q: must be a number between 1 and 65535", port) @@ -3416,12 +3572,20 @@ func buildInferenceServiceOfferSpec(d *inference.Deployment, pt schemas.PriceTab } if d.UpstreamURL != "" { + model := strings.TrimSpace(modelName) + if model == "" { + model = "ollama" // pre-fix fallback; the Action enforces --model + } spec["model"] = map[string]any{ - "name": "ollama", + "name": model, "runtime": "ollama", } } + if len(registration) > 0 { + spec["registration"] = registration + } + return spec, nil } diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index adffbfa3..7ec30a05 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -2,10 +2,12 @@ package main import ( "errors" + "os" "strings" "testing" "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/inference" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" "github.com/ObolNetwork/obol-stack/internal/schemas" x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" @@ -185,9 +187,19 @@ func TestSellInference_Flags(t *testing.T) { "listen", "upstream", "enclave-tag", "vm", "vm-image", "vm-cpus", "vm-memory", "vm-host-port", "tee", "model-hash", + // Registration parity with `obol sell http`. Their absence on the + // inference subcommand was the regression that left + // /.well-known/agent-registration.json unrouted (see + // TestBuildInferenceServiceOfferSpec_RegistrationEnabledByDefault). + "no-register", + "register-name", "register-description", "register-image", + "register-skills", "register-domains", "register-metadata", ) - assertStringDefault(t, flags, "price", "0.001") + // --price intentionally has no default after #470 — the resolvePriceTable + // fallthrough requires an explicit price flag instead of letting "0.001" + // shadow --per-mtok / --per-request. Empty default is the pinned contract. + assertStringDefault(t, flags, "price", "") assertStringDefault(t, flags, "chain", "base") assertStringDefault(t, flags, "token", "USDC") assertStringDefault(t, flags, "listen", ":8402") @@ -256,8 +268,8 @@ func TestSellHTTP_Flags(t *testing.T) { assertIntDefault(t, flags, "max-timeout", 300) } -func TestBuildSellHTTPRegistrationConfig_DefaultEnabled(t *testing.T) { - reg, enabled, err := buildSellHTTPRegistrationConfig("demo", sellHTTPRegistrationInput{}) +func TestBuildSellRegistrationConfig_DefaultEnabled(t *testing.T) { + reg, enabled, err := buildSellRegistrationConfig("demo", sellRegistrationInput{}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -272,8 +284,8 @@ func TestBuildSellHTTPRegistrationConfig_DefaultEnabled(t *testing.T) { } } -func TestBuildSellHTTPRegistrationConfig_NoRegisterConflicts(t *testing.T) { - _, _, err := buildSellHTTPRegistrationConfig("demo", sellHTTPRegistrationInput{ +func TestBuildSellRegistrationConfig_NoRegisterConflicts(t *testing.T) { + _, _, err := buildSellRegistrationConfig("demo", sellRegistrationInput{ NoRegister: true, Name: "custom", }) @@ -755,6 +767,462 @@ func TestDemoRPCNetwork(t *testing.T) { } } +// TestBuildInferenceServiceOfferSpec_RegistrationEnabledByDefault pins the +// fix for the missing-registration regression: `obol sell inference` used to +// build a ServiceOffer with empty `spec.registration`, so the controller +// emitted "Registration disabled" and never published the +// /.well-known/agent-registration.json HTTPRoute. The default contract is +// "registration enabled, name derived from offer name" — same as `sell http`. +func TestBuildInferenceServiceOfferSpec_RegistrationEnabledByDefault(t *testing.T) { + d := &inference.Deployment{ + Name: "aeon", + UpstreamURL: "http://127.0.0.1:8000", + WalletAddress: "0xeFAb75b7b199bf8512e2d5b379374Cb94dfdBA47", + Chain: "base-sepolia", + PricePerRequest: "0.001", + } + reg, enabled, err := buildSellRegistrationConfig("aeon", sellRegistrationInput{}) + if err != nil { + t.Fatalf("buildSellRegistrationConfig: %v", err) + } + if !enabled { + t.Fatal("registration should default to enabled") + } + spec, err := buildInferenceServiceOfferSpec(d, schemas.PriceTable{PerRequest: "0.001"}, "llm", "8402", schemas.AssetTerms{}, "aeon-ultimate", reg) + if err != nil { + t.Fatalf("buildInferenceServiceOfferSpec: %v", err) + } + got, ok := spec["registration"].(map[string]any) + if !ok { + t.Fatalf("spec.registration missing or wrong type: %#v", spec["registration"]) + } + if got["enabled"] != true { + t.Errorf("spec.registration.enabled = %v, want true", got["enabled"]) + } + if got["name"] != "aeon" { + t.Errorf("spec.registration.name = %v, want %q", got["name"], "aeon") + } +} + +// TestBuildInferenceServiceOfferSpec_NoRegisterOmitsRegistration confirms the +// opt-out path: --no-register produces an empty registration map and the spec +// builder must leave spec.registration unset (so the controller emits the +// well-known route only when explicitly requested). +func TestBuildInferenceServiceOfferSpec_NoRegisterOmitsRegistration(t *testing.T) { + d := &inference.Deployment{ + Name: "aeon", UpstreamURL: "http://127.0.0.1:8000", + WalletAddress: "0xeFAb75b7b199bf8512e2d5b379374Cb94dfdBA47", + Chain: "base-sepolia", PricePerRequest: "0.001", + } + reg, enabled, err := buildSellRegistrationConfig("aeon", sellRegistrationInput{NoRegister: true}) + if err != nil { + t.Fatalf("buildSellRegistrationConfig: %v", err) + } + if enabled { + t.Fatal("--no-register should disable registration") + } + spec, err := buildInferenceServiceOfferSpec(d, schemas.PriceTable{PerRequest: "0.001"}, "llm", "8402", schemas.AssetTerms{}, "aeon-ultimate", reg) + if err != nil { + t.Fatalf("buildInferenceServiceOfferSpec: %v", err) + } + if _, present := spec["registration"]; present { + t.Errorf("spec.registration should be absent when --no-register; got %#v", spec["registration"]) + } +} + +// TestBuildInferenceServiceOfferSpec_OperatorOverridesWin pins the fix for +// the controller-side description-clobber regression in +// internal/serviceoffercontroller/render.go: the operator's explicit +// registration fields (name, description, image, skills, domains) must +// survive into the published spec verbatim, not be silently replaced by +// controller-side defaults. This test covers the *spec input*; the +// controller-side guarantee is covered by the companion test in +// serviceoffercontroller/render_test.go. +func TestBuildInferenceServiceOfferSpec_OperatorOverridesWin(t *testing.T) { + d := &inference.Deployment{ + Name: "aeon", UpstreamURL: "http://127.0.0.1:8000", + WalletAddress: "0xeFAb75b7b199bf8512e2d5b379374Cb94dfdBA47", + Chain: "base-sepolia", PricePerRequest: "0.001", + } + reg, _, err := buildSellRegistrationConfig("aeon", sellRegistrationInput{ + Name: "Qwen36 AEON Ultimate", + Description: "Uncensored Qwen3.6-27B abliteration on DGX Spark", + Image: "https://example.com/aeon.png", + Skills: []string{"llm/inference", "llm/uncensored"}, + Domains: []string{"inference.v1337.org"}, + }) + if err != nil { + t.Fatalf("buildSellRegistrationConfig: %v", err) + } + spec, err := buildInferenceServiceOfferSpec(d, schemas.PriceTable{PerRequest: "0.001"}, "llm", "8402", schemas.AssetTerms{}, "aeon-ultimate", reg) + if err != nil { + t.Fatalf("buildInferenceServiceOfferSpec: %v", err) + } + r := spec["registration"].(map[string]any) + for k, want := range map[string]any{ + "name": "Qwen36 AEON Ultimate", + "description": "Uncensored Qwen3.6-27B abliteration on DGX Spark", + "image": "https://example.com/aeon.png", + } { + if r[k] != want { + t.Errorf("spec.registration.%s = %#v, want %#v", k, r[k], want) + } + } + skills, _ := r["skills"].([]string) + if len(skills) != 2 || skills[0] != "llm/inference" { + t.Errorf("spec.registration.skills = %#v, want [llm/inference llm/uncensored]", r["skills"]) + } + domains, _ := r["domains"].([]string) + if len(domains) != 1 || domains[0] != "inference.v1337.org" { + t.Errorf("spec.registration.domains = %#v, want [inference.v1337.org]", r["domains"]) + } +} + +// TestBuildInferenceServiceOfferSpec_ModelNameNotHardcoded pins the fix for +// the "spec.model.name = ollama regardless of --model" regression. The model +// id the operator passed must surface in spec.model.name so the controller's +// per-model registration description (and any downstream tooling that keys +// off model name) reads the truth, not the historical hardcoded literal. +func TestBuildInferenceServiceOfferSpec_ModelNameNotHardcoded(t *testing.T) { + d := &inference.Deployment{ + Name: "aeon", UpstreamURL: "http://127.0.0.1:8000", + WalletAddress: "0xeFAb75b7b199bf8512e2d5b379374Cb94dfdBA47", + Chain: "base-sepolia", PricePerRequest: "0.001", + } + spec, err := buildInferenceServiceOfferSpec(d, schemas.PriceTable{PerRequest: "0.001"}, "llm", "8402", schemas.AssetTerms{}, "aeon-ultimate", nil) + if err != nil { + t.Fatalf("buildInferenceServiceOfferSpec: %v", err) + } + model, ok := spec["model"].(map[string]any) + if !ok { + t.Fatalf("spec.model missing or wrong type: %#v", spec["model"]) + } + if model["name"] != "aeon-ultimate" { + t.Errorf("spec.model.name = %v, want %q (must reflect --model, not the legacy hardcoded \"ollama\")", model["name"], "aeon-ultimate") + } +} + +// TestShouldAutoRegisterSell pins the decision logic shared by the http and +// inference action handlers. The bug it guards: leaving a fresh inference +// offer in `Registered=False AwaitingExternalRegistration` so that the +// controller's services.json filter (which requires Ready=True, which in +// turn requires Registered=True) silently excluded the offer from the +// operator's own storefront. Both call sites must auto-register exactly +// when the spec says enabled AND the tunnel is up — anything else is a +// regression. +func TestShouldAutoRegisterSell(t *testing.T) { + tests := []struct { + name string + spec map[string]any + tunnelURL string + want bool + }{ + { + name: "registration enabled + tunnel up → register", + spec: map[string]any{"registration": map[string]any{"enabled": true}}, + tunnelURL: "https://inference.example.com", + want: true, + }, + { + name: "registration explicitly disabled → skip", + spec: map[string]any{"registration": map[string]any{"enabled": false}}, + tunnelURL: "https://inference.example.com", + want: false, + }, + { + name: "no registration block (sell http --no-register / pre-#485 sell inference) → skip", + spec: map[string]any{}, + tunnelURL: "https://inference.example.com", + want: false, + }, + { + name: "registration enabled but tunnel down → skip (no endpoint to advertise)", + spec: map[string]any{"registration": map[string]any{"enabled": true}}, + tunnelURL: "", + want: false, + }, + { + name: "registration block is not a map (defensive) → skip", + spec: map[string]any{"registration": "garbage"}, + tunnelURL: "https://inference.example.com", + want: false, + }, + { + name: "registration.enabled is not a bool (defensive) → skip", + spec: map[string]any{"registration": map[string]any{"enabled": "yes"}}, + tunnelURL: "https://inference.example.com", + want: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := shouldAutoRegisterSell(tc.spec, tc.tunnelURL) + if got != tc.want { + t.Errorf("shouldAutoRegisterSell = %v, want %v", got, tc.want) + } + }) + } +} + +// TestSellInferenceAction_InvokesAutoRegister is a source-level guard +// against the specific regression the user just hit on spark2: the +// inference Action committed the ServiceOffer, ensured the tunnel, then +// jumped straight to runInferenceGateway without ever calling +// autoRegisterServiceOffer. Result: the offer stayed in +// AwaitingExternalRegistration and never reached the storefront feed. +// Without this guard, an innocent refactor of the post-create code path +// could silently remove the call again — and the only downstream signal +// would be "operator's storefront mysteriously empty", which is hard to +// attribute. +func TestSellInferenceAction_InvokesAutoRegister(t *testing.T) { + src, err := os.ReadFile("sell.go") + if err != nil { + t.Fatalf("read sell.go: %v", err) + } + body := string(src) + start := strings.Index(body, "func sellInferenceCommand(") + if start < 0 { + t.Fatal("sellInferenceCommand not found in sell.go") + } + next := strings.Index(body[start+1:], "\nfunc ") + if next < 0 { + t.Fatal("could not delimit sellInferenceCommand body") + } + scope := body[start : start+1+next] + for _, needle := range []string{ + "shouldAutoRegisterSell(", + "autoRegisterServiceOffer(", + } { + if !strings.Contains(scope, needle) { + t.Errorf("sellInferenceCommand body must contain %q — auto-register path missing, "+ + "offers will stay in AwaitingExternalRegistration and be excluded from /api/services.json", needle) + } + } +} + +// TestSignerPayeeDelegationNote pins the contract that obol now treats the +// "registration signer != offer payTo" case as legitimate ERC-8004 ownership +// delegation rather than an error. The helper returns "" when the two match +// (or either is empty) and a single-line informational note otherwise. +// +// Regression context: autoRegisterServiceOffer used to fail with +// +// registration signer 0xA... does not match the payment wallet 0xB... +// +// which read like an ERC-8004 spec constraint but was purely an obol-CLI +// policy. ERC-8004 explicitly supports the split (msg.sender at register +// time owns the agent; setAgentWallet re-points the wallet post-mint), and +// x402 settlement honors the offer's payTo regardless of what the registry +// reports. +func TestSignerPayeeDelegationNote(t *testing.T) { + tests := []struct { + name string + signer string + payTo string + wantNoteHas []string + wantEmpty bool + }{ + { + name: "exact match → no note", + signer: "0xA5d4aF96E3e740383A36c3123A54724dAcB3Df57", + payTo: "0xA5d4aF96E3e740383A36c3123A54724dAcB3Df57", + wantEmpty: true, + }, + { + name: "case-insensitive match → no note", + signer: "0xa5d4af96e3e740383a36c3123a54724dacb3df57", + payTo: "0xA5D4AF96E3E740383A36C3123A54724DACB3DF57", + wantEmpty: true, + }, + { + name: "whitespace tolerance → no note", + signer: " 0xA5d4aF96E3e740383A36c3123A54724dAcB3Df57 ", + payTo: "0xA5d4aF96E3e740383A36c3123A54724dAcB3Df57", + wantEmpty: true, + }, + { + name: "empty payTo → no note (caller didn't request the check)", + signer: "0xA5d4aF96E3e740383A36c3123A54724dAcB3Df57", + payTo: "", + wantEmpty: true, + }, + { + name: "empty signer → no note (impossible in practice; defensive)", + signer: "", + payTo: "0xeFAb75b7b199bf8512e2d5b379374Cb94dfdBA47", + wantEmpty: true, + }, + { + name: "true mismatch → note names both addrs and suggests sell update", + signer: "0xA5d4aF96E3e740383A36c3123A54724dAcB3Df57", + payTo: "0xeFAb75b7b199bf8512e2d5b379374Cb94dfdBA47", + wantNoteHas: []string{ + "0xA5d4aF96E3e740383A36c3123A54724dAcB3Df57", + "0xeFAb75b7b199bf8512e2d5b379374Cb94dfdBA47", + "obol sell update", + "--pay-to", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := signerPayeeDelegationNote(tc.signer, tc.payTo) + if tc.wantEmpty { + if got != "" { + t.Errorf("note = %q, want empty", got) + } + return + } + for _, sub := range tc.wantNoteHas { + if !strings.Contains(got, sub) { + t.Errorf("note missing %q: %s", sub, got) + } + } + }) + } +} + +// TestAutoRegister_AllowsSignerPayeeMismatch is the source-level guard against +// re-introducing the early-return error that used to reject ERC-8004 +// registrations whenever signer != payTo. The error wording was specific +// ("registration signer ... does not match the payment wallet ..."); we grep +// the function body for it. Anyone who reintroduces that check — even with a +// different error message — must also delete this test, which is a deliberate +// nudge to read the rationale comment first. +func TestAutoRegister_AllowsSignerPayeeMismatch(t *testing.T) { + src, err := os.ReadFile("sell.go") + if err != nil { + t.Fatalf("read sell.go: %v", err) + } + body := string(src) + start := strings.Index(body, "func autoRegisterServiceOffer(") + if start < 0 { + t.Fatal("autoRegisterServiceOffer not found in sell.go") + } + end := strings.Index(body[start+1:], "\nfunc ") + if end < 0 { + t.Fatal("could not delimit autoRegisterServiceOffer body") + } + scope := body[start : start+1+end] + for _, banned := range []string{ + `"registration signer %s does not match the payment wallet`, + `does not match the payment wallet`, + } { + if strings.Contains(scope, banned) { + t.Errorf("autoRegisterServiceOffer must NOT reject signer != payee — ERC-8004 allows "+ + "the split via setAgentWallet and x402 settlement honors payTo directly. "+ + "Banned snippet still present: %q", banned) + } + } + // Positive guard: the soft-notice path must still be present, otherwise + // the operator gets no signal that the addresses diverge. + if !strings.Contains(scope, "signerPayeeDelegationNote(") { + t.Error("autoRegisterServiceOffer must call signerPayeeDelegationNote(...) so operators see when signer ≠ payTo") + } +} + +// TestBuildSellUpdatePatch_PayToOnly pins the `obol sell update --pay-to 0x...` +// shape. The user-facing promise is "change the payee in place" — the patch +// must touch only spec.payment.payTo, not the rest of the offer. +func TestBuildSellUpdatePatch_PayToOnly(t *testing.T) { + const newPayee = "0xB00B00000000000000000000000000000000B00B" + patch, err := buildSellUpdatePatch(newPayee, "", schemas.PriceTable{}) + if err != nil { + t.Fatalf("buildSellUpdatePatch: %v", err) + } + spec, ok := patch["spec"].(map[string]any) + if !ok { + t.Fatalf("patch.spec missing or wrong type: %#v", patch) + } + payment, ok := spec["payment"].(map[string]any) + if !ok { + t.Fatalf("patch.spec.payment missing or wrong type: %#v", spec) + } + if payment["payTo"] != newPayee { + t.Errorf("patch.spec.payment.payTo = %v, want %q", payment["payTo"], newPayee) + } + if _, present := payment["network"]; present { + t.Errorf("--pay-to only patch should NOT touch payment.network; got %#v", payment["network"]) + } + if _, present := payment["price"]; present { + t.Errorf("--pay-to only patch should NOT touch payment.price; got %#v", payment["price"]) + } +} + +// TestBuildSellUpdatePatch_PriceSwitchNullsOldKeys pins the switching contract: +// changing the price model (e.g. perRequest → perMTok) must explicitly null +// the unused keys so merge-patch semantics don't leave a stranded perRequest +// fighting the new perMTok. +func TestBuildSellUpdatePatch_PriceSwitchNullsOldKeys(t *testing.T) { + tests := []struct { + name string + price schemas.PriceTable + setKey string + }{ + {"per-request set", schemas.PriceTable{PerRequest: "0.002"}, "perRequest"}, + {"per-mtok set", schemas.PriceTable{PerMTok: "5.0"}, "perMTok"}, + {"per-hour set", schemas.PriceTable{PerHour: "1.0"}, "perHour"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + patch, err := buildSellUpdatePatch("", "", tc.price) + if err != nil { + t.Fatalf("buildSellUpdatePatch: %v", err) + } + price := patch["spec"].(map[string]any)["payment"].(map[string]any)["price"].(map[string]any) + for _, key := range []string{"perRequest", "perMTok", "perHour"} { + v := price[key] + if key == tc.setKey { + if v == nil { + t.Errorf("price.%s = nil, want a value", key) + } + } else { + if v != nil { + t.Errorf("price.%s = %#v, want nil (so old key doesn't survive the merge)", key, v) + } + } + } + }) + } +} + +// TestBuildSellUpdatePatch_NoFieldsErrors pins the no-op-protection contract: +// `obol sell update ` with no field flags must error rather than fire +// a no-op kubectl patch. The error message names the flags so the operator +// learns the surface from the failure. +func TestBuildSellUpdatePatch_NoFieldsErrors(t *testing.T) { + _, err := buildSellUpdatePatch("", "", schemas.PriceTable{}) + 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"} { + if !strings.Contains(err.Error(), sub) { + t.Errorf("error must name flag %q so the operator learns the surface; got: %v", sub, err) + } + } +} + +// TestSellUpdate_PayToFlagSurface pins the user-facing `obol sell update +// --pay-to 0x...` flag wiring: the same payToFlag() shape used by +// inference + http (so a buyer can muscle-memory `-w` / `--wallet`), plus +// the namespace requirement (the resource is per-namespace, ambiguous +// otherwise). +func TestSellUpdate_PayToFlagSurface(t *testing.T) { + cfg := newTestConfig(t) + cmd := sellCommand(cfg) + update := findSubcommand(t, cmd, "update") + flags := flagMap(update) + + requireFlags(t, flags, + "namespace", "pay-to", "wallet", "recipient", "w", + "chain", "price", "per-request", "per-mtok", "per-hour", + ) + assertFlagRequired(t, flags, "namespace") + assertFlagHasAlias(t, flags, "pay-to", "wallet") + assertFlagHasAlias(t, flags, "pay-to", "recipient") + assertFlagHasAlias(t, flags, "pay-to", "w") +} + func TestBuildDemoServiceOffer_RegisterFlagDrivesEnabled(t *testing.T) { for _, register := range []bool{true, false} { manifest := buildDemoServiceOffer( diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index df59bfee..ef6c4705 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -26,7 +26,6 @@ const ( servicesJSONRouteName = "obol-services-json-route" ) - func buildRegistrationRequest(offer *monetizeapi.ServiceOffer, desiredState string) *unstructured.Unstructured { return &unstructured.Unstructured{ Object: map[string]any{ @@ -583,12 +582,18 @@ func isConditionTrue(status monetizeapi.ServiceOfferStatus, conditionType string func buildActiveRegistrationDocument(owner *monetizeapi.ServiceOffer, offers []*monetizeapi.ServiceOffer, baseURL, agentID string) erc8004.AgentRegistration { baseURL = strings.TrimRight(baseURL, "/") + // Operator-supplied description wins. Only fall back to a controller- + // generated default when the offer left Spec.Registration.Description + // empty. The inference-typed default is more specific (names the model), + // so it preempts the generic default — but neither overrides an explicit + // operator value. description := owner.Spec.Registration.Description if description == "" { - description = fmt.Sprintf("x402 payment-gated %s service: %s", fallbackOfferType(owner), owner.Name) - } - if owner.IsInference() && owner.Spec.Model.Name != "" { - description = fmt.Sprintf("%s inference via x402 micropayments", owner.Spec.Model.Name) + if owner.IsInference() && owner.Spec.Model.Name != "" { + description = fmt.Sprintf("%s inference via x402 micropayments", owner.Spec.Model.Name) + } else { + description = fmt.Sprintf("x402 payment-gated %s service: %s", fallbackOfferType(owner), owner.Name) + } } image := owner.Spec.Registration.Image diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index d77ed603..ea077860 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -240,6 +240,74 @@ func TestBuildActiveRegistrationDocument(t *testing.T) { } } +// TestBuildActiveRegistrationDocument_KeepsOperatorDescription pins the fix +// for the controller-side description-clobber bug: +// `buildActiveRegistrationDocument` used to unconditionally overwrite +// `owner.Spec.Registration.Description` for inference-typed offers with +// `" inference via x402 micropayments"`, so any explicit operator +// description set at sell time was silently lost in the published +// /.well-known/agent-registration.json document. The fix only fills the +// description from the model name when the operator's value is empty. +func TestBuildActiveRegistrationDocument_KeepsOperatorDescription(t *testing.T) { + operatorDesc := "Uncensored Qwen3.6-27B abliteration on DGX Spark" + owner := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "aeon", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "inference", + Model: monetizeapi.ServiceOfferModel{Name: "aeon-ultimate"}, + Path: "/services/aeon", + Registration: monetizeapi.ServiceOfferRegistration{ + Enabled: true, + Name: "Qwen36 AEON Ultimate", + Description: operatorDesc, + }, + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{ + {Type: "ModelReady", Status: "True"}, + {Type: "UpstreamHealthy", Status: "True"}, + {Type: "PaymentGateReady", Status: "True"}, + {Type: "RoutePublished", Status: "True"}, + }, + }, + } + doc := buildActiveRegistrationDocument(owner, []*monetizeapi.ServiceOffer{owner}, "https://inference.example.com", "") + if doc.Description != operatorDesc { + t.Fatalf("description = %q, want operator value %q (the controller used to overwrite this with %q-inference-via-x402-micropayments)", + doc.Description, operatorDesc, owner.Spec.Model.Name) + } + if doc.Name != "Qwen36 AEON Ultimate" { + t.Errorf("name = %q, want operator value %q", doc.Name, "Qwen36 AEON Ultimate") + } +} + +// TestBuildActiveRegistrationDocument_FallsBackToModelDescriptionForInference +// pins the *other* side of the description contract: when the operator does +// not supply a description, inference offers should still get the +// model-aware default (" inference via x402 micropayments"), +// not the generic "x402 payment-gated service: " string used +// for non-inference fallback. The refactor must preserve both branches. +func TestBuildActiveRegistrationDocument_FallsBackToModelDescriptionForInference(t *testing.T) { + owner := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "aeon", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "inference", + Model: monetizeapi.ServiceOfferModel{Name: "aeon-ultimate"}, + Path: "/services/aeon", + Registration: monetizeapi.ServiceOfferRegistration{ + Enabled: true, + Name: "aeon", + // Description intentionally left blank. + }, + }, + } + doc := buildActiveRegistrationDocument(owner, []*monetizeapi.ServiceOffer{owner}, "https://inference.example.com", "") + want := "aeon-ultimate inference via x402 micropayments" + if doc.Description != want { + t.Errorf("description = %q, want %q (model-aware default for inference offers with no operator description)", doc.Description, want) + } +} + func TestBuildRegistrationServices_IncludesOwnerWhenOwnerNotYetPublished(t *testing.T) { owner := &monetizeapi.ServiceOffer{ ObjectMeta: metav1.ObjectMeta{Name: "owner", Namespace: "demo"}, @@ -528,13 +596,17 @@ func TestBuildServiceCatalogJSON_ExcludesNonReady(t *testing.T) { offers := []*monetizeapi.ServiceOffer{ nil, { - ObjectMeta: metav1.ObjectMeta{Name: "paused-svc", Namespace: "llm", - Annotations: map[string]string{monetizeapi.PausedAnnotation: "true"}}, + ObjectMeta: metav1.ObjectMeta{ + Name: "paused-svc", Namespace: "llm", + Annotations: map[string]string{monetizeapi.PausedAnnotation: "true"}, + }, Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond}, }, { - ObjectMeta: metav1.ObjectMeta{Name: "deleting-svc", Namespace: "llm", - DeletionTimestamp: &deleting}, + ObjectMeta: metav1.ObjectMeta{ + Name: "deleting-svc", Namespace: "llm", + DeletionTimestamp: &deleting, + }, Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond}, }, {