Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .agents/skills/obol-stack-dev/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <signer> 0x8004... "setMetadata(uint256,string,bytes)" <agentId> "x402.supported" 0x01 --rpc-url https://sepolia.base.org` reproduces it cheaply.
Expand Down
250 changes: 62 additions & 188 deletions cmd/obol/sell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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")
Expand All @@ -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 <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)
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 <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).
Expand All @@ -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())
Expand Down Expand Up @@ -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 {
Expand Down
Loading