diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 1cb293a8..05802821 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -26,6 +26,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/erc8004" "github.com/ObolNetwork/obol-stack/internal/inference" "github.com/ObolNetwork/obol-stack/internal/kubectl" + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" "github.com/ObolNetwork/obol-stack/internal/openclaw" "github.com/ObolNetwork/obol-stack/internal/schemas" "github.com/ObolNetwork/obol-stack/internal/stack" @@ -395,11 +396,13 @@ func sellHTTPCommand(cfg *config.Config) *cli.Command { Name: "http", Usage: "Sell any local HTTP service with x402 payments", ArgsUsage: "", - Description: `Publishes a payment gated HTTP API to any service within the stack, along with a SKILL.md detailing how to use it. -Include --register to have the service listed on EIP8004 onchain agent registry. + Description: `Publishes a payment gated HTTP API to any service within the stack. +By default it also registers the seller agent on ERC-8004 after the route is live. +Use --no-register to skip the on-chain registration step. -Example: - obol sell http my-cool-api --upstream my-svc.my-namespace.svc.cluster.local --port 8080 --wallet 0x... --price 0.01 --chain base --register`, +Examples: + obol sell http my-cool-api --upstream my-svc.my-namespace.svc.cluster.local --port 8080 --wallet 0x... --price 0.01 --chain base + obol sell http my-cool-api --upstream my-svc --port 8080 --wallet 0x... --price 0.01 --chain base --no-register`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "wallet", @@ -460,7 +463,11 @@ Example: // Registration flags &cli.BoolFlag{ Name: "register", - Usage: "Register on ERC-8004 after routing is live", + Usage: "Deprecated: registration is enabled by default", + }, + &cli.BoolFlag{ + Name: "no-register", + Usage: "Skip the automatic ERC-8004 registration step", }, &cli.StringFlag{ Name: "register-name", @@ -474,6 +481,10 @@ Example: Name: "register-image", Usage: "Agent image URL for ERC-8004 registration", }, + &cli.StringFlag{ + Name: "private-key-file", + Usage: "Path to the ERC-8004 signing key file (defaults to the OpenClaw remote-signer wallet)", + }, &cli.StringSliceFlag{ Name: "register-skills", Usage: "OASF skills for discovery (e.g. natural_language_processing/text_generation)", @@ -649,39 +660,21 @@ Example: prov.Framework, prov.MetricName, prov.MetricValue, prov.ParamCount) } - if cmd.Bool("register") || cmd.String("register-name") != "" { - reg := map[string]any{ - "enabled": cmd.Bool("register"), - } - if n := cmd.String("register-name"); n != "" { - reg["name"] = n - } - - if d := cmd.String("register-description"); d != "" { - reg["description"] = d - } - - if img := cmd.String("register-image"); img != "" { - reg["image"] = img - } - - if skills := cmd.StringSlice("register-skills"); len(skills) > 0 { - reg["skills"] = skills - } - - if domains := cmd.StringSlice("register-domains"); len(domains) > 0 { - reg["domains"] = domains - } - - if metaPairs := cmd.StringSlice("register-metadata"); len(metaPairs) > 0 { - meta, err := parseMetadataPairs(metaPairs) - if err != nil { - return err - } - - reg["metadata"] = meta - } - + reg, registerEnabled, err := buildSellHTTPRegistrationConfig(name, sellHTTPRegistrationInput{ + NoRegister: cmd.Bool("no-register"), + Register: cmd.Bool("register"), + Name: cmd.String("register-name"), + Description: cmd.String("register-description"), + Image: cmd.String("register-image"), + PrivateKeyFile: cmd.String("private-key-file"), + Skills: cmd.StringSlice("register-skills"), + Domains: cmd.StringSlice("register-domains"), + MetadataPairs: cmd.StringSlice("register-metadata"), + }) + if err != nil { + return err + } + if registerEnabled { spec["registration"] = reg } @@ -719,6 +712,23 @@ Example: u.Dim(" Start manually with: obol tunnel restart") } else { u.Successf("Tunnel active: %s", tunnelURL) + + if reg, ok := spec["registration"].(map[string]any); ok { + if enabled, _ := reg["enabled"].(bool); enabled { + 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), + PrivateKeyInput: cmd.String("private-key-file"), + ExpectedOwner: wallet, + }); err != nil { + return fmt.Errorf("automatic sell registration failed: %w", err) + } + } + } } return nil @@ -726,6 +736,222 @@ Example: } } +func registrationNameForPrompt(fallback string, reg map[string]any) string { + if reg == nil { + return fallback + } + if name, ok := reg["name"].(string); ok && strings.TrimSpace(name) != "" { + return name + } + return fallback +} + +func registrationDescriptionForPrompt(fallback string, reg map[string]any) string { + if reg == nil { + return fmt.Sprintf("Obol Stack service %s", fallback) + } + if desc, ok := reg["description"].(string); ok && strings.TrimSpace(desc) != "" { + return desc + } + return fmt.Sprintf("Obol Stack service %s", fallback) +} + +type autoRegisterOptions struct { + ChainCSV string + Endpoint string + AgentName string + AgentDesc string + PrivateKeyInput string + ExpectedOwner string +} + +type sellHTTPRegistrationInput struct { + NoRegister bool + Register bool + Name string + Description string + Image string + PrivateKeyFile string + Skills []string + Domains []string + MetadataPairs []string +} + +func autoRegisterServiceOffer(ctx context.Context, cfg *config.Config, u *ui.UI, opts autoRegisterOptions) error { + if opts.Endpoint == "" { + return errors.New("endpoint is required for automatic registration") + } + + networks, err := erc8004.ResolveNetworks(opts.ChainCSV) + if err != nil { + return err + } + + useRemoteSigner := false + var ( + signerNS string + fallbackKey string + signerAddr string + ) + + if strings.TrimSpace(opts.PrivateKeyInput) == "" { + if _, err := openclaw.ResolveWalletAddress(cfg); err == nil { + ns, nsErr := openclaw.ResolveInstanceNamespace(cfg) + if nsErr == nil { + pf, pfErr := startSignerPortForward(cfg, ns) + if pfErr != nil { + return fmt.Errorf("port-forward to remote-signer: %w", pfErr) + } + defer pf.Stop() + + signer := erc8004.NewRemoteSigner(fmt.Sprintf("http://localhost:%d", pf.localPort)) + addr, addrErr := signer.GetAddress(ctx) + if addrErr != nil { + return addrErr + } + + signerAddr = addr.Hex() + useRemoteSigner = true + signerNS = ns + } + } + } + + if !useRemoteSigner { + fallbackKey, signerAddr, err = readPrivateKeyMaterial(opts.PrivateKeyInput) + if err != nil { + return err + } + } + + 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) + } + + agentURI := strings.TrimRight(opts.Endpoint, "/") + "/.well-known/agent-registration.json" + u.Printf(" Agent URI: %s", agentURI) + + var successes int + for _, net := range networks { + u.Blank() + u.Printf(" [%s] (chain ID %d)", net.Name, net.ChainID) + u.Printf(" Registry: %s", net.RegistryAddress) + + if useRemoteSigner { + if err := registerDirectViaSigner(ctx, cfg, u, net, agentURI, signerNS); err != nil { + u.Warnf("direct registration failed: %v", err) + continue + } + } else { + if err := registerDirectWithKey(ctx, cfg, u, net, agentURI, fallbackKey); err != nil { + u.Warnf("registration failed: %v", err) + continue + } + } + + u.Printf(" Name: %s", opts.AgentName) + u.Printf(" Summary: %s", opts.AgentDesc) + u.Printf(" CAIP-10: %s", net.CAIP10Registry()) + successes++ + } + + if successes == 0 { + return fmt.Errorf("registration failed on all networks") + } + + u.Blank() + u.Successf("Seller agent registered on %d/%d networks.", successes, len(networks)) + return nil +} + +func buildSellHTTPRegistrationConfig(serviceName string, in sellHTTPRegistrationInput) (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 || in.PrivateKeyFile != "") { + return nil, false, errors.New("--no-register cannot be combined with registration-specific flags") + } + if !registerEnabled { + return nil, false, nil + } + + reg := map[string]any{ + "enabled": true, + "name": registrationNameForPrompt(serviceName, nil), + "description": registrationDescriptionForPrompt(serviceName, nil), + } + if in.Name != "" { + reg["name"] = in.Name + } + if in.Description != "" { + reg["description"] = in.Description + } + if in.Image != "" { + reg["image"] = in.Image + } + if len(in.Skills) > 0 { + reg["skills"] = in.Skills + } + if len(in.Domains) > 0 { + reg["domains"] = in.Domains + } + if len(in.MetadataPairs) > 0 { + meta, err := parseMetadataPairs(in.MetadataPairs) + if err != nil { + return nil, false, err + } + reg["metadata"] = meta + } + return reg, true, nil +} + +func readPrivateKeyMaterial(input string) (keyHex string, address string, err error) { + raw := strings.TrimSpace(input) + if raw == "" { + return "", "", nil + } + + if strings.HasPrefix(raw, "0x") && len(raw) >= 64 { + keyHex = raw + } else { + data, readErr := os.ReadFile(raw) + if readErr != nil { + return "", "", fmt.Errorf("read private key file: %w", readErr) + } + keyHex = strings.TrimSpace(string(data)) + } + + keyHex = strings.TrimPrefix(keyHex, "0x") + key, parseErr := crypto.HexToECDSA(keyHex) + if parseErr != nil { + return "", "", fmt.Errorf("invalid private key: %w", parseErr) + } + + addr := crypto.PubkeyToAddress(key.PublicKey) + return "0x" + keyHex, addr.Hex(), nil +} + +func serviceOfferStatusLines(namespace, name string, offer monetizeapi.ServiceOffer) []string { + lines := []string{ + fmt.Sprintf("ServiceOffer: %s/%s", namespace, name), + fmt.Sprintf("Endpoint: %s", valueOrNone(offer.Status.Endpoint)), + fmt.Sprintf("Agent ID: %s", valueOrNone(offer.Status.AgentID)), + fmt.Sprintf("Registration Tx: %s", valueOrNone(offer.Status.RegistrationTxHash)), + "", + "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)) + } + } + return lines +} + // --------------------------------------------------------------------------- // sell list // --------------------------------------------------------------------------- @@ -792,12 +1018,28 @@ func sellStatusCommand(cfg *config.Config) *cli.Command { if ns == "" { return errors.New("namespace required: obol sell status -n ") } - outputFmt := "-o" - outputVal := "yaml" if u.IsJSON() { - outputVal = "json" + return kubectlRun(cfg, "get", "serviceoffers.obol.org", name, "-n", ns, "-o", "json") } - return kubectlRun(cfg, "get", "serviceoffers.obol.org", name, "-n", ns, outputFmt, outputVal) + + raw, err := kubectlOutput(cfg, "get", "serviceoffers.obol.org", name, "-n", ns, "-o", "json") + if err != nil { + return err + } + + var offer monetizeapi.ServiceOffer + if err := json.Unmarshal([]byte(raw), &offer); err != nil { + return fmt.Errorf("parse ServiceOffer: %w", err) + } + + for _, line := range serviceOfferStatusLines(ns, name, offer) { + if line == "" { + u.Blank() + continue + } + u.Print(line) + } + return nil } // No name: show global pricing config + registrations. @@ -1397,17 +1639,12 @@ Examples: // Fallback to private key file if no remote-signer. var fallbackKey string if !useRemoteSigner { - keyFile := cmd.String("private-key-file") - if keyFile != "" { - data, err := os.ReadFile(keyFile) - if err != nil { - return fmt.Errorf("read private key file: %w", err) - } - fallbackKey = strings.TrimSpace(string(data)) - } + var signerAddr string + fallbackKey, signerAddr, err = readPrivateKeyMaterial(cmd.String("private-key-file")) if fallbackKey == "" { return fmt.Errorf("no remote-signer wallet found and no --private-key-file provided.\nRun 'obol agent init' first, or use --private-key-file") } + u.Printf(" Wallet: %s", signerAddr) } // Register on each network (best-effort). diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index 93482d54..965da0de 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -1,11 +1,16 @@ package main import ( + "fmt" + "os" + "path/filepath" "strings" "testing" "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" + "github.com/ethereum/go-ethereum/crypto" "github.com/urfave/cli/v3" ) @@ -204,7 +209,7 @@ func TestSellHTTP_Flags(t *testing.T) { "wallet", "chain", "price", "per-request", "per-mtok", "per-hour", "namespace", "upstream", "port", "health-path", "path", "max-timeout", - "register", "register-name", "register-description", "register-image", + "register", "no-register", "register-name", "register-description", "register-image", "private-key-file", ) assertStringDefault(t, flags, "chain", "base") @@ -214,6 +219,103 @@ func TestSellHTTP_Flags(t *testing.T) { assertIntDefault(t, flags, "max-timeout", 300) } +func TestBuildSellHTTPRegistrationConfig_DefaultEnabled(t *testing.T) { + reg, enabled, err := buildSellHTTPRegistrationConfig("demo", sellHTTPRegistrationInput{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !enabled { + t.Fatal("registration should be enabled by default") + } + if reg["enabled"] != true { + t.Fatalf("registration.enabled = %v, want true", reg["enabled"]) + } + if reg["name"] != "demo" { + t.Fatalf("registration.name = %v, want demo", reg["name"]) + } +} + +func TestBuildSellHTTPRegistrationConfig_NoRegisterConflicts(t *testing.T) { + _, _, err := buildSellHTTPRegistrationConfig("demo", sellHTTPRegistrationInput{ + NoRegister: true, + Name: "custom", + }) + if err == nil { + t.Fatal("expected error for --no-register with registration-specific flags") + } +} + +func TestReadPrivateKeyMaterial_RawKey(t *testing.T) { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + raw := "0x" + fmt.Sprintf("%x", crypto.FromECDSA(key)) + gotKey, gotAddr, err := readPrivateKeyMaterial(raw) + if err != nil { + t.Fatalf("readPrivateKeyMaterial: %v", err) + } + if gotKey != raw { + t.Fatalf("got key = %q, want %q", gotKey, raw) + } + if gotAddr != crypto.PubkeyToAddress(key.PublicKey).Hex() { + t.Fatalf("got addr = %q, want %q", gotAddr, crypto.PubkeyToAddress(key.PublicKey).Hex()) + } +} + +func TestReadPrivateKeyMaterial_File(t *testing.T) { + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + raw := "0x" + fmt.Sprintf("%x", crypto.FromECDSA(key)) + path := filepath.Join(t.TempDir(), "key.txt") + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + gotKey, gotAddr, err := readPrivateKeyMaterial(path) + if err != nil { + t.Fatalf("readPrivateKeyMaterial: %v", err) + } + if gotKey != raw { + t.Fatalf("got key = %q, want %q", gotKey, raw) + } + if gotAddr != crypto.PubkeyToAddress(key.PublicKey).Hex() { + t.Fatalf("got addr = %q, want %q", gotAddr, crypto.PubkeyToAddress(key.PublicKey).Hex()) + } +} + +func TestReadPrivateKeyMaterial_Invalid(t *testing.T) { + if _, _, err := readPrivateKeyMaterial("0xdeadbeef"); err == nil { + t.Fatal("expected error for invalid private key") + } +} + +func TestServiceOfferStatusLines(t *testing.T) { + offer := monetizeapi.ServiceOffer{ + Status: monetizeapi.ServiceOfferStatus{ + Endpoint: "/services/demo", + AgentID: "5008", + RegistrationTxHash: "0xabc", + Conditions: []monetizeapi.Condition{ + {Type: "Registered", Status: "True", Reason: "Registered", Message: "Published registration document and recorded agent 5008"}, + }, + }, + } + lines := serviceOfferStatusLines("llm", "demo", offer) + joined := strings.Join(lines, "\n") + for _, want := range []string{ + "ServiceOffer: llm/demo", + "Agent ID: 5008", + "Registration Tx: 0xabc", + "type: Registered", + } { + if !strings.Contains(joined, want) { + t.Fatalf("status lines missing %q\n%s", want, joined) + } + } +} + func TestSellStop_Structure(t *testing.T) { cfg := newTestConfig(t) cmd := sellCommand(cfg) diff --git a/docs/guides/monetize-inference.md b/docs/guides/monetize-inference.md index b7b76798..0c233a5f 100644 --- a/docs/guides/monetize-inference.md +++ b/docs/guides/monetize-inference.md @@ -157,6 +157,10 @@ obol sell http my-qwen \ --port 11434 ``` +By default this also registers the seller agent on ERC-8004. Use +`--no-register` only for local or private-only testing where on-chain +discovery is intentionally skipped. + If you want to price by million tokens instead of explicitly setting a flat request price, use `--per-mtok`. In phase 1, the verifier still enforces a derived per-request price: @@ -177,14 +181,14 @@ That stores both values in the pricing config: - enforced phase-1 charge: `price = 0.00125 USDC / request` - approximation input: `approxTokensPerRequest = 1000` -The agent automatically reconciles the offer through six stages: +The stack now treats on-chain registration as part of the default selling flow: ``` ModelReady [check] Agent checks /api/tags, model already cached UpstreamHealthy [check] Agent health-checks ollama:11434 -PaymentGateReady [check] Creates Middleware x402-my-qwen + adds pricing route -RoutePublished [check] Creates HTTPRoute so-my-qwen -> ollama backend -Registered -- Skipped (--register not set) +PaymentGateReady [check] Shared x402 seller gateway available +RoutePublished [check] Creates HTTPRoute so-my-qwen -> x402-verifier backend +Registered [check] ERC-8004 registration completes on-chain Ready [check] All required conditions True ``` @@ -196,7 +200,6 @@ obol sell status my-qwen --namespace llm # Verify Kubernetes resources obol kubectl get serviceoffer my-qwen -n llm -obol kubectl get middleware -n llm # x402-my-qwen obol kubectl get httproute -n llm # so-my-qwen ``` @@ -616,7 +619,7 @@ Traefik Gateway +---------+----------+ | +---------v----------+ - | Registered | (ERC-8004, optional) + | Registered | (ERC-8004, default unless --no-register) +---------+----------+ | +-----v-----+ @@ -781,13 +784,13 @@ Replace `openclaw-obol-agent` with your actual OpenClaw namespace if different. | Command | Description | |---------|-------------| | `obol sell pricing --wallet ... --chain ...` | Configure x402 payment settings | -| `obol sell http --wallet ... --chain ... --per-request ... --upstream ... --port ...` | Create a ServiceOffer | +| `obol sell http --wallet ... --chain ... --per-request ... --upstream ... --port ...` | Create a ServiceOffer and register by default | | `obol sell list` | List all ServiceOffers | | `obol sell status -n ` | Show conditions for an offer | | `obol sell stop -n ` | Pause an offer (remove pricing route) | | `obol sell delete -n ` | Delete an offer and cleanup | | `obol sell status` | Show cluster pricing and registration | -| `obol sell register --private-key-file ...` | Register on ERC-8004 | +| `obol sell register --private-key-file ...` | Advanced/manual registration or repair path | ### Key Kubernetes Resources diff --git a/flows/flow-06-sell-setup.sh b/flows/flow-06-sell-setup.sh index 980efd19..2a3e8016 100755 --- a/flows/flow-06-sell-setup.sh +++ b/flows/flow-06-sell-setup.sh @@ -155,6 +155,7 @@ run_step_grep "sell http flow-qwen" \ "$OBOL" sell http flow-qwen \ --wallet "$SELLER_WALLET" \ --chain "$CHAIN" \ + --no-register \ --per-request 0.001 \ --namespace llm \ --upstream ollama \ @@ -164,6 +165,7 @@ run_step_grep "sell http flow-qwen" \ step "sell http idempotent: re-run shows 'updated' not 'created'" rerun_out=$("$OBOL" sell http flow-qwen \ --wallet "$SELLER_WALLET" --chain "$CHAIN" \ + --no-register \ --per-request 0.001 --namespace llm \ --upstream ollama --port 11434 2>&1) || true if echo "$rerun_out" | grep -q "ServiceOffer.*updated"; then diff --git a/flows/flow-11-dual-stack.sh b/flows/flow-11-dual-stack.sh index 6038efe5..e62f24d8 100755 --- a/flows/flow-11-dual-stack.sh +++ b/flows/flow-11-dual-stack.sh @@ -344,7 +344,16 @@ else fail "CA bundle empty or too small: $ca_size bytes" fi +step "Alice: add Base Sepolia RPC to eRPC (for registration + metadata sync)" +alice network add base-sepolia --endpoint https://sepolia.base.org --allow-writes 2>&1 | tail -2 +# eRPC needs a restart to pick up the new chain config +alice kubectl rollout restart deployment/erpc -n erpc 2>/dev/null || true +alice kubectl rollout status deployment/erpc -n erpc --timeout=60s 2>/dev/null || true +pass "Base Sepolia RPC added to eRPC (with write access)" + step "Alice: create ServiceOffer" +KEY_FILE=$(mktemp) +echo "$SIGNER_KEY" > "$KEY_FILE" alice sell http alice-inference \ --wallet "$ALICE_WALLET" \ --chain base-sepolia \ @@ -353,11 +362,12 @@ alice sell http alice-inference \ --upstream litellm \ --port 4000 \ --health-path /health/readiness \ - --register \ --register-name "Dual-Stack Test Inference" \ --register-description "Integration test: local model inference via x402" \ --register-skills natural_language_processing/text_generation \ - --register-domains technology/artificial_intelligence 2>&1 | tail -3 + --register-domains technology/artificial_intelligence \ + --private-key-file "$KEY_FILE" 2>&1 | tail -8 +rm -f "$KEY_FILE" pass "ServiceOffer created" poll_step_grep "Alice: ServiceOffer Ready" "True" 24 5 \ @@ -377,33 +387,14 @@ poll_step_grep "Alice: 402 gate works" "402" 12 5 \ "$TUNNEL_URL/services/alice-inference/v1/chat/completions" \ -H "Content-Type: application/json" \ -d '{"model":"qwen3.5:9b","messages":[{"role":"user","content":"hi"}],"max_tokens":5}' - -step "Alice: add Base Sepolia RPC to eRPC (for on-chain registration)" -alice network add base-sepolia --endpoint https://sepolia.base.org --allow-writes 2>&1 | tail -2 -# eRPC needs a restart to pick up the new chain config -alice kubectl rollout restart deployment/erpc -n erpc 2>/dev/null || true -alice kubectl rollout status deployment/erpc -n erpc --timeout=60s 2>/dev/null || true -pass "Base Sepolia RPC added to eRPC (with write access)" - -step "Alice: register on ERC-8004 (Base Sepolia)" -# Use the .env private key for on-chain registration (has ETH for gas) -KEY_FILE=$(mktemp) -echo "$SIGNER_KEY" > "$KEY_FILE" -set +e -register_out=$(alice sell register \ - --chain base-sepolia \ - --name "Dual-Stack Test Inference" \ - --description "Integration test: local model inference via x402" \ - --private-key-file "$KEY_FILE" 2>&1) -register_rc=$? -set -e -rm -f "$KEY_FILE" -echo "$register_out" | tail -5 -if [ "$register_rc" -eq 0 ] && echo "$register_out" | grep -q "Agent ID:\|registered"; then - AGENT_ID=$(echo "$register_out" | grep -o 'Agent ID: [0-9]*' | grep -o '[0-9]*' | head -1) +step "Alice: ERC-8004 registration reflected in ServiceOffer" +reg_out=$(alice sell status alice-inference -n llm 2>&1) || true +echo "$reg_out" | tail -12 +if echo "$reg_out" | grep -q "Agent ID:"; then + AGENT_ID=$(echo "$reg_out" | grep 'Agent ID:' | awk '{print $3}' | head -1) pass "ERC-8004 registered: Agent ID $AGENT_ID" else - fail "Registration failed: ${register_out:0:200}" + fail "Registration not reflected in sell status: ${reg_out:0:200}" fi # ═════════════════════════════════════════════════════════════════ diff --git a/internal/embed/skills/autoresearch-worker/SKILL.md b/internal/embed/skills/autoresearch-worker/SKILL.md index 218a09ea..94cbd09d 100644 --- a/internal/embed/skills/autoresearch-worker/SKILL.md +++ b/internal/embed/skills/autoresearch-worker/SKILL.md @@ -73,7 +73,6 @@ obol sell http autoresearch-worker \ --chain base-sepolia \ --per-hour 0.50 \ --path /services/autoresearch-worker \ - --register \ --register-name "GPU Worker Alpha" \ --register-description "A GPU worker for paid autoresearch experiments" \ --register-skills devops_mlops/model_versioning \ @@ -83,7 +82,7 @@ obol sell http autoresearch-worker \ This creates a `ServiceOffer` that: - health-checks the worker - creates a payment-gated public route -- optionally registers the worker on ERC-8004 for discovery +- registers the worker on ERC-8004 for discovery by default ## Request Format diff --git a/internal/embed/skills/autoresearch-worker/references/k3s-gpu-worker.md b/internal/embed/skills/autoresearch-worker/references/k3s-gpu-worker.md index 32ac7397..f6fe89eb 100644 --- a/internal/embed/skills/autoresearch-worker/references/k3s-gpu-worker.md +++ b/internal/embed/skills/autoresearch-worker/references/k3s-gpu-worker.md @@ -68,7 +68,6 @@ obol sell http autoresearch-worker \ --chain base-sepolia \ --per-hour 0.50 \ --path /services/autoresearch-worker \ - --register \ --register-name "GPU Worker Alpha" \ --register-description "A GPU worker for paid autoresearch experiments" \ --register-skills devops_mlops/model_versioning \ diff --git a/internal/embed/skills/autoresearch-worker/references/worker-api.md b/internal/embed/skills/autoresearch-worker/references/worker-api.md index 7454c8c0..11e92d6e 100644 --- a/internal/embed/skills/autoresearch-worker/references/worker-api.md +++ b/internal/embed/skills/autoresearch-worker/references/worker-api.md @@ -131,7 +131,6 @@ obol sell http autoresearch-worker \ --chain base-sepolia \ --per-hour 0.50 \ --path /services/autoresearch-worker \ - --register \ --register-name "GPU Worker Alpha" \ --register-description "A GPU worker for paid autoresearch experiments" \ --register-skills devops_mlops/model_versioning \ diff --git a/internal/embed/skills/monetize-guide/SKILL.md b/internal/embed/skills/monetize-guide/SKILL.md index 7386ab3c..683b1b94 100644 --- a/internal/embed/skills/monetize-guide/SKILL.md +++ b/internal/embed/skills/monetize-guide/SKILL.md @@ -128,7 +128,6 @@ Only proceed after the user has confirmed the price. obol sell inference \ --model \ --price \ - --register \ --register-name "" \ --register-description "" \ --register-skills natural_language_processing/natural_language_generation/text_completion \ @@ -156,7 +155,6 @@ obol sell http \ --wallet \ --chain base-sepolia \ --health-path /health/liveliness \ - --register \ --register-name "" \ --register-description "" \ --register-skills natural_language_processing/natural_language_generation/text_completion \ @@ -177,7 +175,6 @@ obol sell http \ --per-request \ --chain base-sepolia \ --health-path \ - --register \ --register-name "" \ --register-description "" \ --register-skills \ @@ -186,6 +183,9 @@ obol sell http \ ### Phase 5: Wait for Reconciliation +`obol sell http` now registers by default. Use `--no-register` only for local +or private-only flows where on-chain discovery is intentionally skipped. + The agent reconciler automatically processes the ServiceOffer through 6 stages. ```bash diff --git a/internal/embed/skills/sell/scripts/monetize.py b/internal/embed/skills/sell/scripts/monetize.py index 708dcd4b..14f80cde 100644 --- a/internal/embed/skills/sell/scripts/monetize.py +++ b/internal/embed/skills/sell/scripts/monetize.py @@ -576,7 +576,7 @@ def main(): create_parser.add_argument("--pay-to", required=True, help="USDC recipient wallet") create_parser.add_argument("--path", help="Public route path") create_parser.add_argument("--max-timeout", type=int, default=300, help="Payment timeout seconds") - create_parser.add_argument("--register", action="store_true", help="Publish registration document") + create_parser.add_argument("--register", action="store_true", help="Legacy flag: publish registration metadata") create_parser.add_argument("--register-name", help="Registration name") create_parser.add_argument("--register-description", help="Registration description") create_parser.add_argument("--register-image", help="Registration image URL") diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index 851ddc64..45c8b9df 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -41,11 +41,12 @@ const ( registrationDesiredActive = "Active" registrationDesiredTombstoned = "Tombstoned" - registrationPhasePublishing = "Publishing" - registrationPhaseRegistering = "Registering" - registrationPhaseRegistered = "Registered" - registrationPhaseOffChainOnly = "OffChainOnly" - registrationPhaseTombstoned = "Tombstoned" + registrationPhasePublishing = "Publishing" + registrationPhaseRegistering = "Registering" + registrationPhaseAwaitingExternal = "AwaitingExternalRegistration" + registrationPhaseRegistered = "Registered" + registrationPhaseOffChainOnly = "OffChainOnly" + registrationPhaseTombstoned = "Tombstoned" ) type Controller struct { @@ -696,10 +697,14 @@ func (c *Controller) reconcileRegistrationActive(ctx context.Context, raw *unstr } var client *erc8004.Client - if c.registrationKey != nil { + if agentID == "" || c.registrationKey != nil { client, err = erc8004.NewClient(ctx, c.registrationRPCURL) if err != nil { - status.Phase = registrationPhaseRegistering + waitPhase := registrationPhaseRegistering + if c.registrationKey == nil { + waitPhase = registrationPhaseAwaitingExternal + } + status.Phase = waitPhase status.Message = truncateMessage(fmt.Sprintf("Waiting for ERC-8004 RPC connectivity: %v", err)) return c.updateRegistrationStatus(ctx, raw, status) } @@ -764,6 +769,47 @@ func (c *Controller) reconcileRegistrationActive(ctx context.Context, raw *unstr status.Message = fmt.Sprintf("Waiting for on-chain registration transaction %s", status.RegistrationTxHash) return c.updateRegistrationStatus(ctx, raw, status) } + } else if agentID == "" { + if status.RegistrationURI != status.PublishedURL || + !strings.EqualFold(status.RegistrationOwner, offer.Spec.Payment.PayTo) || + status.RegistrationSearchFromBlock == 0 { + height, err := client.CurrentBlockNumber(ctx) + if err != nil { + status.Phase = registrationPhaseAwaitingExternal + status.Message = truncateMessage(fmt.Sprintf("Preparing external registration recovery: %v", err)) + return c.updateRegistrationStatus(ctx, raw, status) + } + + status.Phase = registrationPhaseAwaitingExternal + status.Message = "Waiting for external ERC-8004 registration" + status.RegistrationOwner = offer.Spec.Payment.PayTo + status.RegistrationURI = status.PublishedURL + fromBlock := int64(height) - 1024 + if fromBlock < 0 { + fromBlock = 0 + } + status.RegistrationSearchFromBlock = fromBlock + status.RegistrationTxHash = "" + return c.updateRegistrationStatus(ctx, raw, status) + } + + recoveredAgentID, recoveredTxHash, found, err := c.recoverRegistration(ctx, client, status) + if err != nil { + status.Phase = registrationPhaseAwaitingExternal + status.Message = truncateMessage(fmt.Sprintf("Recovering external registration state: %v", err)) + if updateErr := c.updateRegistrationStatus(ctx, raw, status); updateErr != nil { + return updateErr + } + return err + } + if !found { + status.Phase = registrationPhaseAwaitingExternal + status.Message = fmt.Sprintf("Waiting for external ERC-8004 registration for owner %s", status.RegistrationOwner) + return c.updateRegistrationStatus(ctx, raw, status) + } + + agentID = recoveredAgentID + txHash = recoveredTxHash } status.AgentID = agentID @@ -789,9 +835,6 @@ func (c *Controller) reconcileRegistrationActive(ctx context.Context, raw *unstr } else { status.Message = fmt.Sprintf("Published registration document and recorded agent %s; metadata sync will retry on the next reconcile", agentID) } - } else { - status.Phase = registrationPhaseOffChainOnly - status.Message = "Published registration document; controller has no ERC-8004 signing key" } return c.updateRegistrationStatus(ctx, raw, status) @@ -1226,7 +1269,7 @@ func statusFor(status *monetizeapi.ServiceOfferStatus) *monetizeapi.ServiceOffer } func requestPhaseReady(phase string) bool { - return phase == registrationPhaseRegistered || phase == registrationPhaseOffChainOnly + return phase == registrationPhaseRegistered } func requestCleanupComplete(phase string) bool { diff --git a/internal/serviceoffercontroller/controller_test.go b/internal/serviceoffercontroller/controller_test.go index 7e55fa20..9c078044 100644 --- a/internal/serviceoffercontroller/controller_test.go +++ b/internal/serviceoffercontroller/controller_test.go @@ -64,6 +64,18 @@ func TestSelectRegistrationOwnerEmpty(t *testing.T) { } } +func TestRequestPhaseReady(t *testing.T) { + if !requestPhaseReady(registrationPhaseRegistered) { + t.Fatal("Registered should be ready") + } + if requestPhaseReady(registrationPhaseAwaitingExternal) { + t.Fatal("AwaitingExternalRegistration should not be ready") + } + if requestPhaseReady(registrationPhaseOffChainOnly) { + t.Fatal("OffChainOnly should not be ready") + } +} + func TestShouldRefreshSkillCatalogWhenGenerationObservedLags(t *testing.T) { controller := &Controller{} offer := &monetizeapi.ServiceOffer{ diff --git a/internal/x402/bdd_integration_test.go b/internal/x402/bdd_integration_test.go index dad1a2c7..a025f78c 100644 --- a/internal/x402/bdd_integration_test.go +++ b/internal/x402/bdd_integration_test.go @@ -35,9 +35,10 @@ var ( const ( serviceOfferName = "bdd-test" serviceOfferNamespace = "llm" - serviceOfferPayTo = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" ) +var serviceOfferPayTo string + // TestMain bootstraps the full obol stack following the real user journey: // // 1. Build obol binary from source @@ -58,7 +59,6 @@ func TestMain(m *testing.M) { integrationKubeconfig = filepath.Join(configDir, "kubeconfig.yaml") integrationObolBin = filepath.Join(binDir, "obol") integrationRoutePath = "/services/" + serviceOfferName + "/v1/chat/completions" - integrationPayTo = serviceOfferPayTo integrationModel = os.Getenv("OBOL_TEST_MODEL") if integrationModel == "" { integrationModel = "qwen3.5:9b" @@ -104,6 +104,13 @@ func TestMain(m *testing.M) { log.Fatalf("obol stack up: %v", err) } + payTo, err := runObolOutput(obolBin, "openclaw", "wallet", "address", "obol-agent") + if err != nil { + teardown(obolBin) + log.Fatalf("resolve seller wallet: %v", err) + } + serviceOfferPayTo = strings.TrimSpace(payTo) + integrationKubectlBin = kubectlBin integrationKubeconfig = kubeconfigPath @@ -303,6 +310,15 @@ func ensureExistingClusterBootstrap(obolBin, kubectlBin, kubeconfig string) erro return fmt.Errorf("existing-cluster ServiceOffer not Ready: %w", err) } + payTo, err := kubectl.Output(kubectlBin, kubeconfig, + "get", "serviceoffers.obol.org", serviceOfferName, "-n", serviceOfferNamespace, + "-o", "jsonpath={.spec.payment.payTo}") + if err != nil { + return fmt.Errorf("read ServiceOffer payTo: %w", err) + } + serviceOfferPayTo = strings.TrimSpace(payTo) + integrationPayTo = serviceOfferPayTo + return nil }