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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,9 @@ Port-forward to `x402-verifier` and calling `/verify` directly: MUST set `X-Forw

**PurchaseRequest status caveat**: `PurchaseRequest.status` (`conditions[].message`, `remaining`, `spent`) is controller's last reconciled snapshot, NOT live per-request counter. For real-time auth pool + refill decisions, always query `x402-buyer` `GET /status` in litellm pod.

**CLI**: `obol sell pricing --pay-to --chain`, `obol sell inference <name> --model --pay-to --price|--per-mtok [--token USDC|OBOL]`, `obol sell http <name> --pay-to --chain --price|--per-request|--per-mtok --upstream --port --namespace --health-path [--token USDC|OBOL]`, `obol sell list|status|stop|delete`, `obol sell register --chain [--name]`.
**CLI**: `obol sell pricing --pay-to --chain`, `obol sell inference <name> --model --pay-to --price|--per-mtok [--token USDC|OBOL]`, `obol sell http <name> --pay-to --chain --price|--per-request|--per-mtok --upstream --port --namespace --health-path [--token USDC|OBOL]`, `obol sell list|status|stop|delete`, `obol sell register --chain [--name]`, `obol sell register x402scan [--origin]`.

**x402scan registration** (`obol sell register x402scan`, `internal/x402scan/`): submits the storefront origin to the x402scan.com discovery index via `POST /api/x402/registry/register-origin`. Auth is SIWX (EIP-4361/SIWE challenge in the 402 body, signed EIP-191 by the agent's remote-signer via `POST /api/v1/sign/{addr}/message`, retried with the payload base64-encoded in the `SIGN-IN-WITH-X` header). x402scan crawls the origin's `/openapi.json` (already published with `x-payment-info` per paid op) and live-probes each endpoint for a real 402. Rejects `obol.stack`/localhost and `*.trycloudflare.com` quick-tunnels locally — needs a permanent tunnel hostname. Idempotent; stale offers are deprecated server-side.

**`obol sell http` flag reference** (common mistakes: `--model`, `--network` do NOT exist; `--wallet` is a deprecated alias for `--pay-to`):
```
Expand Down
17 changes: 11 additions & 6 deletions cmd/obol/sell.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ Commands split into two scopes:
The storefront (the whole shop)
info Preview the storefront and everything on sale, as buyers see it
info set Set the storefront's branding (name, tagline, logo)
register Publish the seller's identity to a distribution channel (ERC-8004)
register Publish the seller to a distribution channel (ERC-8004 on-chain,
'register x402scan' for the x402scan discovery index)
identity Manage the durable on-chain identity record
list List every offer (raw)
resume Re-publish all offers (e.g. after a restart)
Expand Down Expand Up @@ -3080,11 +3081,11 @@ Reloads the payment verifier when configuration is changed.`,
func sellRegisterCommand(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "register",
Usage: "Publish the seller's identity to a distribution channel (ERC-8004)",
Usage: "Publish the seller's identity to a distribution channel (ERC-8004, x402scan)",
Description: `Publishes the seller's agent identity to a distribution channel so buyers can
discover it. Today the only channel is the ERC-8004 Agent Registry (on-chain);
more channels may follow, so this command is framed around "where do I
advertise" rather than a single registry.
discover it. The default (no subcommand) registers on the ERC-8004 Agent
Registry (on-chain); 'register x402scan' submits the storefront origin to the
x402scan.com discovery index instead.

Registers an AgentIdentity on the ERC-8004 Agent Registry for one chain.
The on-chain register tx is signed and broadcast by the Hermes remote-signer
Expand All @@ -3094,7 +3095,11 @@ the target chain (~$0.20–$0.50 of native gas typically suffices).
Examples:
obol sell register # defaults to mainnet
obol sell register --network base # register on base
obol sell register --network base-sepolia # add a Base Sepolia registration`,
obol sell register --network base-sepolia # add a Base Sepolia registration
obol sell register x402scan # list in the x402scan index (no gas)`,
Commands: []*cli.Command{
sellRegisterX402scanCommand(cfg),
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "network",
Expand Down
109 changes: 101 additions & 8 deletions cmd/obol/sell_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,21 @@ func sellInfoSetCommand(cfg *config.Config) *cli.Command {
Usage: "Set storefront display name, tagline, and/or logo URL",
Description: `Updates seller-wide storefront branding. With no flags on a TTY this walks
you through each field (pre-filled with the current value). With flags, only
the fields you pass change; everything else is left untouched.`,
the fields you pass change; everything else is left untouched.

Logo URLs are preflight-checked before publishing: reachability, an image
content-type, https (mixed content), and permissive CORS headers (needed by
sites that embed your catalog). On a TTY a failing check asks before
proceeding; non-interactive runs warn and continue. To sidestep hosting
brittleness entirely (CORS, hotlinking, dead hosts), inline a local image —
it is embedded in the catalog as a self-contained data: URI:

obol sell info set --logo-file ./logo.png`,
Flags: []cli.Flag{
&cli.StringFlag{Name: "display-name", Usage: "Seller title shown in the storefront header"},
&cli.StringFlag{Name: "tagline", Usage: "Short subtitle under the storefront hero"},
&cli.StringFlag{Name: "logo-url", Usage: "Logo image URL (https://... or /path on this host)"},
&cli.StringFlag{Name: "logo-url", Usage: "Logo image URL (https://..., /path on this host, or inline data:image/...;base64)"},
&cli.StringFlag{Name: "logo-file", Usage: "Local image file to inline as the logo (≤256 KiB, converted to a data: URI — no hosting needed)"},
&cli.StringFlag{Name: "contact-email", Usage: "Operator contact email published in /openapi.json (x402scan)"},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
Expand All @@ -120,9 +130,12 @@ the fields you pass change; everything else is left untouched.`,
}

patch := schemas.StorefrontProfile{}
anyFlag := cmd.IsSet("display-name") || cmd.IsSet("tagline") || cmd.IsSet("logo-url") || cmd.IsSet("contact-email")
anyFlag := cmd.IsSet("display-name") || cmd.IsSet("tagline") || cmd.IsSet("logo-url") || cmd.IsSet("logo-file") || cmd.IsSet("contact-email")
if anyFlag {
// Flag mode: patch only the fields the operator passed.
if cmd.IsSet("logo-url") && cmd.IsSet("logo-file") {
return errors.New("--logo-url and --logo-file are mutually exclusive")
}
if cmd.IsSet("display-name") {
patch.DisplayName = strings.TrimSpace(cmd.String("display-name"))
}
Expand All @@ -132,13 +145,20 @@ the fields you pass change; everything else is left untouched.`,
if cmd.IsSet("logo-url") {
patch.LogoURL = strings.TrimSpace(cmd.String("logo-url"))
}
if cmd.IsSet("logo-file") {
uri, err := storefront.InlineLogoFromFile(strings.TrimSpace(cmd.String("logo-file")))
if err != nil {
return err
}
patch.LogoURL = uri
}
if cmd.IsSet("contact-email") {
patch.ContactEmail = strings.TrimSpace(cmd.String("contact-email"))
}
} else {
// No flags: prompt interactively (pre-filled with effective values).
if !u.IsTTY() {
return errors.New("no flags given and not a TTY: pass --display-name, --tagline, --logo-url, and/or --contact-email")
return errors.New("no flags given and not a TTY: pass --display-name, --tagline, --logo-url, --logo-file, and/or --contact-email")
}
effective := storefront.ResolvePublished(&current, mustSellerBaseURL(cfg))
if v, err := u.Input("Display name", effective.DisplayName); err == nil {
Expand All @@ -147,8 +167,15 @@ the fields you pass change; everything else is left untouched.`,
if v, err := u.Input("Tagline", effective.Tagline); err == nil {
patch.Tagline = strings.TrimSpace(v)
}
if v, err := u.Input("Logo URL", effective.LogoURL); err == nil {
patch.LogoURL = strings.TrimSpace(v)
// An inline data: URI is too long to pre-fill; keep it unless
// the operator types a replacement.
logoDefault := effective.LogoURL
if strings.HasPrefix(logoDefault, "data:") {
u.Dim("Current logo: " + storefront.DescribeLogoURL(logoDefault) + " (leave blank to keep)")
logoDefault = ""
}
if v, err := u.Input("Logo URL or local image file", logoDefault); err == nil {
patch.LogoURL = resolveLogoInput(u, v)
}
if v, err := u.Input("Contact email (OpenAPI)", effective.ContactEmail); err == nil {
patch.ContactEmail = strings.TrimSpace(v)
Expand All @@ -164,6 +191,9 @@ the fields you pass change; everything else is left untouched.`,
if err := storefront.ValidateContactEmail(patch.ContactEmail); err != nil {
return err
}
if err := confirmLogoURL(ctx, u, cfg, patch.LogoURL); err != nil {
return err
}

merged := storefront.MergeProfile(current, patch)
if err := applySellerProfile(cfg, merged); err != nil {
Expand Down Expand Up @@ -240,6 +270,69 @@ more field flags to reset only those fields, leaving the rest untouched.`,
}
}

// resolveLogoInput turns an interactive "Logo URL" answer into a profile
// value: a path to an existing local image file is inlined as a data: URI;
// anything else passes through unchanged for URL validation.
func resolveLogoInput(u *ui.UI, v string) string {
v = strings.TrimSpace(v)
if v == "" || strings.HasPrefix(v, "data:") ||
strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://") {
return v
}
if st, err := os.Stat(v); err != nil || !st.Mode().IsRegular() {
return v
}
uri, err := storefront.InlineLogoFromFile(v)
if err != nil {
u.Warn(err.Error())
return v
}
u.Infof("Inlined %s as %s", v, storefront.DescribeLogoURL(uri))
return uri
}

// confirmLogoURL probes a newly-set logo URL the way browsers will load it
// (reachability, image content-type, permissive CORS, https, size) before it
// is published to every catalog consumer. On a TTY, problems become a
// proceed-anyway confirmation; non-interactive runs warn and continue so
// scripts never block. Default, inline (data:), and empty logos skip the
// probe — there is nothing remote to check.
func confirmLogoURL(ctx context.Context, u *ui.UI, cfg *config.Config, logoURL string) error {
logoURL = strings.TrimSpace(logoURL)
if logoURL == "" || strings.HasPrefix(logoURL, "data:") || storefront.IsDefaultLogoURL(logoURL) {
return nil
}
if strings.HasPrefix(logoURL, "/") {
// Site-relative paths are resolved against the seller origin by
// consumers; probe the same absolute URL they will fetch.
logoURL = strings.TrimRight(mustSellerBaseURL(cfg), "/") + logoURL
}

var result storefront.LogoPreflight
_ = u.RunWithSpinner("Checking logo URL", func() error {
result = storefront.PreflightLogoURL(ctx, logoURL)
return nil
})
if result.OK() {
return nil
}
for _, w := range result.Warnings {
u.Warn(w)
}
if !u.IsTTY() || u.IsJSON() {
u.Warn("continuing anyway (non-interactive); the logo may not load on all sites")
return nil
}
msg, defaultYes := "The logo may not display on every site. Set it anyway?", true
if result.LoadFailure {
msg, defaultYes = "Unable to load the logo image. Set it anyway?", false
}
if !u.Confirm(msg, defaultYes) {
return errors.New("cancelled: storefront branding not updated")
}
return nil
}

// clearProfileFields returns a copy of p with the flagged fields emptied, so
// they fall back to stack defaults while the rest of the operator override is
// preserved.
Expand Down Expand Up @@ -309,7 +402,7 @@ func printStorefrontHeader(u *ui.UI, catalog schemas.ServiceCatalog) {
u.Bold("Storefront")
u.Printf(" Name: %s", valueOrNone(catalog.DisplayName))
u.Printf(" Tagline: %s", valueOrNone(catalog.Tagline))
u.Printf(" Logo: %s", valueOrNone(catalog.LogoURL))
u.Printf(" Logo: %s", valueOrNone(storefront.DescribeLogoURL(catalog.LogoURL)))
u.Blank()
}

Expand Down Expand Up @@ -490,7 +583,7 @@ func mustSellerBaseURL(cfg *config.Config) string {
func printSellerProfile(u *ui.UI, profile schemas.StorefrontProfile) {
u.Printf(" Display name: %s", profile.DisplayName)
u.Printf(" Tagline: %s", profile.Tagline)
u.Printf(" Logo URL: %s", profile.LogoURL)
u.Printf(" Logo URL: %s", storefront.DescribeLogoURL(profile.LogoURL))
if email := strings.TrimSpace(profile.ContactEmail); email != "" {
u.Printf(" Contact email: %s", email)
} else {
Expand Down
Loading
Loading