Skip to content
Closed
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
250 changes: 207 additions & 43 deletions cmd/obol/sell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
}
}
}
}
}
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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 <name> --pay-to <addr>",
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
Expand Down Expand Up @@ -827,7 +988,7 @@ type autoRegisterOptions struct {
ExpectedOwner string
}

type sellHTTPRegistrationInput struct {
type sellRegistrationInput struct {
NoRegister bool
Register bool
Name string
Expand Down Expand Up @@ -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 <name> --pay-to <new>` 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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading