From 262cf3eddbe0f81c64966fab84b2125f694e4392 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ois=C3=ADn=20Kyne?=
Date: Fri, 3 Jul 2026 14:03:54 +0100
Subject: [PATCH 1/4] feat(sell): pre-flight check logo URLs for pitfalls
---
cmd/obol/sell_info.go | 57 +++++++++-
internal/storefront/logocheck.go | 111 ++++++++++++++++++++
internal/storefront/logocheck_test.go | 145 ++++++++++++++++++++++++++
internal/storefront/profile.go | 36 ++++++-
internal/storefront/profile_test.go | 9 ++
5 files changed, 354 insertions(+), 4 deletions(-)
create mode 100644 internal/storefront/logocheck.go
create mode 100644 internal/storefront/logocheck_test.go
diff --git a/cmd/obol/sell_info.go b/cmd/obol/sell_info.go
index 0c66e38b..1440d6e8 100644
--- a/cmd/obol/sell_info.go
+++ b/cmd/obol/sell_info.go
@@ -101,11 +101,19 @@ 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, pass an inline data URI (self-contained, no CORS):
+
+ obol sell info set --logo-url "data:image/png;base64,$(base64 -i 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: "contact-email", Usage: "Operator contact email published in /openapi.json (x402scan)"},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
@@ -164,6 +172,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 {
@@ -240,6 +251,48 @@ more field flags to reset only those fields, leaving the rest untouched.`,
}
}
+// 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.
diff --git a/internal/storefront/logocheck.go b/internal/storefront/logocheck.go
new file mode 100644
index 00000000..8f429b00
--- /dev/null
+++ b/internal/storefront/logocheck.go
@@ -0,0 +1,111 @@
+package storefront
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// probeOrigin is a representative third-party origin sent as the Origin
+// header when probing a logo host. Browsers attach Origin to cross-origin
+// image fetches; sending one elicits the host's CORS response headers so we
+// can tell whether fetch()/canvas consumers will be able to load the image.
+const probeOrigin = "https://storefront.example"
+
+// largeLogoBytes is the size above which we warn that the logo will be slow
+// to load on catalog and storefront pages.
+const largeLogoBytes = 2 << 20 // 2 MiB
+
+// logoProbeClient performs preflight requests; overridable in tests so TLS
+// httptest servers can be trusted.
+var logoProbeClient = &http.Client{Timeout: 10 * time.Second}
+
+// LogoPreflight is the result of probing a logo URL the way a browser would
+// load it.
+type LogoPreflight struct {
+ // LoadFailure is true when the image could not be fetched at all
+ // (network error, HTTP error status, or a non-image response) — the
+ // logo will be broken everywhere, not just for strict consumers.
+ LoadFailure bool
+ // Warnings are human-readable problems found by the probe.
+ Warnings []string
+}
+
+// OK reports whether the probe found no problems.
+func (p LogoPreflight) OK() bool { return !p.LoadFailure && len(p.Warnings) == 0 }
+
+// PreflightLogoURL fetches an absolute logo URL and checks the properties
+// browsers and catalog consumers depend on: reachability, an image
+// content-type, permissive CORS (for fetch()/canvas-based consumers), https
+// (mixed content on https storefront pages), and size. Problems come back as
+// Warnings rather than an error so the caller can decide whether to proceed.
+// Empty and data: URIs are self-contained and probe as OK.
+func PreflightLogoURL(ctx context.Context, rawURL string) LogoPreflight {
+ var out LogoPreflight
+ rawURL = strings.TrimSpace(rawURL)
+ if rawURL == "" || strings.HasPrefix(rawURL, "data:") {
+ return out
+ }
+
+ if strings.HasPrefix(rawURL, "http://") {
+ out.Warnings = append(out.Warnings,
+ "logo is served over http:// — browsers block mixed content, so it will not render on https pages (including your tunnel storefront); use an https URL")
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+ if err != nil {
+ out.LoadFailure = true
+ out.Warnings = append(out.Warnings, fmt.Sprintf("invalid logo URL: %v", err))
+ return out
+ }
+ req.Header.Set("Origin", probeOrigin)
+ req.Header.Set("Accept", "image/*,*/*")
+
+ resp, err := logoProbeClient.Do(req)
+ if err != nil {
+ out.LoadFailure = true
+ out.Warnings = append(out.Warnings, fmt.Sprintf("could not fetch logo: %v", err))
+ return out
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 400 {
+ out.LoadFailure = true
+ out.Warnings = append(out.Warnings, fmt.Sprintf("logo URL returned HTTP %d", resp.StatusCode))
+ return out
+ }
+
+ // Content-type: trust a specific header, sniff when absent or generic.
+ sniff, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ ctype := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Type")))
+ if i := strings.Index(ctype, ";"); i >= 0 {
+ ctype = strings.TrimSpace(ctype[:i])
+ }
+ if ctype == "" || ctype == "application/octet-stream" {
+ ctype = http.DetectContentType(sniff)
+ }
+ if !strings.HasPrefix(ctype, "image/") {
+ out.LoadFailure = true
+ out.Warnings = append(out.Warnings,
+ fmt.Sprintf("logo URL does not serve an image (content-type %s)", ctype))
+ }
+
+ // Plain
tags load without CORS, but fetch()/canvas-based consumers
+ // (marketplace previews, catalog aggregators) need a permissive
+ // Access-Control-Allow-Origin from the logo host.
+ if acao := strings.TrimSpace(resp.Header.Get("Access-Control-Allow-Origin")); acao != "*" && acao != probeOrigin {
+ out.Warnings = append(out.Warnings,
+ "logo host does not send permissive CORS headers (Access-Control-Allow-Origin) — the image may not load on every site that embeds your catalog; host it on a CDN with CORS enabled or inline it as a data:image/... URI")
+ }
+
+ if resp.ContentLength > largeLogoBytes {
+ out.Warnings = append(out.Warnings,
+ fmt.Sprintf("logo is large (%.1f MiB) — it will load slowly; consider an image under 2 MiB", float64(resp.ContentLength)/(1<<20)))
+ }
+ return out
+}
diff --git a/internal/storefront/logocheck_test.go b/internal/storefront/logocheck_test.go
new file mode 100644
index 00000000..745e2554
--- /dev/null
+++ b/internal/storefront/logocheck_test.go
@@ -0,0 +1,145 @@
+package storefront
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// pngBytes is a minimal PNG magic header — enough for http.DetectContentType
+// to sniff image/png.
+var pngBytes = []byte("\x89PNG\r\n\x1a\n0000000000000000")
+
+func withProbeClient(t *testing.T, c *http.Client) {
+ t.Helper()
+ prev := logoProbeClient
+ logoProbeClient = c
+ t.Cleanup(func() { logoProbeClient = prev })
+}
+
+func newLogoServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewTLSServer(handler)
+ t.Cleanup(srv.Close)
+ withProbeClient(t, srv.Client())
+ return srv
+}
+
+func hasWarning(p LogoPreflight, substr string) bool {
+ for _, w := range p.Warnings {
+ if strings.Contains(w, substr) {
+ return true
+ }
+ }
+ return false
+}
+
+func TestPreflightLogoURL_HappyPath(t *testing.T) {
+ srv := newLogoServer(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Write(pngBytes)
+ })
+ got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png")
+ if !got.OK() {
+ t.Fatalf("expected OK, got %+v", got)
+ }
+}
+
+func TestPreflightLogoURL_MissingCORSWarns(t *testing.T) {
+ srv := newLogoServer(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(pngBytes)
+ })
+ got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png")
+ if got.LoadFailure {
+ t.Fatalf("missing CORS is a soft warning, not a load failure: %+v", got)
+ }
+ if !hasWarning(got, "CORS") {
+ t.Fatalf("expected CORS warning, got %+v", got)
+ }
+}
+
+func TestPreflightLogoURL_EchoedOriginIsPermissive(t *testing.T) {
+ srv := newLogoServer(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
+ w.Write(pngBytes)
+ })
+ got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png")
+ if !got.OK() {
+ t.Fatalf("echoed Origin should pass the CORS check: %+v", got)
+ }
+}
+
+func TestPreflightLogoURL_HTTPErrorIsLoadFailure(t *testing.T) {
+ srv := newLogoServer(t, func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ })
+ got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png")
+ if !got.LoadFailure || !hasWarning(got, "HTTP 404") {
+ t.Fatalf("expected load failure with HTTP 404, got %+v", got)
+ }
+}
+
+func TestPreflightLogoURL_NonImageIsLoadFailure(t *testing.T) {
+ srv := newLogoServer(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Write([]byte("login required"))
+ })
+ got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png")
+ if !got.LoadFailure || !hasWarning(got, "does not serve an image") {
+ t.Fatalf("expected non-image load failure, got %+v", got)
+ }
+}
+
+func TestPreflightLogoURL_SniffsGenericContentType(t *testing.T) {
+ srv := newLogoServer(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Write(pngBytes)
+ })
+ got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png")
+ if !got.OK() {
+ t.Fatalf("PNG bytes behind a generic content-type should sniff as image: %+v", got)
+ }
+}
+
+func TestPreflightLogoURL_UnreachableIsLoadFailure(t *testing.T) {
+ srv := httptest.NewServer(http.NotFoundHandler())
+ url := srv.URL
+ srv.Close()
+ withProbeClient(t, http.DefaultClient)
+ got := PreflightLogoURL(context.Background(), url+"/logo.png")
+ if !got.LoadFailure || !hasWarning(got, "could not fetch") {
+ t.Fatalf("expected fetch failure, got %+v", got)
+ }
+}
+
+func TestPreflightLogoURL_PlainHTTPWarnsMixedContent(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "image/png")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Write(pngBytes)
+ }))
+ t.Cleanup(srv.Close)
+ withProbeClient(t, srv.Client())
+ got := PreflightLogoURL(context.Background(), srv.URL+"/logo.png")
+ if got.LoadFailure {
+ t.Fatalf("http scheme is a soft warning, not a load failure: %+v", got)
+ }
+ if !hasWarning(got, "mixed content") {
+ t.Fatalf("expected mixed-content warning for http:// URL, got %+v", got)
+ }
+}
+
+func TestPreflightLogoURL_SkipsEmptyAndDataURIs(t *testing.T) {
+ for _, raw := range []string{"", "data:image/png;base64,aGk="} {
+ if got := PreflightLogoURL(context.Background(), raw); !got.OK() {
+ t.Fatalf("%q: expected OK without probing, got %+v", raw, got)
+ }
+ }
+}
diff --git a/internal/storefront/profile.go b/internal/storefront/profile.go
index 02434091..d205f488 100644
--- a/internal/storefront/profile.go
+++ b/internal/storefront/profile.go
@@ -1,6 +1,7 @@
package storefront
import (
+ "encoding/base64"
"encoding/json"
"fmt"
"net/mail"
@@ -90,7 +91,14 @@ func ProfileLocalPath(cfg *config.Config) string {
return filepath.Join(cfg.ConfigDir, profileLocalRelPath)
}
-// ValidateLogoURL accepts absolute http(s) URLs or site-relative paths.
+// maxInlineLogoBytes caps the decoded size of an inline data: logo. The
+// profile (and the published catalog that embeds it) live in ConfigMaps with
+// a hard 1 MiB object limit, so the logo must stay well under that.
+const maxInlineLogoBytes = 256 << 10 // 256 KiB
+
+// ValidateLogoURL accepts absolute http(s) URLs, site-relative paths, or
+// inline data:image/...;base64 URIs (self-contained — immune to CORS,
+// hotlink protection, and dead hosts).
func ValidateLogoURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
@@ -102,7 +110,31 @@ func ValidateLogoURL(raw string) error {
if strings.HasPrefix(raw, "https://") || strings.HasPrefix(raw, "http://") {
return nil
}
- return fmt.Errorf("logo URL must be https://..., http://..., or a path starting with /")
+ if strings.HasPrefix(raw, "data:") {
+ return validateInlineLogo(raw)
+ }
+ return fmt.Errorf("logo URL must be https://..., http://..., a path starting with /, or an inline data:image/...;base64 URI")
+}
+
+func validateInlineLogo(raw string) error {
+ meta, payload, ok := strings.Cut(strings.TrimPrefix(raw, "data:"), ",")
+ if !ok {
+ return fmt.Errorf("inline logo: malformed data: URI (missing comma separator)")
+ }
+ if !strings.HasPrefix(meta, "image/") {
+ return fmt.Errorf("inline logo must be a data:image/... URI, got data:%s", meta)
+ }
+ if !strings.HasSuffix(meta, ";base64") {
+ return fmt.Errorf("inline logo must be base64-encoded (data:image/...;base64,...)")
+ }
+ decoded, err := base64.StdEncoding.DecodeString(payload)
+ if err != nil {
+ return fmt.Errorf("inline logo: invalid base64 payload: %w", err)
+ }
+ if len(decoded) > maxInlineLogoBytes {
+ return fmt.Errorf("inline logo is %d KiB decoded; max %d KiB (it is embedded in the catalog ConfigMap)", len(decoded)>>10, maxInlineLogoBytes>>10)
+ }
+ return nil
}
// ValidateContactEmail accepts a bare operator contact address for OpenAPI
diff --git a/internal/storefront/profile_test.go b/internal/storefront/profile_test.go
index 7a1709a9..92393985 100644
--- a/internal/storefront/profile_test.go
+++ b/internal/storefront/profile_test.go
@@ -1,6 +1,7 @@
package storefront_test
import (
+ "encoding/base64"
"testing"
"github.com/ObolNetwork/obol-stack/internal/schemas"
@@ -35,6 +36,14 @@ func TestValidateLogoURL(t *testing.T) {
{"https://cdn.example/logo.png", true},
{"/obol-stack-logo.png", true},
{"logo.png", false},
+ // Inline data URIs: image mime + base64 only, size-capped.
+ {"data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\n")), true},
+ {"data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte("")), true},
+ {"data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte("")), false},
+ {"data:image/png;base64,not-valid-base64!!!", false},
+ {"data:image/png;base64", false}, // no comma separator
+ {"data:image/svg+xml,", false}, // not base64-encoded
+ {"data:image/png;base64," + base64.StdEncoding.EncodeToString(make([]byte, 300<<10)), false}, // over 256 KiB cap
} {
err := storefront.ValidateLogoURL(tc.raw)
if tc.ok && err != nil {
From bfa56e28b6fbae84affd73bd99595f3feb1b98c4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ois=C3=ADn=20Kyne?=
Date: Fri, 3 Jul 2026 14:19:30 +0100
Subject: [PATCH 2/4] allow for a local file too
---
cmd/obol/sell_info.go | 56 ++++++++++++++++++---
internal/storefront/profile.go | 62 +++++++++++++++++++++++
internal/storefront/profile_test.go | 78 +++++++++++++++++++++++++++++
3 files changed, 188 insertions(+), 8 deletions(-)
diff --git a/cmd/obol/sell_info.go b/cmd/obol/sell_info.go
index 1440d6e8..b8db5124 100644
--- a/cmd/obol/sell_info.go
+++ b/cmd/obol/sell_info.go
@@ -107,13 +107,15 @@ 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, pass an inline data URI (self-contained, no CORS):
+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-url "data:image/png;base64,$(base64 -i logo.png)"`,
+ 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://..., /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 {
@@ -128,9 +130,12 @@ brittleness entirely, pass an inline data URI (self-contained, no CORS):
}
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"))
}
@@ -140,13 +145,20 @@ brittleness entirely, pass an inline data URI (self-contained, no CORS):
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(¤t, mustSellerBaseURL(cfg))
if v, err := u.Input("Display name", effective.DisplayName); err == nil {
@@ -155,8 +167,15 @@ brittleness entirely, pass an inline data URI (self-contained, no CORS):
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)
@@ -251,6 +270,27 @@ 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
@@ -362,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()
}
@@ -543,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 {
diff --git a/internal/storefront/profile.go b/internal/storefront/profile.go
index d205f488..cc6b53fe 100644
--- a/internal/storefront/profile.go
+++ b/internal/storefront/profile.go
@@ -4,7 +4,9 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
+ "net/http"
"net/mail"
+ "os"
"path/filepath"
"strings"
@@ -116,6 +118,44 @@ func ValidateLogoURL(raw string) error {
return fmt.Errorf("logo URL must be https://..., http://..., a path starting with /, or an inline data:image/...;base64 URI")
}
+// InlineLogoFromFile reads a local image file and returns it as a
+// data:image/...;base64 URI suitable for StorefrontProfile.LogoURL. Inline
+// logos are self-contained — immune to CORS, hotlink protection, and dead
+// hosts — but must fit the ConfigMap budget (maxInlineLogoBytes).
+func InlineLogoFromFile(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", fmt.Errorf("read logo file: %w", err)
+ }
+ if len(data) == 0 {
+ return "", fmt.Errorf("logo file %s is empty", path)
+ }
+ if len(data) > maxInlineLogoBytes {
+ return "", fmt.Errorf("logo file is %d KiB; max %d KiB for an inline logo (it is embedded in the catalog ConfigMap) — host larger images at an https URL instead", len(data)>>10, maxInlineLogoBytes>>10)
+ }
+ mime := detectImageMIME(path, data)
+ if mime == "" {
+ return "", fmt.Errorf("logo file %s does not look like an image (png, jpeg, gif, webp, svg, ico)", path)
+ }
+ return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
+}
+
+// detectImageMIME sniffs an image content-type from file bytes, falling back
+// to the extension for SVG (which sniffs as XML/plain text).
+func detectImageMIME(path string, data []byte) string {
+ sniffed := http.DetectContentType(data)
+ if i := strings.Index(sniffed, ";"); i >= 0 {
+ sniffed = sniffed[:i]
+ }
+ if strings.HasPrefix(sniffed, "image/") {
+ return sniffed
+ }
+ if strings.EqualFold(filepath.Ext(path), ".svg") {
+ return "image/svg+xml"
+ }
+ return ""
+}
+
func validateInlineLogo(raw string) error {
meta, payload, ok := strings.Cut(strings.TrimPrefix(raw, "data:"), ",")
if !ok {
@@ -154,6 +194,28 @@ func ValidateContactEmail(raw string) error {
return nil
}
+// DescribeLogoURL returns a terminal-friendly rendering of a logo URL:
+// inline data: URIs are summarised (mime + decoded size) instead of dumping
+// the base64 payload; everything else passes through unchanged.
+func DescribeLogoURL(raw string) string {
+ raw = strings.TrimSpace(raw)
+ if !strings.HasPrefix(raw, "data:") {
+ return raw
+ }
+ meta, payload, ok := strings.Cut(strings.TrimPrefix(raw, "data:"), ",")
+ if !ok {
+ return raw
+ }
+ mime := strings.TrimSuffix(meta, ";base64")
+ // DecodedLen ignores '=' padding; subtract it for an exact size.
+ pad := 0
+ for i := len(payload) - 1; i >= 0 && payload[i] == '='; i-- {
+ pad++
+ }
+ size := len(payload)/4*3 - pad
+ return fmt.Sprintf("inline %s (%d KiB)", mime, (size+1023)>>10)
+}
+
// IsDefaultLogoURL reports whether url is the stack default wordmark (relative or absolute).
func IsDefaultLogoURL(raw string) bool {
raw = strings.TrimSpace(raw)
diff --git a/internal/storefront/profile_test.go b/internal/storefront/profile_test.go
index 92393985..973c5f29 100644
--- a/internal/storefront/profile_test.go
+++ b/internal/storefront/profile_test.go
@@ -2,6 +2,9 @@ package storefront_test
import (
"encoding/base64"
+ "os"
+ "path/filepath"
+ "strings"
"testing"
"github.com/ObolNetwork/obol-stack/internal/schemas"
@@ -74,6 +77,81 @@ func TestValidateContactEmail(t *testing.T) {
}
}
+func TestInlineLogoFromFile(t *testing.T) {
+ dir := t.TempDir()
+ write := func(name string, data []byte) string {
+ t.Helper()
+ path := filepath.Join(dir, name)
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return path
+ }
+ pngBytes := []byte("\x89PNG\r\n\x1a\n0000000000000000")
+
+ t.Run("png sniffed from bytes", func(t *testing.T) {
+ uri, err := storefront.InlineLogoFromFile(write("logo.bin", pngBytes))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(uri, "data:image/png;base64,") {
+ t.Fatalf("unexpected URI prefix: %.50s", uri)
+ }
+ if err := storefront.ValidateLogoURL(uri); err != nil {
+ t.Fatalf("inlined logo should validate: %v", err)
+ }
+ })
+
+ t.Run("svg by extension", func(t *testing.T) {
+ uri, err := storefront.InlineLogoFromFile(write("logo.svg", []byte(``)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(uri, "data:image/svg+xml;base64,") {
+ t.Fatalf("unexpected URI prefix: %.50s", uri)
+ }
+ })
+
+ t.Run("rejects non-image", func(t *testing.T) {
+ if _, err := storefront.InlineLogoFromFile(write("notes.txt", []byte("hello"))); err == nil {
+ t.Fatal("expected error for non-image file")
+ }
+ })
+
+ t.Run("rejects oversized", func(t *testing.T) {
+ big := append(append([]byte{}, pngBytes...), make([]byte, 300<<10)...)
+ if _, err := storefront.InlineLogoFromFile(write("big.png", big)); err == nil {
+ t.Fatal("expected error for oversized file")
+ }
+ })
+
+ t.Run("rejects empty and missing", func(t *testing.T) {
+ if _, err := storefront.InlineLogoFromFile(write("empty.png", nil)); err == nil {
+ t.Fatal("expected error for empty file")
+ }
+ if _, err := storefront.InlineLogoFromFile(filepath.Join(dir, "nope.png")); err == nil {
+ t.Fatal("expected error for missing file")
+ }
+ })
+}
+
+func TestDescribeLogoURL(t *testing.T) {
+ payload := base64.StdEncoding.EncodeToString(make([]byte, 4<<10))
+ for _, tc := range []struct {
+ raw, want string
+ }{
+ {"https://cdn.example/logo.png", "https://cdn.example/logo.png"},
+ {"/obol-stack-logo.png", "/obol-stack-logo.png"},
+ {"", ""},
+ {"data:image/png;base64," + payload, "inline image/png (4 KiB)"},
+ {"data:garbage-no-comma", "data:garbage-no-comma"},
+ } {
+ if got := storefront.DescribeLogoURL(tc.raw); got != tc.want {
+ t.Fatalf("DescribeLogoURL(%.40q) = %q, want %q", tc.raw, got, tc.want)
+ }
+ }
+}
+
func TestIsDefaultLogoURL(t *testing.T) {
for _, tc := range []struct {
raw string
From 8d3fe2561d705c43b5bef8330200b85d5762846c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ois=C3=ADn=20Kyne?=
Date: Fri, 3 Jul 2026 16:09:58 +0100
Subject: [PATCH 3/4] Register to x402scan via cli
---
CLAUDE.md | 4 +-
cmd/obol/sell.go | 17 +-
cmd/obol/sell_register_x402scan.go | 208 +++++++++++++++++++++
cmd/obol/sell_register_x402scan_test.go | 36 ++++
internal/erc8004/signer.go | 40 ++++
internal/x402scan/client.go | 235 +++++++++++++++++++++++
internal/x402scan/client_test.go | 236 ++++++++++++++++++++++++
internal/x402scan/siwe.go | 80 ++++++++
8 files changed, 849 insertions(+), 7 deletions(-)
create mode 100644 cmd/obol/sell_register_x402scan.go
create mode 100644 cmd/obol/sell_register_x402scan_test.go
create mode 100644 internal/x402scan/client.go
create mode 100644 internal/x402scan/client_test.go
create mode 100644 internal/x402scan/siwe.go
diff --git a/CLAUDE.md b/CLAUDE.md
index 3eaf1db6..4d0a6c25 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 --model --pay-to --price|--per-mtok [--token USDC|OBOL]`, `obol sell http --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 --model --pay-to --price|--per-mtok [--token USDC|OBOL]`, `obol sell http --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`):
```
diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go
index 036c8e78..5a7f4cb3 100644
--- a/cmd/obol/sell.go
+++ b/cmd/obol/sell.go
@@ -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)
@@ -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
@@ -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",
diff --git a/cmd/obol/sell_register_x402scan.go b/cmd/obol/sell_register_x402scan.go
new file mode 100644
index 00000000..f9845610
--- /dev/null
+++ b/cmd/obol/sell_register_x402scan.go
@@ -0,0 +1,208 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/ObolNetwork/obol-stack/internal/config"
+ "github.com/ObolNetwork/obol-stack/internal/erc8004"
+ "github.com/ObolNetwork/obol-stack/internal/hermes"
+ "github.com/ObolNetwork/obol-stack/internal/kubectl"
+ "github.com/ObolNetwork/obol-stack/internal/ui"
+ "github.com/ObolNetwork/obol-stack/internal/x402scan"
+ "github.com/urfave/cli/v3"
+)
+
+// sellRegisterX402scanCommand registers the storefront's public origin in the
+// x402scan.com discovery index. x402scan crawls the origin's /openapi.json
+// (which the serviceoffer-controller already publishes with x-payment-info
+// per paid operation), live-probes each advertised endpoint for a real 402
+// challenge, and lists the ones that pass. Authentication is SIWX: the
+// registry's challenge is signed EIP-191 by the agent's remote-signer — the
+// CLI never touches key material.
+func sellRegisterX402scanCommand(cfg *config.Config) *cli.Command {
+ return &cli.Command{
+ Name: "x402scan",
+ Usage: "Register this storefront's origin in the x402scan discovery index",
+ Description: `Submits the storefront's public origin to x402scan.com so agents can
+discover its paid services. x402scan reads /openapi.json from the origin and
+probes every advertised endpoint for a live x402 402 challenge, so:
+
+ - a public tunnel hostname must be configured (obol tunnel setup);
+ x402scan rejects ephemeral quick-tunnel and private/localhost origins
+ - at least one offer must be Ready (obol sell status)
+
+The request is authenticated by signing the registry's Sign-In-With-X
+challenge with the agent's wallet via the remote-signer. Re-running is safe:
+registration is idempotent per origin, and offers that disappeared from the
+catalog are deprecated on the x402scan side.
+
+Examples:
+ obol sell register x402scan
+ obol sell register x402scan --origin https://store.example.com`,
+ Flags: []cli.Flag{
+ &cli.StringFlag{
+ Name: "origin",
+ Usage: "Public origin to register (auto-detected from the tunnel hostname if not set)",
+ },
+ &cli.StringFlag{
+ Name: "registry-url",
+ Usage: "x402scan-compatible registry base URL",
+ Value: x402scan.DefaultBaseURL,
+ Hidden: true,
+ },
+ },
+ Action: func(ctx context.Context, cmd *cli.Command) error {
+ u := getUI(cmd)
+ if err := kubectl.EnsureCluster(cfg); err != nil {
+ return err
+ }
+
+ origin, err := resolveX402scanOrigin(cfg, cmd.String("origin"))
+ if err != nil {
+ return err
+ }
+
+ // Non-fatal preflight: x402scan discovers services from the
+ // origin's /openapi.json, so a missing/empty doc means the
+ // registration will come back "no discovery" or "no valid
+ // resources". Warn early with the local fix.
+ preflightOpenAPI(ctx, u, origin)
+
+ // Same signer posture as ERC-8004 registration: all signing via
+ // the agent's remote-signer, no key material in the CLI.
+ 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)
+ }
+ pf, err := startSignerPortForward(cfg, signerNS)
+ if err != nil {
+ return fmt.Errorf("port-forward to remote-signer: %w", err)
+ }
+ defer pf.Stop()
+
+ signer := erc8004.NewRemoteSigner(fmt.Sprintf("http://localhost:%d", pf.localPort))
+ addr, err := signer.GetAddress(ctx)
+ if err != nil {
+ return err
+ }
+
+ u.Infof("Registering %s with x402scan...", origin)
+ u.Dim(fmt.Sprintf(" Signing as agent wallet %s", addr.Hex()))
+
+ client := x402scan.NewClient(cmd.String("registry-url"))
+ var result *x402scan.RegisterResult
+ err = u.RunWithSpinner("x402scan is crawling and probing the origin", func() error {
+ var regErr error
+ result, regErr = client.RegisterOrigin(ctx, origin, addr, signer)
+ return regErr
+ })
+ if err != nil {
+ switch {
+ case errors.Is(err, x402scan.ErrNoDiscovery):
+ u.Error(err.Error())
+ u.Dim(" x402scan could not read a discovery document from the origin.")
+ u.Dim(fmt.Sprintf(" Check that %s/openapi.json is publicly reachable and the tunnel is up (obol tunnel status).", origin))
+ case errors.Is(err, x402scan.ErrNoValidResources):
+ u.Error(err.Error())
+ u.Dim(" A discovery document was found, but no endpoint answered with a live x402 402 challenge.")
+ u.Dim(" Check offers are Ready (obol sell status) and publicly reachable through the tunnel.")
+ }
+ return err
+ }
+
+ if u.IsJSON() {
+ return u.JSON(result)
+ }
+
+ u.Successf("Registered %d/%d resources on x402scan (source: %s)", result.Registered, result.Total, result.Source)
+ if result.SIWX > 0 {
+ u.Printf(" Identity-only (SIWX) resources: %d", result.SIWX)
+ }
+ if result.Deprecated > 0 {
+ u.Printf(" Deprecated stale resources: %d", result.Deprecated)
+ }
+ for _, f := range result.FailedList {
+ u.Warnf("failed probe: %s — %s", f.URL, f.Error)
+ }
+ if result.Warning != "" {
+ u.Warn(result.Warning)
+ }
+ if result.ContactEmail != "" {
+ u.Dim(" Contact email on record: " + result.ContactEmail)
+ }
+ u.Blank()
+ u.Dim("Browse the index: https://x402scan.com/resources")
+ return nil
+ },
+ }
+}
+
+// resolveX402scanOrigin picks the public origin to register and rejects
+// origins x402scan is known to refuse (local hostnames, ephemeral
+// quick-tunnel domains) with actionable errors instead of a remote 4xx.
+func resolveX402scanOrigin(cfg *config.Config, explicit string) (string, error) {
+ origin := strings.TrimSpace(explicit)
+ if origin == "" {
+ base, err := sellerBaseURL(cfg)
+ if err != nil {
+ return "", fmt.Errorf("resolve public origin: %w (pass --origin explicitly)", err)
+ }
+ origin = base
+ }
+
+ parsed, err := url.Parse(origin)
+ if err != nil || parsed.Host == "" {
+ return "", fmt.Errorf("invalid origin %q: expected https://", origin)
+ }
+ host := parsed.Hostname()
+ switch {
+ case host == "obol.stack" || host == "localhost" || host == "127.0.0.1":
+ return "", errors.New("no public hostname configured — x402scan must be able to reach the storefront from the internet.\n\n Set up a permanent tunnel hostname first: obol tunnel setup\n Then re-run, or pass --origin https://")
+ case strings.HasSuffix(host, ".trycloudflare.com"):
+ return "", errors.New("the current tunnel is an ephemeral quick-tunnel (*.trycloudflare.com), which x402scan rejects.\n\n Register a permanent hostname on your own domain (obol tunnel setup, see also obol domain), then re-run")
+ }
+ if parsed.Scheme != "https" {
+ return "", fmt.Errorf("origin %s must be https:// — x402scan only indexes TLS origins", origin)
+ }
+ // Registration is per-origin; strip any path so what we submit matches
+ // what the registry crawls.
+ return parsed.Scheme + "://" + parsed.Host, nil
+}
+
+// preflightOpenAPI warns (never fails) when the origin's /openapi.json is
+// unreachable or advertises no operations — the two states that make the
+// remote registration a guaranteed no-op.
+func preflightOpenAPI(ctx context.Context, u *ui.UI, origin string) {
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, origin+"/openapi.json", nil)
+ if err != nil {
+ return
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ u.Warnf("could not fetch %s/openapi.json (%v) — x402scan discovers services from it; check the tunnel is up", origin, err)
+ return
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ u.Warnf("%s/openapi.json returned HTTP %d — x402scan discovers services from it; check the tunnel is up", origin, resp.StatusCode)
+ return
+ }
+ var doc struct {
+ Paths map[string]json.RawMessage `json:"paths"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&doc); err == nil && len(doc.Paths) == 0 {
+ u.Warn("the published /openapi.json advertises no operations — put at least one offer on sale (obol sell inference|http|agent) before registering")
+ }
+}
diff --git a/cmd/obol/sell_register_x402scan_test.go b/cmd/obol/sell_register_x402scan_test.go
new file mode 100644
index 00000000..a810346f
--- /dev/null
+++ b/cmd/obol/sell_register_x402scan_test.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestResolveX402scanOrigin_Explicit(t *testing.T) {
+ for _, tc := range []struct {
+ in string
+ want string
+ errPart string
+ }{
+ {in: "https://store.example.com", want: "https://store.example.com"},
+ {in: "https://store.example.com/some/path", want: "https://store.example.com"},
+ {in: "http://store.example.com", errPart: "must be https"},
+ {in: "https://obol.stack:8080", errPart: "no public hostname"},
+ {in: "https://localhost:8080", errPart: "no public hostname"},
+ {in: "https://abc-def.trycloudflare.com", errPart: "quick-tunnel"},
+ {in: "not a url", errPart: "invalid origin"},
+ } {
+ got, err := resolveX402scanOrigin(nil, tc.in)
+ if tc.errPart != "" {
+ if err == nil || !strings.Contains(err.Error(), tc.errPart) {
+ t.Fatalf("%q: expected error containing %q, got %v", tc.in, tc.errPart, err)
+ }
+ continue
+ }
+ if err != nil {
+ t.Fatalf("%q: %v", tc.in, err)
+ }
+ if got != tc.want {
+ t.Fatalf("%q: got %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
diff --git a/internal/erc8004/signer.go b/internal/erc8004/signer.go
index 1a553bc6..6201733a 100644
--- a/internal/erc8004/signer.go
+++ b/internal/erc8004/signer.go
@@ -127,6 +127,46 @@ func (s *RemoteSigner) SignTransaction(ctx context.Context, addr common.Address,
return sr.SignedTransaction, nil
}
+// SignMessage signs a plain-text message with the EIP-191 personal-message
+// prefix via the remote-signer (POST /api/v1/sign/{address}/message). Used
+// for SIWE / Sign-In-With-X authentication. Returns the 65-byte signature
+// as a 0x-prefixed hex string.
+func (s *RemoteSigner) SignMessage(ctx context.Context, addr common.Address, message string) (string, error) {
+ url := fmt.Sprintf("%s/api/v1/sign/%s/message", s.baseURL, addr.Hex())
+
+ body, err := json.Marshal(map[string]string{"message": message})
+ if err != nil {
+ return "", fmt.Errorf("remote-signer: marshal message: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return "", fmt.Errorf("remote-signer: build request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("remote-signer: sign message: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("remote-signer: sign message: HTTP %d: %s", resp.StatusCode, body)
+ }
+
+ var sr signResponse
+ if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
+ return "", fmt.Errorf("remote-signer: decode response: %w", err)
+ }
+ if sr.Error != "" {
+ return "", fmt.Errorf("remote-signer: %s", sr.Error)
+ }
+
+ return sr.Signature, nil
+}
+
// EIP712TypedData represents a full EIP-712 typed data structure for signing.
type EIP712TypedData struct {
Types map[string][]EIP712Field `json:"types"`
diff --git a/internal/x402scan/client.go b/internal/x402scan/client.go
new file mode 100644
index 00000000..49c22245
--- /dev/null
+++ b/internal/x402scan/client.go
@@ -0,0 +1,235 @@
+package x402scan
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/ethereum/go-ethereum/common"
+)
+
+// DefaultBaseURL is the public x402scan registry.
+const DefaultBaseURL = "https://x402scan.com"
+
+const registerOriginPath = "/api/x402/registry/register-origin"
+
+// siwxHeader carries the base64-encoded signed SIWX payload on the retry.
+const siwxHeader = "SIGN-IN-WITH-X"
+
+// Sentinel errors for the registry's two well-known rejection modes so the
+// CLI can print targeted fix-it guidance.
+var (
+ // ErrNoDiscovery: the registry could not find a discovery document
+ // (openapi.json / .well-known/x402) at the origin. HTTP 404.
+ ErrNoDiscovery = errors.New("x402scan found no discovery document at the origin")
+ // ErrNoValidResources: a discovery document was found but no advertised
+ // endpoint passed the live x402 402 probe. HTTP 422.
+ ErrNoValidResources = errors.New("x402scan found no valid paid resources at the origin")
+)
+
+// MessageSigner signs a plain-text message EIP-191 style with the key held
+// for addr. *erc8004.RemoteSigner satisfies this.
+type MessageSigner interface {
+ SignMessage(ctx context.Context, addr common.Address, message string) (string, error)
+}
+
+// Client talks to an x402scan-compatible registry.
+type Client struct {
+ baseURL string
+ http *http.Client
+}
+
+// NewClient returns a registry client. An empty baseURL selects the public
+// x402scan.com instance.
+func NewClient(baseURL string) *Client {
+ if strings.TrimSpace(baseURL) == "" {
+ baseURL = DefaultBaseURL
+ }
+ return &Client{
+ baseURL: strings.TrimRight(baseURL, "/"),
+ // Registration crawls the origin and live-probes every advertised
+ // endpoint before answering, so allow well over the registry's own
+ // 10s-per-fetch discovery budget.
+ http: &http.Client{Timeout: 120 * time.Second},
+ }
+}
+
+// RegisterResult is the registry's summary of a registration run.
+type RegisterResult struct {
+ Success bool `json:"success"`
+ Registered int `json:"registered"`
+ SIWX int `json:"siwx"`
+ Failed int `json:"failed"`
+ Skipped int `json:"skipped"`
+ Deprecated int `json:"deprecated"`
+ Total int `json:"total"`
+ Source string `json:"source"`
+ FailedList []FailedResource `json:"failedDetails,omitempty"`
+ SIWXList []SIWXResource `json:"siwxDetails,omitempty"`
+ ContactEmail string `json:"contactEmail,omitempty"`
+ Warning string `json:"warning,omitempty"`
+}
+
+// FailedResource describes one advertised endpoint that failed the
+// registry's live probe.
+type FailedResource struct {
+ URL string `json:"url"`
+ Error string `json:"error"`
+ Status int `json:"status,omitempty"`
+}
+
+// SIWXResource is an identity-only (wallet-gated, unpaid) endpoint.
+type SIWXResource struct {
+ URL string `json:"url"`
+}
+
+// registryError is the {"success":false,"error":{...}} failure envelope.
+type registryError struct {
+ Success bool `json:"success"`
+ Error struct {
+ Type string `json:"type"`
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
+// challengeBody is the subset of the 402 response we need: the SIWX
+// challenge under extensions["sign-in-with-x"].
+type challengeBody struct {
+ Extensions struct {
+ SignInWithX struct {
+ Info SIWXInfo `json:"info"`
+ SupportedChains []struct {
+ ChainID string `json:"chainId"`
+ Type string `json:"type"`
+ } `json:"supportedChains"`
+ } `json:"sign-in-with-x"`
+ } `json:"extensions"`
+}
+
+// siwxPayload is the signed proof sent back in the SIGN-IN-WITH-X header:
+// the challenge fields verbatim plus the signer address and signature.
+type siwxPayload struct {
+ SIWXInfo
+ Address string `json:"address"`
+ Signature string `json:"signature"`
+}
+
+// RegisterOrigin registers origin in the discovery index, authenticating as
+// addr via signer. It performs the full SIWX handshake: unauthenticated POST
+// -> 402 challenge -> EIP-191 sign -> authenticated retry.
+func (c *Client) RegisterOrigin(ctx context.Context, origin string, addr common.Address, signer MessageSigner) (*RegisterResult, error) {
+ // First POST without auth to obtain the SIWX challenge (nonce, domain,
+ // uri, timestamps) minted by the registry.
+ status, body, err := c.postRegister(ctx, origin, "")
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusPaymentRequired {
+ // Auth requirements may be relaxed in future; accept a direct answer.
+ return parseRegisterResponse(status, body)
+ }
+
+ var challenge challengeBody
+ if err := json.Unmarshal(body, &challenge); err != nil {
+ return nil, fmt.Errorf("x402scan: parse SIWX challenge: %w (body: %.300s)", err, body)
+ }
+ info := challenge.Extensions.SignInWithX.Info
+ if info.Nonce == "" || info.Domain == "" {
+ return nil, fmt.Errorf("x402scan: 402 response carried no sign-in-with-x challenge (body: %.300s)", body)
+ }
+ if info.Type != "" && info.Type != "eip191" {
+ return nil, fmt.Errorf("x402scan: registry requires unsupported signature scheme %q (only eip191 is supported)", info.Type)
+ }
+
+ message := FormatSIWEMessage(info, addr)
+ signature, err := signer.SignMessage(ctx, addr, message)
+ if err != nil {
+ return nil, fmt.Errorf("sign SIWX challenge: %w", err)
+ }
+
+ payload := siwxPayload{SIWXInfo: info, Address: addr.Hex(), Signature: signature}
+ if payload.Type == "" {
+ payload.Type = "eip191"
+ }
+ encoded, err := json.Marshal(payload)
+ if err != nil {
+ return nil, fmt.Errorf("x402scan: marshal SIWX payload: %w", err)
+ }
+
+ status, body, err = c.postRegister(ctx, origin, base64.StdEncoding.EncodeToString(encoded))
+ if err != nil {
+ return nil, err
+ }
+ if status == http.StatusPaymentRequired {
+ // Auth was rejected on the retry — surface the registry's reason
+ // (siwx_expired, siwx_invalid_signature, ...) rather than a bare 402.
+ var authErr struct {
+ Error string `json:"error"`
+ Message string `json:"message"`
+ }
+ _ = json.Unmarshal(body, &authErr)
+ if authErr.Error != "" {
+ return nil, fmt.Errorf("x402scan rejected SIWX authentication: %s: %s", authErr.Error, authErr.Message)
+ }
+ return nil, fmt.Errorf("x402scan rejected SIWX authentication (body: %.300s)", body)
+ }
+ return parseRegisterResponse(status, body)
+}
+
+func (c *Client) postRegister(ctx context.Context, origin, siwx string) (int, []byte, error) {
+ payload, err := json.Marshal(map[string]string{"origin": origin})
+ if err != nil {
+ return 0, nil, fmt.Errorf("x402scan: marshal body: %w", err)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+registerOriginPath, bytes.NewReader(payload))
+ if err != nil {
+ return 0, nil, fmt.Errorf("x402scan: build request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if siwx != "" {
+ req.Header.Set(siwxHeader, siwx)
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return 0, nil, fmt.Errorf("x402scan: %w", err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return 0, nil, fmt.Errorf("x402scan: read response: %w", err)
+ }
+ return resp.StatusCode, body, nil
+}
+
+func parseRegisterResponse(status int, body []byte) (*RegisterResult, error) {
+ switch status {
+ case http.StatusOK:
+ var result RegisterResult
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("x402scan: parse registration result: %w (body: %.300s)", err, body)
+ }
+ return &result, nil
+ case http.StatusNotFound:
+ return nil, fmt.Errorf("%w: %s", ErrNoDiscovery, registryErrorMessage(body))
+ case http.StatusUnprocessableEntity:
+ return nil, fmt.Errorf("%w: %s", ErrNoValidResources, registryErrorMessage(body))
+ default:
+ return nil, fmt.Errorf("x402scan: registration failed: HTTP %d: %.300s", status, body)
+ }
+}
+
+func registryErrorMessage(body []byte) string {
+ var e registryError
+ if err := json.Unmarshal(body, &e); err == nil && e.Error.Message != "" {
+ return e.Error.Message
+ }
+ return strings.TrimSpace(fmt.Sprintf("%.300s", body))
+}
diff --git a/internal/x402scan/client_test.go b/internal/x402scan/client_test.go
new file mode 100644
index 00000000..ab8d2163
--- /dev/null
+++ b/internal/x402scan/client_test.go
@@ -0,0 +1,236 @@
+package x402scan
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/accounts"
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/crypto"
+)
+
+// localSigner implements MessageSigner with an in-memory key, mirroring the
+// remote-signer's EIP-191 semantics (prefix + keccak + 27-offset v).
+type localSigner struct{ key *ecdsa.PrivateKey }
+
+func (s localSigner) SignMessage(_ context.Context, _ common.Address, message string) (string, error) {
+ sig, err := crypto.Sign(accounts.TextHash([]byte(message)), s.key)
+ if err != nil {
+ return "", err
+ }
+ sig[64] += 27
+ return "0x" + hex.EncodeToString(sig), nil
+}
+
+// recoverSIWX re-renders the SIWE message from a decoded payload and
+// recovers the signer address — the same verification x402scan performs.
+func recoverSIWX(t *testing.T, payload siwxPayload) common.Address {
+ t.Helper()
+ msg := FormatSIWEMessage(payload.SIWXInfo, common.HexToAddress(payload.Address))
+ sig, err := hex.DecodeString(strings.TrimPrefix(payload.Signature, "0x"))
+ if err != nil || len(sig) != 65 {
+ t.Fatalf("bad signature encoding: %v (len %d)", err, len(sig))
+ }
+ sig[64] -= 27
+ pub, err := crypto.SigToPub(accounts.TextHash([]byte(msg)), sig)
+ if err != nil {
+ t.Fatalf("recover signer: %v", err)
+ }
+ return crypto.PubkeyToAddress(*pub)
+}
+
+// newRegistry stands up a fake x402scan: 402 + SIWX challenge without the
+// header, full verification + canned result with it.
+func newRegistry(t *testing.T, result string) (*httptest.Server, *SIWXInfo) {
+ t.Helper()
+ issued := &SIWXInfo{}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != registerOriginPath {
+ http.NotFound(w, r)
+ return
+ }
+ var body struct {
+ Origin string `json:"origin"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Origin == "" {
+ http.Error(w, "bad body", http.StatusBadRequest)
+ return
+ }
+
+ header := r.Header.Get(siwxHeader)
+ if header == "" {
+ *issued = SIWXInfo{
+ Domain: "x402scan.test",
+ URI: "http://" + r.Host + registerOriginPath,
+ Version: "1",
+ ChainID: "eip155:8453",
+ Type: "eip191",
+ Nonce: "a3f1c2d4e5b6978812345678deadbeef",
+ IssuedAt: "2026-07-03T12:00:00.000Z",
+ ExpirationTime: "2026-07-03T12:05:00.000Z",
+ Statement: "Sign in to verify your wallet identity",
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusPaymentRequired)
+ challenge := map[string]any{
+ "x402Version": 2,
+ "error": "SIWX authentication required",
+ "accepts": []any{},
+ "extensions": map[string]any{
+ "sign-in-with-x": map[string]any{
+ "info": issued,
+ "supportedChains": []map[string]string{
+ {"chainId": "eip155:8453", "type": "eip191"},
+ },
+ },
+ },
+ }
+ _ = json.NewEncoder(w).Encode(challenge)
+ return
+ }
+
+ raw, err := base64.StdEncoding.DecodeString(header)
+ if err != nil {
+ http.Error(w, `{"error":"siwx_malformed"}`, http.StatusPaymentRequired)
+ return
+ }
+ var payload siwxPayload
+ if err := json.Unmarshal(raw, &payload); err != nil {
+ http.Error(w, `{"error":"siwx_malformed"}`, http.StatusPaymentRequired)
+ return
+ }
+ if payload.Nonce != issued.Nonce || payload.Domain != issued.Domain || payload.URI != issued.URI {
+ http.Error(w, `{"error":"siwx_challenge_mismatch"}`, http.StatusPaymentRequired)
+ return
+ }
+ if got := recoverSIWX(t, payload); got != common.HexToAddress(payload.Address) {
+ http.Error(w, `{"error":"siwx_invalid_signature"}`, http.StatusPaymentRequired)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(result))
+ }))
+ t.Cleanup(srv.Close)
+ return srv, issued
+}
+
+func TestRegisterOrigin_FullSIWXHandshake(t *testing.T) {
+ srv, _ := newRegistry(t, `{"success":true,"registered":2,"siwx":0,"failed":1,"skipped":0,"deprecated":1,"total":3,"source":"openapi","failedDetails":[{"url":"https://s.example/services/x","error":"no 402 challenge","status":200}],"warning":"no contact email"}`)
+
+ key, _ := crypto.GenerateKey()
+ addr := crypto.PubkeyToAddress(key.PublicKey)
+
+ result, err := NewClient(srv.URL).RegisterOrigin(context.Background(), "https://seller.example", addr, localSigner{key})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !result.Success || result.Registered != 2 || result.Total != 3 || result.Source != "openapi" {
+ t.Fatalf("unexpected result: %+v", result)
+ }
+ if len(result.FailedList) != 1 || result.FailedList[0].Error != "no 402 challenge" {
+ t.Fatalf("unexpected failedDetails: %+v", result.FailedList)
+ }
+ if result.Warning != "no contact email" {
+ t.Fatalf("unexpected warning: %q", result.Warning)
+ }
+}
+
+func TestRegisterOrigin_WrongKeyIsRejected(t *testing.T) {
+ srv, _ := newRegistry(t, `{"success":true}`)
+
+ key, _ := crypto.GenerateKey()
+ imposter, _ := crypto.GenerateKey()
+ addr := crypto.PubkeyToAddress(key.PublicKey) // claims key's address...
+
+ _, err := NewClient(srv.URL).RegisterOrigin(context.Background(), "https://seller.example", addr, localSigner{imposter})
+ if err == nil || !strings.Contains(err.Error(), "siwx_invalid_signature") {
+ t.Fatalf("expected siwx_invalid_signature rejection, got %v", err)
+ }
+}
+
+func TestRegisterOrigin_NoDiscovery(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"success":false,"error":{"type":"no_discovery","message":"no discovery document found"}}`))
+ }))
+ t.Cleanup(srv.Close)
+
+ key, _ := crypto.GenerateKey()
+ _, err := NewClient(srv.URL).RegisterOrigin(context.Background(), "https://seller.example", crypto.PubkeyToAddress(key.PublicKey), localSigner{key})
+ if !errors.Is(err, ErrNoDiscovery) {
+ t.Fatalf("expected ErrNoDiscovery, got %v", err)
+ }
+ if !strings.Contains(err.Error(), "no discovery document found") {
+ t.Fatalf("expected registry message in error, got %v", err)
+ }
+}
+
+func TestRegisterOrigin_NoValidResources(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ _, _ = w.Write([]byte(`{"success":false,"error":{"type":"no_valid_resources","message":"0 of 2 endpoints answered a 402"}}`))
+ }))
+ t.Cleanup(srv.Close)
+
+ key, _ := crypto.GenerateKey()
+ _, err := NewClient(srv.URL).RegisterOrigin(context.Background(), "https://seller.example", crypto.PubkeyToAddress(key.PublicKey), localSigner{key})
+ if !errors.Is(err, ErrNoValidResources) {
+ t.Fatalf("expected ErrNoValidResources, got %v", err)
+ }
+}
+
+func TestFormatSIWEMessage_Golden(t *testing.T) {
+ info := SIWXInfo{
+ Domain: "x402scan.com",
+ URI: "https://x402scan.com/api/x402/registry/register-origin",
+ Version: "1",
+ ChainID: "eip155:8453",
+ Type: "eip191",
+ Nonce: "deadbeefdeadbeefdeadbeefdeadbeef",
+ IssuedAt: "2026-07-03T12:00:00.000Z",
+ ExpirationTime: "2026-07-03T12:05:00.000Z",
+ Statement: "Sign in to verify your wallet identity",
+ }
+ addr := common.HexToAddress("0x8ba1f109551bd432803012645ac136ddd64dba72")
+
+ want := "x402scan.com wants you to sign in with your Ethereum account:\n" +
+ "0x8ba1f109551bD432803012645Ac136ddd64DBA72\n" +
+ "\n" +
+ "Sign in to verify your wallet identity\n" +
+ "\n" +
+ "URI: https://x402scan.com/api/x402/registry/register-origin\n" +
+ "Version: 1\n" +
+ "Chain ID: 8453\n" +
+ "Nonce: deadbeefdeadbeefdeadbeefdeadbeef\n" +
+ "Issued At: 2026-07-03T12:00:00.000Z\n" +
+ "Expiration Time: 2026-07-03T12:05:00.000Z"
+
+ if got := FormatSIWEMessage(info, addr); got != want {
+ t.Fatalf("SIWE message mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
+ }
+}
+
+func TestFormatSIWEMessage_NoStatementFollowsABNF(t *testing.T) {
+ info := SIWXInfo{
+ Domain: "x402scan.com",
+ URI: "https://x402scan.com/api",
+ Version: "1",
+ ChainID: "eip155:8453",
+ Nonce: "n0nce",
+ IssuedAt: "2026-07-03T12:00:00.000Z",
+ }
+ addr := common.HexToAddress("0x8ba1f109551bd432803012645ac136ddd64dba72")
+ got := FormatSIWEMessage(info, addr)
+ // EIP-4361 ABNF without a statement: address LF, blank line, blank line, fields.
+ if !strings.Contains(got, addr.Hex()+"\n\n\nURI: ") {
+ t.Fatalf("no-statement layout wrong:\n%s", got)
+ }
+}
diff --git a/internal/x402scan/siwe.go b/internal/x402scan/siwe.go
new file mode 100644
index 00000000..78147d4d
--- /dev/null
+++ b/internal/x402scan/siwe.go
@@ -0,0 +1,80 @@
+// Package x402scan registers this stack's public origin in the x402scan.com
+// discovery index (https://x402scan.com/discovery/spec). Registration is a
+// SIWX-authenticated POST: the registry answers an unauthenticated request
+// with a 402 carrying a Sign-In-With-X (EIP-4361 / SIWE) challenge, the
+// client signs it EIP-191 with the agent wallet, and retries with the signed
+// payload base64-encoded in the SIGN-IN-WITH-X header. x402scan then crawls
+// the origin's /openapi.json, probes each advertised endpoint for a real
+// x402 402 challenge, and indexes the ones that pass.
+package x402scan
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/ethereum/go-ethereum/common"
+)
+
+// SIWXInfo is the challenge issued by the registry under
+// extensions["sign-in-with-x"].info in the 402 body. The signed payload
+// echoes these fields verbatim (plus address and signature) — the server
+// re-renders the SIWE message from them to verify the signature, so they
+// must round-trip unmodified.
+type SIWXInfo struct {
+ Domain string `json:"domain"`
+ URI string `json:"uri"`
+ Version string `json:"version"`
+ ChainID string `json:"chainId"` // CAIP-2, e.g. "eip155:8453"
+ Type string `json:"type"` // signature scheme, "eip191"
+ Nonce string `json:"nonce"`
+ IssuedAt string `json:"issuedAt"`
+ ExpirationTime string `json:"expirationTime,omitempty"`
+ NotBefore string `json:"notBefore,omitempty"`
+ RequestID string `json:"requestId,omitempty"`
+ Statement string `json:"statement,omitempty"`
+ Resources []string `json:"resources,omitempty"`
+}
+
+// FormatSIWEMessage renders the EIP-4361 message for a challenge and signer
+// address. Layout follows the spec ABNF exactly — the server re-renders the
+// same message from the payload fields, so any deviation (a missing blank
+// line, a lowercase address) makes signature verification fail.
+func FormatSIWEMessage(info SIWXInfo, addr common.Address) string {
+ var b strings.Builder
+ // addr.Hex() is EIP-55 checksummed, which SIWE requires.
+ fmt.Fprintf(&b, "%s wants you to sign in with your Ethereum account:\n%s\n\n", info.Domain, addr.Hex())
+ if info.Statement != "" {
+ b.WriteString(info.Statement + "\n")
+ }
+ b.WriteString("\n")
+ fmt.Fprintf(&b, "URI: %s\n", info.URI)
+ fmt.Fprintf(&b, "Version: %s\n", info.Version)
+ fmt.Fprintf(&b, "Chain ID: %s\n", numericChainID(info.ChainID))
+ fmt.Fprintf(&b, "Nonce: %s\n", info.Nonce)
+ fmt.Fprintf(&b, "Issued At: %s", info.IssuedAt)
+ if info.ExpirationTime != "" {
+ fmt.Fprintf(&b, "\nExpiration Time: %s", info.ExpirationTime)
+ }
+ if info.NotBefore != "" {
+ fmt.Fprintf(&b, "\nNot Before: %s", info.NotBefore)
+ }
+ if info.RequestID != "" {
+ fmt.Fprintf(&b, "\nRequest ID: %s", info.RequestID)
+ }
+ if len(info.Resources) > 0 {
+ b.WriteString("\nResources:")
+ for _, r := range info.Resources {
+ fmt.Fprintf(&b, "\n- %s", r)
+ }
+ }
+ return b.String()
+}
+
+// numericChainID strips the CAIP-2 namespace ("eip155:8453" -> "8453") for
+// the SIWE "Chain ID" line, which is numeric per EIP-4361.
+func numericChainID(caip2 string) string {
+ if _, id, ok := strings.Cut(caip2, ":"); ok {
+ return id
+ }
+ return caip2
+}
From 8db45e0776b0d2f4c947c42542891e103989f50f Mon Sep 17 00:00:00 2001
From: bussyjd
Date: Wed, 8 Jul 2026 14:19:40 +0400
Subject: [PATCH 4/4] feat(x402scan): warn when registering a shared origin
that serves many offers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
x402scan indexes per ORIGIN — it crawls the one /openapi.json we submit and
lists everything under that origin. On the shared /services/ model that
collapses every offer into one mixed listing (a data API beside a chat agent
beside a dataset), which reads as a single blurry product to discovery
crawlers. The preflight now counts distinct offers in the advertised doc and
warns, pointing at the clean shape: one subdomain per offer. Non-fatal, like
the rest of the preflight. Pure stdlib, ~8 lines + a table test.
---
cmd/obol/sell_register_x402scan.go | 37 ++++++++++++++++++++++++-
cmd/obol/sell_register_x402scan_test.go | 29 +++++++++++++++++++
2 files changed, 65 insertions(+), 1 deletion(-)
diff --git a/cmd/obol/sell_register_x402scan.go b/cmd/obol/sell_register_x402scan.go
index f9845610..7a823056 100644
--- a/cmd/obol/sell_register_x402scan.go
+++ b/cmd/obol/sell_register_x402scan.go
@@ -202,7 +202,42 @@ func preflightOpenAPI(ctx context.Context, u *ui.UI, origin string) {
var doc struct {
Paths map[string]json.RawMessage `json:"paths"`
}
- if err := json.NewDecoder(resp.Body).Decode(&doc); err == nil && len(doc.Paths) == 0 {
+ if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
+ return
+ }
+ if len(doc.Paths) == 0 {
u.Warn("the published /openapi.json advertises no operations — put at least one offer on sale (obol sell inference|http|agent) before registering")
+ return
+ }
+ // x402scan indexes per ORIGIN — it crawls this one /openapi.json and
+ // lists everything it finds under the origin we submit. On the shared
+ // /services/ origin model, that means every offer collapses into
+ // one mixed listing (a data API next to a chat agent next to a dataset),
+ // which reads as a single blurry product to discovery crawlers. Count
+ // the distinct offers and warn: the clean shape is one origin per offer.
+ offers := map[string]struct{}{}
+ for p := range doc.Paths {
+ if name := servicesOfferName(p); name != "" {
+ offers[name] = struct{}{}
+ }
+ }
+ if len(offers) > 1 {
+ u.Warnf("this origin advertises %d offers in one /openapi.json — x402scan indexes per origin, so all of them will be listed together under %s rather than as distinct products. For clean discovery, give each offer its own subdomain (obol tunnel hostname add ) and register that origin.", len(offers), origin)
+ }
+}
+
+// servicesOfferName extracts the offer name from a shared-origin OpenAPI path
+// key like "/services/foo/v1/chat/completions" → "foo". Returns "" for paths
+// that aren't under /services/ — a per-offer subdomain roots its paths
+// elsewhere, so it correctly never trips the multi-offer warning.
+func servicesOfferName(path string) string {
+ const prefix = "/services/"
+ if !strings.HasPrefix(path, prefix) {
+ return ""
+ }
+ rest := path[len(prefix):]
+ if i := strings.IndexByte(rest, '/'); i >= 0 {
+ return rest[:i]
}
+ return rest
}
diff --git a/cmd/obol/sell_register_x402scan_test.go b/cmd/obol/sell_register_x402scan_test.go
index a810346f..725e5734 100644
--- a/cmd/obol/sell_register_x402scan_test.go
+++ b/cmd/obol/sell_register_x402scan_test.go
@@ -34,3 +34,32 @@ func TestResolveX402scanOrigin_Explicit(t *testing.T) {
}
}
}
+
+func TestServicesOfferName(t *testing.T) {
+ for _, tc := range []struct{ in, want string }{
+ {"/services/foo/v1/chat/completions", "foo"},
+ {"/services/bar", "bar"},
+ {"/services/baz/", "baz"},
+ {"/openapi.json", ""}, // per-offer subdomain root — no /services/ prefix
+ {"/audits/{id}", ""}, // app path on a dedicated origin
+ {"/", ""},
+ {"/servicesfoo", ""}, // not the /services/ prefix
+ } {
+ if got := servicesOfferName(tc.in); got != tc.want {
+ t.Errorf("servicesOfferName(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+
+ // The leak signal: two distinct /services/ prefixes = a shared
+ // origin serving multiple offers (what the preflight warns about).
+ paths := []string{"/services/foo/v1", "/services/foo/v1/models", "/services/bar", "/openapi.json"}
+ offers := map[string]struct{}{}
+ for _, p := range paths {
+ if n := servicesOfferName(p); n != "" {
+ offers[n] = struct{}{}
+ }
+ }
+ if len(offers) != 2 {
+ t.Fatalf("distinct offers = %d, want 2 (foo, bar)", len(offers))
+ }
+}