diff --git a/.agents/skills/obol-stack-dev/SKILL.md b/.agents/skills/obol-stack-dev/SKILL.md index 7754208a..4a161a43 100644 --- a/.agents/skills/obol-stack-dev/SKILL.md +++ b/.agents/skills/obol-stack-dev/SKILL.md @@ -459,16 +459,20 @@ LLM agents (OpenClaw or Hermes) give different wording across runs. `grep -qE "p ### ERC-8004 registration prerequisites in flows -If the ServiceOffer has `registration.enabled: true`, the controller will not move to `Ready=True` until registration completes. Two paths to satisfy that: +The serviceoffer-controller never signs on-chain. All ERC-8004 registration goes through the CLI (`obol sell http`, `obol sell register`), which delegates signing to the agent's remote-signer pod — the CLI never sees raw key material. If the ServiceOffer has `registration.enabled: true`, the controller will publish the registration document and watch the chain, but it depends on the operator (or a flow script) to land the on-chain register tx via the CLI. -1. **Via `obol sell http --private-key-file`** — supplies a signing key to the controller. The CLI also calls `EnsureTunnelForSell` first so the controller gets the tunnel URL in the `obol-stack-config` ConfigMap, which is required for the registration metadata. flow-11 takes this path. +Two paths to satisfy that for a flow: + +1. **Via `obol sell http`** — `obol agent init` (or `obol stack up`'s default-agent setup) must have created a Hermes remote-signer with a usable wallet. If you need a specific test wallet, run `obol wallet import --instance obol-agent --private-key-file --force` first, then `obol sell http` will sign register/setMetadata via that imported key. The CLI also calls `EnsureTunnelForSell` so the controller gets the tunnel URL in the `obol-stack-config` ConfigMap. flow-11 and flow-14 take this path. 2. **YAML-apply with `registration.enabled: false`** — skip ERC-8004 entirely. Suitable for tests that exercise only the payment path. flow-13 does this; cross-cluster discovery falls back to skill.md / a known-URL hand-off. -If you apply a ServiceOffer YAML with `registration.enabled: true` and never give the controller a key, it parks at `AwaitingExternalRegistration` and `Ready` stays False. Either supply the key or drop the flag. +If you apply a ServiceOffer YAML with `registration.enabled: true` and never run the CLI register step, the request parks at `AwaitingExternalRegistration` and `Ready` stays False. Either run `obol sell register` or drop the flag. ### setMetadata revert during simulation (live Base Sepolia) -`erc8004.Client.SetMetadata` writes via `setMetadata(uint256,string,bytes)` on the registry contract. If the `eth_estimateGas` simulation reverts, the broadcast is aborted (only `register` lands; the wallet nonce only goes 0→1). The most common causes: +`erc8004.Client.SetMetadata` writes via `setMetadata(uint256,string,bytes)` on the registry contract. If the `eth_estimateGas` simulation reverts, the broadcast is aborted (only `register` lands; the wallet nonce only goes 0→1). The dominant cause we hit is **read-side staleness**: eRPC's read upstream returns `ERC721NonexistentToken` for the freshly-minted agent ID even though the register tx already landed on the write upstream. PR #387 closes this window by inserting `Client.WaitForAgent` between `Register` and `SetMetadata`, which polls `ownerOf(agentID)` until the reader catches up — that's the right fix and is now wired into both `registerSponsored` and `registerDirectViaSigner`. + +Other less-common causes worth ruling out: 1. The eRPC chain route is pinned to a stale Anvil fork from a prior flow-12/13 run that didn't unwind. Verify with `obol kubectl get cm erpc-config -n erpc -o yaml` — if the `base-sepolia` upstream points anywhere other than the public RPC (`https://sepolia.base.org`), the simulation runs against a dead fork. `obol network sync` or `obol network remove base-sepolia && obol network add base-sepolia --allow-writes` to reset. 2. Contract-level: the registry's `setMetadata` checks ownership of the agent ID. If the registration tx and the setMetadata simulation use different `from` addresses (e.g. signer-key mismatch between commands), the revert is "not owner". `cast call --from 0x8004... "setMetadata(uint256,string,bytes)" "x402.supported" 0x01 --rpc-url https://sepolia.base.org` reproduces it cheaply. diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 8bf80882..d1154f4e 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -37,7 +37,6 @@ import ( "github.com/ObolNetwork/obol-stack/internal/validate" x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" "github.com/urfave/cli/v3" ) @@ -500,10 +499,6 @@ Examples: 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 Hermes remote-signer wallet)", - }, &cli.StringSliceFlag{ Name: "register-skills", Usage: "OASF skills for discovery (e.g. natural_language_processing/text_generation)", @@ -689,15 +684,14 @@ Examples: } 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"), + NoRegister: cmd.Bool("no-register"), + Register: cmd.Bool("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 err != nil { return err @@ -771,12 +765,11 @@ Examples: 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, + ChainCSV: cmd.String("chain"), + Endpoint: tunnelURL, + AgentName: registrationNameForPrompt(name, reg), + AgentDesc: registrationDescriptionForPrompt(name, reg), + ExpectedOwner: wallet, }); err != nil { return fmt.Errorf("automatic sell registration failed: %w", err) } @@ -809,26 +802,29 @@ func registrationDescriptionForPrompt(fallback string, reg map[string]any) strin } type autoRegisterOptions struct { - ChainCSV string - Endpoint string - AgentName string - AgentDesc string - PrivateKeyInput string - ExpectedOwner string + ChainCSV string + Endpoint string + AgentName string + AgentDesc string + ExpectedOwner string } type sellHTTPRegistrationInput struct { - NoRegister bool - Register bool - Name string - Description string - Image string - PrivateKeyFile string - Skills []string - Domains []string - MetadataPairs []string + NoRegister bool + Register bool + Name string + Description string + Image string + Skills []string + Domains []string + MetadataPairs []string } +// autoRegisterServiceOffer performs ERC-8004 registration via the agent's +// remote-signer. The remote-signer holds the only copy of the agent's +// signing key — the CLI never sees raw key material. If no remote-signer is +// configured (no Hermes default agent), the operator must run +// `obol agent init` first (or `obol wallet import` to seed a known key). 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") @@ -839,42 +835,26 @@ func autoRegisterServiceOffer(ctx context.Context, cfg *config.Config, u *ui.UI, return err } - useRemoteSigner := false - var ( - signerNS string - fallbackKey string - signerAddr string - ) - - if strings.TrimSpace(opts.PrivateKeyInput) == "" { - if _, err := hermes.ResolveWalletAddress(cfg); err == nil { - ns, nsErr := hermes.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 - } + if _, err := hermes.ResolveWalletAddress(cfg); err != nil { + return fmt.Errorf("no Hermes remote-signer wallet found: %w\n\n Run 'obol agent init' first, or 'obol wallet import --private-key-file ' to seed a specific key", err) + } + signerNS, err := hermes.ResolveInstanceNamespace(cfg) + if err != nil { + return fmt.Errorf("resolve Hermes instance namespace: %w", err) + } - signerAddr = addr.Hex() - useRemoteSigner = true - signerNS = ns - } - } + pf, err := startSignerPortForward(cfg, signerNS) + if err != nil { + return fmt.Errorf("port-forward to remote-signer: %w", err) } + defer pf.Stop() - if !useRemoteSigner { - fallbackKey, signerAddr, err = readPrivateKeyMaterial(opts.PrivateKeyInput) - if err != nil { - return err - } + signer := erc8004.NewRemoteSigner(fmt.Sprintf("http://localhost:%d", pf.localPort)) + addr, err := signer.GetAddress(ctx) + if err != nil { + return err } + 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) @@ -889,16 +869,9 @@ func autoRegisterServiceOffer(ctx context.Context, cfg *config.Config, u *ui.UI, 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 - } + if err := registerDirectViaSigner(ctx, cfg, u, net, agentURI, signerNS); err != nil { + u.Warnf("direct registration failed: %v", err) + continue } u.Printf(" Name: %s", opts.AgentName) @@ -919,7 +892,7 @@ func autoRegisterServiceOffer(ctx context.Context, cfg *config.Config, u *ui.UI, 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 != "") { + len(in.Skills) > 0 || len(in.Domains) > 0 || len(in.MetadataPairs) > 0) { return nil, false, errors.New("--no-register cannot be combined with registration-specific flags") } if !registerEnabled { @@ -956,32 +929,6 @@ func buildSellHTTPRegistrationConfig(serviceName string, in sellHTTPRegistration 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), @@ -1736,11 +1683,6 @@ Examples: Name: "image", Usage: "Agent image URL for registration", }, - &cli.StringFlag{ - Name: "private-key-file", - Usage: "Path to private key file (fallback if no remote-signer available)", - Sources: cli.EnvVars("ERC8004_PRIVATE_KEY"), - }, }, Action: func(ctx context.Context, cmd *cli.Command) error { u := getUI(cmd) @@ -1803,31 +1745,16 @@ Examples: } agentURI := endpoint + "/.well-known/agent-registration.json" - // Determine signing method: private key file (if explicitly provided) - // or remote-signer (default when Hermes agent is deployed). - useRemoteSigner := false - var signerNS string - - // If --private-key-file is explicitly provided, honour user intent. - if !cmd.IsSet("private-key-file") { - if _, err := hermes.ResolveWalletAddress(cfg); err == nil { - ns, nsErr := hermes.ResolveInstanceNamespace(cfg) - if nsErr == nil { - useRemoteSigner = true - signerNS = ns - } - } + // All signing happens via the agent's remote-signer; the CLI never + // sees raw key material. If no Hermes default agent is configured, + // the operator must run `obol agent init` (or `obol wallet import` + // to seed a known key) first. + if _, err := hermes.ResolveWalletAddress(cfg); err != nil { + return fmt.Errorf("no Hermes remote-signer wallet found: %w\n\n Run 'obol agent init' first, or 'obol wallet import --private-key-file ' to seed a specific key", err) } - - // Fallback to private key file if no remote-signer. - var fallbackKey string - if !useRemoteSigner { - 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) + signerNS, err := hermes.ResolveInstanceNamespace(cfg) + if err != nil { + return fmt.Errorf("resolve Hermes instance namespace: %w", err) } // Register on each network (best-effort). @@ -1843,24 +1770,16 @@ Examples: sponsored := net.HasSponsor() && (cmd.Bool("sponsored") || !cmd.IsSet("sponsored")) - if sponsored && useRemoteSigner { - // Sponsored path via remote-signer. + if sponsored { if err := registerSponsored(ctx, cfg, u, net, agentURI, signerNS); err != nil { u.Warnf("sponsored registration failed: %v", err) continue } - } else if useRemoteSigner { - // Direct on-chain via remote-signer (needs funded wallet). + } else { if err := registerDirectViaSigner(ctx, cfg, u, net, agentURI, signerNS); err != nil { u.Warnf("direct registration failed: %v", err) continue } - } else { - // Fallback: direct on-chain with private key file. - if err := registerDirectWithKey(ctx, cfg, u, net, agentURI, fallbackKey); err != nil { - u.Warnf("registration failed: %v", err) - continue - } } u.Printf(" CAIP-10: %s", net.CAIP10Registry()) @@ -1961,51 +1880,6 @@ func registerDirectViaSigner(ctx context.Context, cfg *config.Config, u *ui.UI, return nil } -// registerDirectWithKey performs a direct on-chain registration using a raw private key. -func registerDirectWithKey(ctx context.Context, cfg *config.Config, u *ui.UI, net erc8004.NetworkConfig, agentURI, keyHex string) error { - u.Printf(" Using direct on-chain registration with private key...") - - keyHex = strings.TrimPrefix(keyHex, "0x") - key, err := crypto.HexToECDSA(keyHex) - if err != nil { - return fmt.Errorf("invalid private key: %w", err) - } - - rpcBaseURL := stack.LocalIngressURL(cfg) + "/rpc" - client, err := erc8004.NewClientForNetwork(ctx, rpcBaseURL, net) - if err != nil { - return fmt.Errorf("connect to %s via eRPC: %w", net.Name, err) - } - defer client.Close() - - txAddr := crypto.PubkeyToAddress(key.PublicKey) - startBlock := registrationRecoveryStartBlock(ctx, client, u) - agentID, txHash, err := registerWithRecovery(ctx, u, client, agentURI, txAddr, startBlock, func() (*big.Int, string, error) { - return client.RegisterDetailed(ctx, key, agentURI) - }) - if err != nil { - return err - } - - u.Printf(" Agent ID: %s", agentID.String()) - u.Printf(" Owner: %s", txAddr.Hex()) - if txHash != "" { - u.Printf(" Tx: %s", txHash) - } - - // Wait for the chain READER to catch up to the freshly-minted agent id; - // see comment in registerWithRemoteSigner for the rationale. - if _, err := client.WaitForAgent(ctx, agentID, 30*time.Second); err != nil { - u.Warnf("agent not visible to reader after register: %v", err) - } - - x402Meta := []byte(`{"x402":true}`) - if err := client.SetMetadata(ctx, key, agentID, "x402", x402Meta); err != nil { - u.Warnf("failed to set x402 metadata: %v", err) - } - return nil -} - func registrationRecoveryStartBlock(ctx context.Context, client *erc8004.Client, u *ui.UI) uint64 { startBlock, err := client.CurrentBlockNumber(ctx) if err != nil { diff --git a/cmd/obol/sell_test.go b/cmd/obol/sell_test.go index 8e8f1199..df84035b 100644 --- a/cmd/obol/sell_test.go +++ b/cmd/obol/sell_test.go @@ -2,16 +2,12 @@ package main import ( "errors" - "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" ) @@ -211,7 +207,7 @@ func TestSellHTTP_Flags(t *testing.T) { "wallet", "chain", "token", "price", "per-request", "per-mtok", "per-hour", "namespace", "upstream", "port", "health-path", "path", "max-timeout", - "register", "no-register", "register-name", "register-description", "register-image", "private-key-file", + "register", "no-register", "register-name", "register-description", "register-image", ) assertStringDefault(t, flags, "chain", "base") @@ -248,52 +244,6 @@ func TestBuildSellHTTPRegistrationConfig_NoRegisterConflicts(t *testing.T) { } } -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{ @@ -349,7 +299,7 @@ func TestSellRegister_Flags(t *testing.T) { flags := flagMap(reg) requireFlags(t, flags, - "chain", "sponsored", "private-key-file", + "chain", "sponsored", "endpoint", "name", "description", "image", ) diff --git a/flows/flow-11-dual-stack.sh b/flows/flow-11-dual-stack.sh index 8b79299a..bf6a7e89 100755 --- a/flows/flow-11-dual-stack.sh +++ b/flows/flow-11-dual-stack.sh @@ -869,8 +869,30 @@ if [ -z "$REG_START_BLOCK" ]; then fail "Could not read Base Sepolia block number before registration" emit_metrics; exit 1 fi + +# Seed the remote-signer with the Alice key so `obol sell http` can sign +# ERC-8004 register/setMetadata via the agent's signer (no key passes +# through the CLI). --force overwrites the auto-generated key from +# `obol stack up`'s default-agent setup. +step "Alice: import seller wallet into remote-signer" KEY_FILE=$(mktemp) +chmod 600 "$KEY_FILE" echo "$SIGNER_KEY" > "$KEY_FILE" +set +e +import_out=$(alice wallet import \ + --instance obol-agent \ + --private-key-file "$KEY_FILE" \ + --force 2>&1) +import_rc=$? +set -e +rm -f "$KEY_FILE" +echo "$import_out" | tail -6 +if [ "$import_rc" -ne 0 ]; then + fail "Could not seed Alice remote-signer: ${import_out:0:300}" + emit_metrics; exit "$import_rc" +fi +pass "Alice remote-signer seeded with seller wallet" + set +e sell_http_out=$(alice sell http alice-inference \ --wallet "$ALICE_WALLET" \ @@ -883,12 +905,10 @@ sell_http_out=$(alice sell http alice-inference \ --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 \ - --private-key-file "$KEY_FILE" 2>&1) + --register-domains technology/artificial_intelligence 2>&1) sell_http_rc=$? set -e printf '%s\n' "$sell_http_out" | tail -8 -rm -f "$KEY_FILE" if [ "$sell_http_rc" -ne 0 ]; then fail "ServiceOffer create/register failed (exit $sell_http_rc): ${sell_http_out:0:300}" emit_metrics; exit "$sell_http_rc" diff --git a/flows/flow-13-dual-stack-obol.sh b/flows/flow-13-dual-stack-obol.sh index 025777f6..1dac5f1a 100755 --- a/flows/flow-13-dual-stack-obol.sh +++ b/flows/flow-13-dual-stack-obol.sh @@ -834,10 +834,11 @@ spec: perRequest: "0.001" path: /services/alice-obol-inference # Intentionally NO registration: this flow's focus is the OBOL Permit2 - # payment path, not ERC-8004 discovery. The controller can't drive - # registration without a signing private key (which `obol sell http` normally - # supplies via --private-key-file); leaving registration off keeps Ready=True - # reachable. Matches TestIntegration_SellBuySidecar_OBOLPermit2's offer YAML. + # payment path, not ERC-8004 discovery. The controller never signs + # on-chain (registration is a CLI/remote-signer flow); leaving + # registration off keeps Ready=True reachable without needing to seed + # a remote-signer. Matches TestIntegration_SellBuySidecar_OBOLPermit2's + # offer YAML. YAML alice kubectl apply -f "$ALICE_OFFER_YAML" 2>&1 | tail -2 rm -f "$ALICE_OFFER_YAML" diff --git a/flows/flow-14-live-obol-base-sepolia.sh b/flows/flow-14-live-obol-base-sepolia.sh index 00a91a24..c7f2898a 100755 --- a/flows/flow-14-live-obol-base-sepolia.sh +++ b/flows/flow-14-live-obol-base-sepolia.sh @@ -720,15 +720,32 @@ pass "Tunnel: $TUNNEL_URL" # Drive the on-chain IdentityRegistry tx via `obol sell register`. The # controller publishes the registration metadata + sets RoutePublished # but leaves Registered=AwaitingExternalRegistration until this CLI -# call lands the on-chain register. The CLI takes --chain / --sponsored -# / --endpoint / --name / --description / --image / --private-key-file — -# it has no `--namespace` flag (the offer is reconciled by the -# controller, not looked up by the CLI). +# call lands the on-chain register. Signing happens via the agent's +# remote-signer — there is no longer any `--private-key-file` escape +# hatch on `obol sell register`. We seed the remote-signer with the +# Alice key here so the register tx uses a known, funded wallet. # ═════════════════════════════════════════════════════════════════ -step "Alice: drive ERC-8004 registration (obol sell register)" +step "Alice: import seller wallet into remote-signer" KEY_FILE=$(mktemp) +chmod 600 "$KEY_FILE" echo "$SIGNER_KEY" > "$KEY_FILE" +set +e +import_out=$(alice wallet import \ + --instance obol-agent \ + --private-key-file "$KEY_FILE" \ + --force 2>&1) +import_rc=$? +set -e +rm -f "$KEY_FILE" +printf '%s\n' "$import_out" | tail -6 +if [ "$import_rc" -ne 0 ]; then + fail "Could not seed Alice remote-signer: ${import_out:0:300}" + emit_metrics; exit "$import_rc" +fi +pass "Alice remote-signer seeded with seller wallet" + +step "Alice: drive ERC-8004 registration (obol sell register)" # 5-minute hard timeout: the on-chain tx + WaitForAgent + SetMetadata # should complete in ~30-60s; anything beyond that is a hang we want # to surface, not silently block the run. `timeout` is an external @@ -742,10 +759,8 @@ register_out=$(timeout 300 \ "$ALICE_DIR/bin/obol" sell register \ --chain base-sepolia \ --endpoint "$TUNNEL_URL" \ - --name "Live OBOL Base Sepolia Test Inference" \ - --private-key-file "$KEY_FILE" 2>&1) + --name "Live OBOL Base Sepolia Test Inference" 2>&1) register_rc=$? -rm -f "$KEY_FILE" printf '%s\n' "$register_out" | tail -10 if [ "$register_rc" -ne 0 ]; then fail "obol sell register failed (exit $register_rc) — offer will stay AwaitingExternalRegistration" diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index bf2ac222..7ae6b6dc 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -2,12 +2,10 @@ package serviceoffercontroller import ( "context" - "crypto/ecdsa" "crypto/md5" "encoding/json" "fmt" "log" - "math/big" "net/http" "os" "slices" @@ -19,7 +17,6 @@ import ( "github.com/ObolNetwork/obol-stack/internal/erc8004" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -80,11 +77,9 @@ type Controller struct { // instead of the in-cluster litellm Service DNS. Empty in production. litellmURLOverride string - registrationKey *ecdsa.PrivateKey - registrationOwnerAddress string - registrationRPCURL string - baseURLOverride string - defaultBaseURL string + registrationRPCURL string + baseURLOverride string + defaultBaseURL string } func New(cfg *rest.Config) (*Controller, error) { @@ -93,16 +88,6 @@ func New(cfg *rest.Config) (*Controller, error) { return nil, fmt.Errorf("create dynamic client: %w", err) } - registrationKey, err := loadRegistrationSigningKey() - if err != nil { - return nil, err - } - if registrationKey != nil { - log.Printf("serviceoffer-controller: ERC-8004 signing key loaded") - } else { - log.Printf("serviceoffer-controller: no ERC-8004 signing key configured; on-chain registration disabled") - } - kubeClient, err := kubernetes.NewForConfig(cfg) if err != nil { return nil, fmt.Errorf("create kube client: %w", err) @@ -117,36 +102,29 @@ func New(cfg *rest.Config) (*Controller, error) { }) configMapInformer := configMapFactory.ForResource(monetizeapi.ConfigMapGVR).Informer() - registrationOwnerAddress := "" - if registrationKey != nil { - registrationOwnerAddress = crypto.PubkeyToAddress(registrationKey.PublicKey).Hex() - } - controller := &Controller{ - kubeClient: kubeClient, - dynClient: client, - client: client, - offers: client.Resource(monetizeapi.ServiceOfferGVR), - registrationRequests: client.Resource(monetizeapi.RegistrationRequestGVR), - services: client.Resource(monetizeapi.ServiceGVR), - configMaps: client.Resource(monetizeapi.ConfigMapGVR), - deployments: client.Resource(monetizeapi.DeploymentGVR), - middlewares: client.Resource(monetizeapi.MiddlewareGVR), - httpRoutes: client.Resource(monetizeapi.HTTPRouteGVR), - referenceGrants: client.Resource(monetizeapi.ReferenceGrantGVR), - offerInformer: offerInformer, - registrationInformer: registrationInformer, - purchaseInformer: purchaseInformer, - configMapInformer: configMapInformer, - offerQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), - registrationQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), - purchaseQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), - httpClient: &http.Client{Timeout: 3 * time.Second}, - registrationKey: registrationKey, - registrationOwnerAddress: registrationOwnerAddress, - registrationRPCURL: getenvDefault("ERC8004_RPC_URL", erc8004.DefaultRPCURL), - baseURLOverride: strings.TrimRight(os.Getenv("AGENT_BASE_URL"), "/"), - defaultBaseURL: "http://obol.stack:8080", + kubeClient: kubeClient, + dynClient: client, + client: client, + offers: client.Resource(monetizeapi.ServiceOfferGVR), + registrationRequests: client.Resource(monetizeapi.RegistrationRequestGVR), + services: client.Resource(monetizeapi.ServiceGVR), + configMaps: client.Resource(monetizeapi.ConfigMapGVR), + deployments: client.Resource(monetizeapi.DeploymentGVR), + middlewares: client.Resource(monetizeapi.MiddlewareGVR), + httpRoutes: client.Resource(monetizeapi.HTTPRouteGVR), + referenceGrants: client.Resource(monetizeapi.ReferenceGrantGVR), + offerInformer: offerInformer, + registrationInformer: registrationInformer, + purchaseInformer: purchaseInformer, + configMapInformer: configMapInformer, + offerQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), + registrationQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), + purchaseQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), + httpClient: &http.Client{Timeout: 3 * time.Second}, + registrationRPCURL: getenvDefault("ERC8004_RPC_URL", erc8004.DefaultRPCURL), + baseURLOverride: strings.TrimRight(os.Getenv("AGENT_BASE_URL"), "/"), + defaultBaseURL: "http://obol.stack:8080", } offerInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ @@ -760,80 +738,21 @@ func (c *Controller) reconcileRegistrationActive(ctx context.Context, raw *unstr return c.updateRegistrationStatus(ctx, raw, status) } + // On-chain registration is performed by the CLI (`obol sell register` / + // `obol sell http`) via the agent's remote-signer — never by the + // controller. The controller only publishes the registration document + // and watches for the registration tx to land on-chain so it can mark + // the request Ready=True. var client *erc8004.Client - if agentID == "" || c.registrationKey != nil { + if agentID == "" { client, err = erc8004.NewClient(ctx, c.registrationRPCURL) if err != nil { - waitPhase := registrationPhaseRegistering - if c.registrationKey == nil { - waitPhase = registrationPhaseAwaitingExternal - } - status.Phase = waitPhase + status.Phase = registrationPhaseAwaitingExternal status.Message = truncateMessage(fmt.Sprintf("Waiting for ERC-8004 RPC connectivity: %v", err)) return c.updateRegistrationStatus(ctx, raw, status) } defer client.Close() - } - - if agentID == "" && c.registrationKey != nil { - if status.RegistrationURI != status.PublishedURL || - !strings.EqualFold(status.RegistrationOwner, c.registrationOwnerAddress) || - status.RegistrationSearchFromBlock == 0 { - height, err := client.CurrentBlockNumber(ctx) - if err != nil { - status.Phase = registrationPhaseRegistering - status.Message = truncateMessage(fmt.Sprintf("Preparing on-chain registration: %v", err)) - return c.updateRegistrationStatus(ctx, raw, status) - } - - status.Phase = registrationPhaseRegistering - status.Message = "Prepared on-chain registration and fenced duplicate retries" - status.RegistrationOwner = c.registrationOwnerAddress - status.RegistrationURI = status.PublishedURL - fromBlock := int64(height) - if fromBlock > 0 { - fromBlock-- - } - status.RegistrationSearchFromBlock = fromBlock - status.RegistrationTxHash = "" - status.MetadataSynced = false - return c.updateRegistrationStatus(ctx, raw, status) - } - - recoveredAgentID, recoveredTxHash, found, err := c.recoverRegistration(ctx, client, status) - if err != nil { - status.Phase = registrationPhaseRegistering - status.Message = truncateMessage(fmt.Sprintf("Recovering on-chain registration state: %v", err)) - if updateErr := c.updateRegistrationStatus(ctx, raw, status); updateErr != nil { - return updateErr - } - return err - } - switch { - case found: - agentID = recoveredAgentID - txHash = recoveredTxHash - case status.RegistrationTxHash == "": - submittedTxHash, err := client.SubmitRegister(ctx, c.registrationKey, status.PublishedURL) - if err != nil { - status.Phase = registrationPhaseRegistering - status.Message = truncateMessage(fmt.Sprintf("Submitting on-chain registration: %v", err)) - if updateErr := c.updateRegistrationStatus(ctx, raw, status); updateErr != nil { - return updateErr - } - return err - } - status.Phase = registrationPhaseRegistering - status.Message = fmt.Sprintf("Submitted on-chain registration transaction %s", submittedTxHash) - status.RegistrationTxHash = submittedTxHash - return c.updateRegistrationStatus(ctx, raw, status) - default: - status.Phase = registrationPhaseRegistering - 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 { @@ -878,27 +797,10 @@ func (c *Controller) reconcileRegistrationActive(ctx context.Context, raw *unstr status.AgentID = agentID status.RegistrationTxHash = txHash - status.RegistrationOwner = firstNonEmpty(status.RegistrationOwner, c.registrationOwnerAddress) status.RegistrationURI = firstNonEmpty(status.RegistrationURI, status.PublishedURL) - if agentID != "" && c.registrationKey != nil && client != nil && !status.MetadataSynced { - agentIDBig, ok := new(big.Int).SetString(strings.TrimSpace(agentID), 10) - if !ok { - return fmt.Errorf("invalid agent id %q", agentID) - } - if err := c.syncRegistrationMetadata(ctx, client, offer, agentIDBig); err == nil { - status.MetadataSynced = true - log.Printf("serviceoffer-controller: on-chain metadata synced for agent %s", agentID) - } else { - log.Printf("serviceoffer-controller: metadata sync failed for agent %s (will retry): %v", agentID, err) - } - } if agentID != "" { status.Phase = registrationPhaseRegistered - if status.MetadataSynced || c.registrationKey == nil { - status.Message = fmt.Sprintf("Published registration document and recorded agent %s", agentID) - } else { - status.Message = fmt.Sprintf("Published registration document and recorded agent %s; metadata sync will retry on the next reconcile", agentID) - } + status.Message = fmt.Sprintf("Published registration document and recorded agent %s", agentID) } return c.updateRegistrationStatus(ctx, raw, status) @@ -929,66 +831,18 @@ func (c *Controller) recoverRegistration(ctx context.Context, client *erc8004.Cl return agentID.String(), resolvedTxHash, true, nil } -func (c *Controller) syncRegistrationMetadata(ctx context.Context, client *erc8004.Client, offer *monetizeapi.ServiceOffer, agentID *big.Int) error { - if err := client.SetMetadata(ctx, c.registrationKey, agentID, "x402.supported", []byte{1}); err != nil { - return err - } - if err := client.SetMetadata(ctx, c.registrationKey, agentID, "service.type", []byte(fallbackOfferType(offer))); err != nil { - return err - } - for key, value := range offer.Spec.Registration.Metadata { - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - if key == "" || value == "" { - continue - } - if err := client.SetMetadata(ctx, c.registrationKey, agentID, "metadata."+key, []byte(value)); err != nil { - return err - } - } - return nil -} - -func (c *Controller) reconcileRegistrationTombstone(ctx context.Context, raw *unstructured.Unstructured, request *monetizeapi.RegistrationRequest, offer *monetizeapi.ServiceOffer, baseURL string) error { +func (c *Controller) reconcileRegistrationTombstone(ctx context.Context, raw *unstructured.Unstructured, request *monetizeapi.RegistrationRequest, offer *monetizeapi.ServiceOffer, _ string) error { status := request.Status agentID := firstNonEmpty(status.AgentID, offer.Status.AgentID) - if agentID != "" && c.registrationKey != nil { - client, err := erc8004.NewClient(ctx, c.registrationRPCURL) - if err != nil { - status.Phase = registrationPhaseOffChainOnly - status.Message = truncateMessage(fmt.Sprintf("Deleted registration resources but could not connect for tombstone: %v", err)) - if err := c.deleteRegistrationResources(ctx, request); err != nil { - return err - } - return c.updateRegistrationStatus(ctx, raw, status) - } - defer client.Close() - - agentIDBig, ok := new(big.Int).SetString(strings.TrimSpace(agentID), 10) - if !ok { - return fmt.Errorf("invalid agent id %q", agentID) - } - tombstoneURI, err := registrationDataURL(buildTombstoneRegistrationDocument(offer, baseURL, agentID)) - if err != nil { - return err - } - if err := client.SetAgentURI(ctx, c.registrationKey, agentIDBig, tombstoneURI); err != nil { - status.Phase = registrationPhaseOffChainOnly - status.Message = truncateMessage(fmt.Sprintf("Deleted registration resources but could not tombstone on-chain: %v", err)) - if err := c.deleteRegistrationResources(ctx, request); err != nil { - return err - } - return c.updateRegistrationStatus(ctx, raw, status) - } - if err := client.SetMetadata(ctx, c.registrationKey, agentIDBig, "x402.supported", []byte{0}); err != nil { - log.Printf("serviceoffer-controller: failed to clear x402.supported metadata for agent %s: %v", agentID, err) - } - status.Phase = registrationPhaseTombstoned - status.Message = fmt.Sprintf("Tombstoned registration for agent %s", agentID) - } else if agentID != "" { + // On-chain tombstoning is the operator's responsibility via the CLI + // (the controller has no signing key by design — registration is a + // CLI/remote-signer flow). We only delete the published registration + // resources here and mark the request OffChainOnly when an agent ID + // was ever assigned, otherwise Tombstoned (nothing to tombstone). + if agentID != "" { status.Phase = registrationPhaseOffChainOnly - status.Message = "Deleted registration resources; controller has no ERC-8004 signing key for tombstone" + status.Message = "Deleted registration resources; on-chain tombstone is the operator's responsibility" } else { status.Phase = registrationPhaseTombstoned status.Message = "Deleted registration resources" @@ -1380,28 +1234,6 @@ func truncateMessage(message string) string { return message[:200] } -func loadRegistrationSigningKey() (*ecdsa.PrivateKey, error) { - keyHex := strings.TrimSpace(os.Getenv("ERC8004_PRIVATE_KEY")) - if keyHex == "" { - if path := strings.TrimSpace(os.Getenv("ERC8004_PRIVATE_KEY_FILE")); path != "" { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read ERC8004_PRIVATE_KEY_FILE: %w", err) - } - keyHex = strings.TrimSpace(string(data)) - } - } - if keyHex == "" { - return nil, nil - } - - key, err := crypto.HexToECDSA(strings.TrimPrefix(keyHex, "0x")) - if err != nil { - return nil, fmt.Errorf("parse ERC8004 private key: %w", err) - } - return key, nil -} - func getenvDefault(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value