diff --git a/cmd/obol/main.go b/cmd/obol/main.go index d3432da5..dea9a096 100644 --- a/cmd/obol/main.go +++ b/cmd/obol/main.go @@ -325,6 +325,7 @@ GLOBAL OPTIONS:{{template "visibleFlagTemplate" .}}{{end}} openclawCommand(cfg), sellCommand(cfg), buyCommand(cfg), + skillsCommand(cfg), modelCommand(cfg), { Name: "app", diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 6e1287c7..e2064d79 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -53,6 +53,7 @@ func sellCommand(cfg *config.Config) *cli.Command { sellHTTPCommand(cfg), sellMCPCommand(cfg), sellAgentCommand(cfg), + sellSkillCommand(cfg), sellDemoCommand(cfg), sellListCommand(cfg), sellStatusCommand(cfg), @@ -2811,10 +2812,30 @@ func sellDeleteCommand(cfg *config.Config) *cli.Command { // controller renders an active:false / x402Support:false tombstone // document while keeping the agentId. + // For type=skill offers the bundle ConfigMap is CLI/agent-created + // (not controller-owned, so no ownerRef GC). Capture its name + // before the offer disappears and delete it afterwards. + bundleCM := "" + { + bin, kubeconfig := kubectl.Paths(cfg) + if out, err := kubectl.Output(bin, kubeconfig, "get", "serviceoffers.obol.org", name, "-n", ns, + "-o", "jsonpath={.spec.type}/{.spec.skill.bundleConfigMap}"); err == nil { + if typ, cm, ok := strings.Cut(strings.TrimSpace(out), "/"); ok && typ == "skill" && cm != "" { + bundleCM = cm + } + } + } + if err := kubectlRun(cfg, "delete", "serviceoffers.obol.org", name, "-n", ns); err != nil { return err } + if bundleCM != "" { + if err := kubectlRun(cfg, "delete", "configmap", bundleCM, "-n", ns, "--ignore-not-found"); err != nil { + u.Warnf("could not delete skill bundle ConfigMap %s/%s: %v", ns, bundleCM, err) + } + } + // Drop the offer's manifest from the resume ledger so the next // `obol stack up` / `obol sell resume` doesn't replay an offer // the operator just deleted. Covers every ledger-persisted type @@ -4793,7 +4814,10 @@ func resumePersistedServiceOffers(cfg *config.Config, u *ui.UI) error { u.Blank() u.Infof("Resuming %d locally-persisted sell offer(s)...", len(manifests)) for _, m := range manifests { - if err := kubectlApply(cfg, m.Manifest); err != nil { + // resumeApplyManifest (sell_skill.go) routes ConfigMap items in + // List bundles through server-side apply; skill bundle payloads + // overflow the client-side last-applied annotation otherwise. + if err := resumeApplyManifest(cfg, m.Manifest); err != nil { u.Warnf("resume %s %s/%s: %v", m.label(), m.Namespace, m.Name, err) continue } diff --git a/cmd/obol/sell_skill.go b/cmd/obol/sell_skill.go new file mode 100644 index 00000000..f47453d1 --- /dev/null +++ b/cmd/obol/sell_skill.go @@ -0,0 +1,570 @@ +package main + +// obol sell skill — sell a skill (SKILL.md + scripts bundle) as one +// sellable + ratable unit. +// +// Pack the skill directory into a deterministic gzipped tarball, store +// it in a ConfigMap, and publish a ServiceOffer of type=skill. The +// serviceoffer-controller renders a restricted-PSS busybox bundle server +// from the ConfigMap and gates /services//* behind x402; buyers +// download bundle.tar.gz with a one-shot paid request and can verify the +// sha256 offline and against the seller's ERC-8004 metadata anchor. +// +// To sell a skill's *execution* rather than its bytes, gate the agent +// that carries it with the existing agent path: `obol agent new +// --skills ` then `obol sell agent `. + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/embed" + "github.com/ObolNetwork/obol-stack/internal/hermes" + "github.com/ObolNetwork/obol-stack/internal/kubectl" + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "github.com/ObolNetwork/obol-stack/internal/schemas" + "github.com/ObolNetwork/obol-stack/internal/skillpkg" + "github.com/ObolNetwork/obol-stack/internal/tunnel" + "github.com/ObolNetwork/obol-stack/internal/ui" + "github.com/ObolNetwork/obol-stack/internal/validate" + x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" + "github.com/urfave/cli/v3" +) + +// skillBundleConfigMapSuffix names the operator-owned ConfigMap that +// carries the gzipped bundle bytes: "-skill-bundle". Distinct +// from monetizeapi.SkillBundleWorkloadName ("so--bundle"), which +// names the controller-rendered bundle-server children. +const skillBundleConfigMapSuffix = "-skill-bundle" + +var ( + // skillNameRe mirrors the CRD pattern on spec.skill.name. + skillNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + // skillVersionRe mirrors the CRD pattern on spec.skill.version. + skillVersionRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) +) + +func skillBundleConfigMapName(offerName string) string { + return offerName + skillBundleConfigMapSuffix +} + +func sellSkillCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "skill", + Usage: "Sell a skill bundle (SKILL.md + scripts) as a paid download", + ArgsUsage: "", + Description: `Packages a skill directory into a deterministic gzipped tarball and +publishes it behind an x402 payment gate as a ServiceOffer of +type=skill. The bundle's sha256 is pinned in the offer, surfaced in the +402 response (extra.skill), and can be anchored on the ERC-8004 Identity +Registry with ` + "`obol skills calldata set-hash`" + `. + +To sell a skill's execution rather than its bytes, gate the agent that +carries it: ` + "`obol agent new --skills `" + ` then +` + "`obol sell agent `" + `. + +Examples: + obol sell skill quant-notes --from ./skills/quant-notes --skill-version 0.1.0 \ + --per-request 0.25 --chain base --pay-to 0x... + obol sell skill buy-x402 --from-embedded buy-x402 --skill-version 0.1.0 --price 0.05`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "from", + Usage: "Directory containing the skill to package (must contain SKILL.md)", + }, + &cli.StringFlag{ + Name: "from-embedded", + Usage: "Name of an embedded obol skill to package (mutually exclusive with --from)", + }, + &cli.StringFlag{ + Name: "skill-name", + Usage: "Skill name for the @ ref (default: the embedded skill name with --from-embedded, otherwise the offer name)", + }, + &cli.StringFlag{ + Name: "skill-version", + Usage: "Skill version for the @ ref (e.g. 0.1.0)", + Required: true, + }, + &cli.StringFlag{ + Name: "display-name", + Usage: "Human-friendly display name for catalog surfaces", + }, + &cli.StringFlag{ + Name: "description", + Aliases: []string{"register-description"}, + Usage: "Human-readable description. Surfaced on the 402 payment page, in the storefront catalog, and on the ERC-8004 registration document.", + }, + payToFlag("Payment recipient address"), + &cli.StringFlag{ + Name: "chain", + Usage: "Payment chain (base, base-sepolia, ethereum)", + Value: "base", + }, + &cli.StringFlag{ + Name: "token", + Usage: "Payment token (USDC, OBOL)", + Value: "USDC", + }, + &cli.StringFlag{ + Name: "price", + Usage: "Per-request price in the selected payment token (one paid request = one bundle download)", + }, + &cli.StringFlag{ + Name: "per-request", + Usage: "Per-request price (alias for --price)", + }, + &cli.StringFlag{ + Name: "path", + Usage: "URL path prefix (default: /services/)", + }, + &cli.IntFlag{ + Name: "max-timeout", + Usage: "Payment validity window in seconds", + Value: 300, + }, + &cli.StringFlag{ + Name: "namespace", + Aliases: []string{"n"}, + Usage: "Namespace for the ServiceOffer AND the bundle ConfigMap (must match — the controller reads the ConfigMap from the offer's namespace)", + Value: "default", + }, + &cli.BoolFlag{ + Name: "no-register", + Usage: "Skip ERC-8004 registration metadata. Useful for local dev.", + }, + &cli.StringFlag{ + Name: "register-name", + Usage: "Agent name for ERC-8004 registration (defaults to the offer name)", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + if cmd.NArg() != 1 { + return fmt.Errorf("offer name required: obol sell skill (--from | --from-embedded )") + } + name := strings.TrimSpace(cmd.Args().First()) + if err := validate.Name(name); err != nil { + return err + } + + version := strings.TrimSpace(cmd.String("skill-version")) + if !skillVersionRe.MatchString(version) || len(version) > 64 { + return fmt.Errorf("invalid --skill-version %q: must match %s (max 64 chars), e.g. 0.1.0", version, skillVersionRe) + } + + price := strings.TrimSpace(cmd.String("price")) + if price == "" { + price = strings.TrimSpace(cmd.String("per-request")) + } + if price == "" { + return fmt.Errorf("price required: use --price or --per-request (skills are priced per request — one paid request, one download)") + } + + return runSellSkillShare(ctx, cfg, u, cmd, name, version, price) + }, + } +} + +// runSellSkillShare is SHARE mode: pack → ConfigMap → type=skill offer. +func runSellSkillShare(_ context.Context, cfg *config.Config, u *ui.UI, cmd *cli.Command, name, version, price string) error { + from := strings.TrimSpace(cmd.String("from")) + fromEmbedded := strings.TrimSpace(cmd.String("from-embedded")) + if err := validateSkillSourceFlags(from, fromEmbedded); err != nil { + return err + } + + srcDir := from + skillName := name + if fromEmbedded != "" { + dir, cleanup, err := materializeEmbeddedSkill(fromEmbedded) + if err != nil { + return err + } + defer cleanup() + srcDir = dir + skillName = fromEmbedded + } + if override := strings.TrimSpace(cmd.String("skill-name")); override != "" { + skillName = override + } + if !skillNameRe.MatchString(skillName) || len(skillName) > 64 { + return fmt.Errorf("invalid skill name %q: must match %s (max 64 chars); pass --skill-name to override", skillName, skillNameRe) + } + + if info, err := os.Stat(srcDir); err != nil || !info.IsDir() { + return fmt.Errorf("--from %q is not a readable directory", srcDir) + } + + // Pack deterministically. Pack enforces the post-gzip size cap + // (monetizeapi.MaxSkillBundleBytes) and the SKILL.md requirement. + gz, hash, err := skillpkg.Pack(os.DirFS(srcDir)) + if err != nil { + return err + } + // Warn-only secret scan: the bundle is published verbatim to every + // buyer, so surface anything that smells like a credential. + if warnings, scanErr := skillpkg.ScanSecrets(os.DirFS(srcDir)); scanErr == nil { + for _, w := range warnings { + u.Warnf("bundle content: %s", w) + } + } else { + u.Warnf("bundle secret scan failed (publishing anyway — inspect the bundle yourself): %v", scanErr) + } + + if err := kubectl.EnsureCluster(cfg); err != nil { + return fmt.Errorf("Obol Stack is not running. Start it with `obol stack up` first") + } + + ns := cmd.String("namespace") + + // Crypto payment resolution — same branch as `sell http` (card + // payments are deliberately not offered on sell skill v0). + wallet := strings.TrimSpace(cmd.String("pay-to")) + if wallet == "" { + if resolved, rerr := hermes.ResolveWalletAddress(cfg); rerr == nil { + wallet = resolved + u.Infof("Using wallet from remote-signer: %s", wallet) + } else if u.IsTTY() { + var inputErr error + wallet, inputErr = u.Input("Wallet address (payment recipient)", "") + if inputErr != nil || wallet == "" { + return fmt.Errorf("recipient required: use --pay-to or set X402_WALLET") + } + } else { + return fmt.Errorf("recipient required: use --pay-to or set X402_WALLET") + } + } + if err := x402verifier.ValidateWallet(wallet); err != nil { + return err + } + x402verifier.PopulateCABundle(cfg) + + chainName := cmd.String("chain") + assetTerms, err := resolveAssetTerms(cmd, &chainName) + if err != nil { + return err + } + symbol := assetTerms.Symbol + if symbol == "" { + symbol = strings.ToUpper(cmd.String("token")) + } + + // Registration block: same builder as `sell http`, with the skill + // surfaced for discovery plus integrity metadata for ERC-8004. + reg, registerEnabled, err := buildSellRegistrationConfig(name, sellRegistrationInput{ + NoRegister: cmd.Bool("no-register"), + Name: cmd.String("register-name"), + Description: cmd.String("description"), + Skills: []string{skillName}, + }) + if err != nil { + return err + } + if registerEnabled { + reg["metadata"] = map[string]string{ + "skillName": skillName, + "skillVersion": version, + "skillSha256": hash, + } + } else { + reg = nil + } + + bundleCM := buildSkillBundleConfigMapManifest(skillBundleConfigMapName(name), ns, gz) + offer := buildSkillShareOfferManifest(skillShareOfferInputs{ + OfferName: name, + Namespace: ns, + SkillName: skillName, + Version: version, + SHA256: hash, + BundleConfigMap: skillBundleConfigMapName(name), + DisplayName: strings.TrimSpace(cmd.String("display-name")), + Description: strings.TrimSpace(cmd.String("description")), + PayTo: wallet, + Chain: chainName, + Price: price, + MaxTimeout: cmd.Int("max-timeout"), + AssetTerms: assetTerms, + Path: strings.TrimSpace(cmd.String("path")), + Registration: reg, + }) + + if err := preflightOfferPathCollision(cfg, offer); err != nil { + return err + } + + // The bundle ConfigMap MUST go through server-side apply: client- + // side apply copies the whole object (base64 bundle included) into + // the last-applied-configuration annotation, which blows the 256KiB + // annotation cap for any bundle over ~190KB. + if err := applyConfigMapServerSide(cfg, bundleCM); err != nil { + return fmt.Errorf("apply bundle ConfigMap: %w", err) + } + + applyOut, err := kubectlApplyOutput(cfg, offer) + if err != nil { + return fmt.Errorf("apply ServiceOffer: %w", err) + } + if persistErr := persistServiceOffer(cfg, ns, name, skillOfferBundle(ns, name, bundleCM, offer)); persistErr != nil { + u.Warnf("could not persist offer for resume: %v", persistErr) + } + + action := "created" + if strings.Contains(applyOut, "configured") || strings.Contains(applyOut, "unchanged") { + action = "updated" + } + u.Successf("ServiceOffer %s/%s %s (type: skill, %s@%s, %s %s/download → %s)", ns, name, action, skillName, version, price, symbol, wallet) + u.Infof("Bundle: %d bytes gzipped, sha256 %s", len(gz), hash) + u.Infof("The controller will verify the hash → publish the bundle server → payment gate → route") + u.Infof("Check status: obol sell status %s -n %s", name, ns) + + servicePath := strings.TrimSpace(cmd.String("path")) + if servicePath == "" { + servicePath = "/services/" + name + } + baseURL := "http://obol.stack:8080" + if tURL, terr := tunnel.EnsureTunnelForSell(cfg, u); terr != nil { + u.Warnf("Tunnel not started: %v", terr) + u.Dim(" Start manually with: obol tunnel restart") + } else { + baseURL = strings.TrimRight(tURL, "/") + u.Successf("Tunnel: %s%s", baseURL, servicePath) + } + + printSkillPurchaseInstructions(u, baseURL, servicePath, skillName, version, chainName, hash) + + if !cmd.Bool("no-register") { + u.Dim("On-chain identity: obol sell register --chain " + chainName + " (once), then anchor the hash above.") + } + return nil +} + +// printSkillPurchaseInstructions renders the buyer-facing steps plus +// the seller's set-hash hint. Split out so the share flow stays +// readable. +// +// buy.py pay is text-only: it prints diagnostics before the body and +// decodes the body with errors="replace", so redirecting it to a file +// corrupts binary artifacts. Point it at /skill.json (JSON metadata) +// and steer the bundle download to a binary-safe x402 client. +func printSkillPurchaseInstructions(u *ui.UI, baseURL, servicePath, skillName, version, chain, hash string) { + bundleURL := baseURL + servicePath + "/bundle.tar.gz" + metadataURL := baseURL + servicePath + "/skill.json" + u.Blank() + u.Bold("Buy it (one paid request = one download):") + u.Printf(" Probe pricing: curl -i %s", bundleURL) + u.Printf(" Paid metadata: buy.py pay %s", metadataURL) + u.Printf(" Paid download: fetch %s with a binary-safe x402 client, save as %s-%s.tar.gz", bundleURL, skillName, version) + u.Dim(" (buy.py pay prints the body as text — do NOT redirect it to a file for the bundle)") + u.Printf(" Verify bundle: obol skills verify %s-%s.tar.gz --agent-id --skill %s@%s --chain %s", + skillName, version, skillName, version, chain) + u.Blank() + u.Bold("Anchor the bundle hash on ERC-8004 (sellers — submitted with YOUR wallet):") + u.Printf(" obol skills calldata set-hash %s@%s --agent-id --hash %s --chain %s", + skillName, version, hash, chain) +} + +// validateSkillSourceFlags enforces the --from XOR --from-embedded +// contract for SHARE mode. +func validateSkillSourceFlags(from, fromEmbedded string) error { + switch { + case from != "" && fromEmbedded != "": + return fmt.Errorf("--from and --from-embedded are mutually exclusive — pass exactly one") + case from == "" && fromEmbedded == "": + return fmt.Errorf("bundle source required: --from or --from-embedded ") + default: + return nil + } +} + +// materializeEmbeddedSkill copies one embedded skill into a temp dir +// (the same normalization path as agent seeding) and returns the +// per-skill directory to pack from. Caller must invoke cleanup. +func materializeEmbeddedSkill(name string) (dir string, cleanup func(), err error) { + names, err := embed.GetEmbeddedSkillNames() + if err != nil { + return "", nil, err + } + if !slices.Contains(names, name) { + return "", nil, fmt.Errorf("embedded skill %q not found; available: %s", name, strings.Join(names, ", ")) + } + tmp, err := os.MkdirTemp("", "obol-sell-skill-*") + if err != nil { + return "", nil, fmt.Errorf("create temp dir: %w", err) + } + cleanup = func() { _ = os.RemoveAll(tmp) } + if err := embed.WriteSkillSubset(tmp, []string{name}); err != nil { + cleanup() + return "", nil, err + } + return filepath.Join(tmp, name), cleanup, nil +} + +// buildSkillBundleConfigMapManifest renders the operator-owned bundle +// ConfigMap: binaryData[monetizeapi.SkillBundleKey] = gzipped tarball. +func buildSkillBundleConfigMapManifest(cmName, ns string, gz []byte) map[string]any { + return map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": cmName, + "namespace": ns, + "labels": map[string]any{ + "app.kubernetes.io/managed-by": "obol-cli", + "obol.org/skill-bundle": "true", + }, + }, + "binaryData": map[string]any{ + monetizeapi.SkillBundleKey: base64.StdEncoding.EncodeToString(gz), + }, + } +} + +// skillShareOfferInputs carries everything buildSkillShareOfferManifest +// needs; a struct so the pure builder stays unit-testable without a +// cli.Command. +type skillShareOfferInputs struct { + OfferName string + Namespace string + SkillName string + Version string + SHA256 string + BundleConfigMap string + DisplayName string + Description string + PayTo string + Chain string + Price string + MaxTimeout int + AssetTerms schemas.AssetTerms + Path string + Registration map[string]any // nil omits the block +} + +// buildSkillShareOfferManifest assembles the type=skill ServiceOffer. +// spec.upstream is pinned to the controller's deterministic bundle- +// server name so reconcileUpstream and routeRuleFromOffer need zero +// changes — and so the controller can reject spoofed upstreams (a skill +// offer may only ever advertise its own bundle server). +func buildSkillShareOfferManifest(in skillShareOfferInputs) map[string]any { + payment := map[string]any{ + "scheme": "exact", + "network": in.Chain, + "payTo": in.PayTo, + "maxTimeoutSeconds": in.MaxTimeout, + "price": map[string]any{ + "perRequest": in.Price, + }, + } + if !in.AssetTerms.IsZero() { + payment["asset"] = in.AssetTerms + } + + skill := map[string]any{ + "name": in.SkillName, + "version": in.Version, + "sha256": strings.ToLower(in.SHA256), + "bundleConfigMap": in.BundleConfigMap, + } + if in.DisplayName != "" { + skill["displayName"] = in.DisplayName + } + if in.Description != "" { + skill["description"] = in.Description + } + + spec := map[string]any{ + "type": "skill", + "skill": skill, + "upstream": map[string]any{ + "service": monetizeapi.SkillBundleWorkloadName(in.OfferName), + "namespace": in.Namespace, + "port": 8080, + "healthPath": "/skill.json", + }, + "payment": payment, + } + if in.Path != "" { + spec["path"] = in.Path + } + if in.Registration != nil { + spec["registration"] = in.Registration + } + + return map[string]any{ + "apiVersion": "obol.org/v1alpha1", + "kind": "ServiceOffer", + "metadata": map[string]any{ + "name": in.OfferName, + "namespace": in.Namespace, + }, + "spec": spec, + } +} + +// skillOfferBundle wraps the bundle ConfigMap + type=skill ServiceOffer +// in a v1 List for the resume ledger, modeled on agentOfferBundle. The +// ConfigMap precedes the offer so a replay lands the artifact before +// the controller reconciles the offer against it. The resume path +// routes kind=ConfigMap items through server-side apply (see +// resumeApplyManifest) — replaying the bundle client-side would blow +// the 256KiB last-applied-configuration annotation cap. +func skillOfferBundle(offerNs, name string, bundleCM, offer map[string]any) map[string]any { + return map[string]any{ + "apiVersion": "v1", + "kind": "List", + "metadata": map[string]any{"name": name, "namespace": offerNs}, + "items": []any{bundleCM, offer}, + } +} + +// applyConfigMapServerSide applies one ConfigMap manifest with +// `kubectl apply --server-side --force-conflicts`. Server-side apply +// keeps the (potentially ~900KB) binaryData payload out of the +// last-applied-configuration annotation, which client-side apply would +// overflow at 256KiB. +func applyConfigMapServerSide(cfg *config.Config, manifest map[string]any) error { + raw, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("marshal ConfigMap manifest: %w", err) + } + bin, kc := kubectl.Paths(cfg) + return kubectl.ApplyServerSideForceConflicts(bin, kc, raw, "obol-cli") +} + +// resumeApplyManifest replays one persisted ledger manifest. Plain +// manifests keep the legacy client-side apply. v1 List bundles are +// applied item by item in order, routing kind=ConfigMap items (skill +// bundle artifacts) through server-side apply — everything else (the +// namespace shims in agent bundles, the offers themselves) stays +// client-side. +func resumeApplyManifest(cfg *config.Config, manifest map[string]any) error { + if manifest["kind"] != "List" { + return kubectlApply(cfg, manifest) + } + items, _ := manifest["items"].([]any) + for _, it := range items { + m, ok := it.(map[string]any) + if !ok { + return fmt.Errorf("malformed List item %T in persisted offer bundle", it) + } + if m["kind"] == "ConfigMap" { + if err := applyConfigMapServerSide(cfg, m); err != nil { + return err + } + continue + } + if err := kubectlApply(cfg, m); err != nil { + return err + } + } + return nil +} diff --git a/cmd/obol/sell_skill_test.go b/cmd/obol/sell_skill_test.go new file mode 100644 index 00000000..10836c5a --- /dev/null +++ b/cmd/obol/sell_skill_test.go @@ -0,0 +1,330 @@ +package main + +import ( + "bytes" + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/embed" + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "github.com/ObolNetwork/obol-stack/internal/schemas" + "github.com/ObolNetwork/obol-stack/internal/skillpkg" + "github.com/ObolNetwork/obol-stack/internal/ui" + "github.com/urfave/cli/v3" +) + +func TestSellCommand_IncludesSkillSubcommand(t *testing.T) { + cfg := newTestConfig(t) + if c := findSubcommand(t, sellCommand(cfg), "skill"); c.ArgsUsage != "" { + t.Errorf("sell skill ArgsUsage = %q, want ", c.ArgsUsage) + } +} + +func TestSellSkill_Flags(t *testing.T) { + cfg := newTestConfig(t) + skill := findSubcommand(t, sellCommand(cfg), "skill") + flags := flagMap(skill) + + requireFlags(t, flags, + "from", "from-embedded", "skill-name", "skill-version", + "display-name", "description", + "pay-to", "chain", "token", "price", "per-request", + "path", "max-timeout", "namespace", + "no-register", "register-name", + ) + + // Selling a skill's execution is `obol sell agent`, not a flag here: + // the as-service sugar was removed as redundant. + for _, name := range []string{"as-service", "agent"} { + if _, ok := flags[name]; ok { + t.Errorf("flag --%s must not exist on sell skill (sell a skill's execution via `obol sell agent`)", name) + } + } + + // Payment flag set mirrors sell http. + assertStringDefault(t, flags, "chain", "base") + assertStringDefault(t, flags, "token", "USDC") + assertStringDefault(t, flags, "namespace", "default") + assertIntDefault(t, flags, "max-timeout", 300) + assertFlagHasAlias(t, flags, "pay-to", "wallet") + assertFlagHasAlias(t, flags, "namespace", "n") + + assertFlagRequired(t, flags, "skill-version") + + // Skills are per-request only in v0 — no per-mtok/per-hour. + for _, name := range []string{"per-mtok", "per-hour"} { + if _, ok := flags[name]; ok { + t.Errorf("flag --%s must not exist on sell skill (per-request pricing only)", name) + } + } +} + +func TestValidateSkillSourceFlags(t *testing.T) { + tests := []struct { + name string + from string + fromEmbedded string + wantErr string + }{ + {name: "from only", from: "./skills/x", fromEmbedded: ""}, + {name: "embedded only", from: "", fromEmbedded: "buy-x402"}, + {name: "both", from: "./skills/x", fromEmbedded: "buy-x402", wantErr: "mutually exclusive"}, + {name: "neither", from: "", fromEmbedded: "", wantErr: "bundle source required"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSkillSourceFlags(tt.from, tt.fromEmbedded) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want substring %q", err, tt.wantErr) + } + }) + } +} + +// TestMaterializeEmbeddedSkill_PacksDeterministically exercises the +// --from-embedded path end to end: materialize a real embedded skill +// twice and prove the two packs hash identically (both source modes +// share one normalization). +func TestMaterializeEmbeddedSkill_PacksDeterministically(t *testing.T) { + names, err := embed.GetEmbeddedSkillNames() + if err != nil || len(names) == 0 { + t.Fatalf("no embedded skills available: %v", err) + } + name := names[0] + + pack := func() string { + dir, cleanup, err := materializeEmbeddedSkill(name) + if err != nil { + t.Fatal(err) + } + defer cleanup() + if _, err := os.Stat(filepath.Join(dir, "SKILL.md")); err != nil { + t.Fatalf("materialized skill %s missing SKILL.md: %v", name, err) + } + _, hash, err := skillpkg.Pack(os.DirFS(dir)) + if err != nil { + t.Fatal(err) + } + return hash + } + + if h1, h2 := pack(), pack(); h1 != h2 { + t.Errorf("two materializations hash differently: %s vs %s", h1, h2) + } +} + +func TestMaterializeEmbeddedSkill_UnknownName(t *testing.T) { + _, _, err := materializeEmbeddedSkill("definitely-not-a-skill") + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err = %v, want not-found listing available skills", err) + } +} + +func TestSkillBundleConfigMapName(t *testing.T) { + if got := skillBundleConfigMapName("quant-notes"); got != "quant-notes-skill-bundle" { + t.Fatalf("skillBundleConfigMapName = %q, want quant-notes-skill-bundle", got) + } +} + +func TestBuildSkillBundleConfigMapManifest(t *testing.T) { + gz := []byte{0x1f, 0x8b, 0x08, 0x00} + m := buildSkillBundleConfigMapManifest("quant-skill-bundle", "default", gz) + + if m["kind"] != "ConfigMap" || m["apiVersion"] != "v1" { + t.Fatalf("unexpected kind/apiVersion: %v/%v", m["kind"], m["apiVersion"]) + } + md := m["metadata"].(map[string]any) + if md["name"] != "quant-skill-bundle" || md["namespace"] != "default" { + t.Errorf("metadata = %v", md) + } + bd := m["binaryData"].(map[string]any) + enc, ok := bd[monetizeapi.SkillBundleKey].(string) + if !ok { + t.Fatalf("binaryData missing key %q", monetizeapi.SkillBundleKey) + } + dec, err := base64.StdEncoding.DecodeString(enc) + if err != nil || string(dec) != string(gz) { + t.Errorf("binaryData does not base64 round-trip: %v", err) + } +} + +func TestBuildSkillShareOfferManifest(t *testing.T) { + hash := strings.Repeat("AB", 32) // uppercase in, lowercase out + in := skillShareOfferInputs{ + OfferName: "quant-notes", + Namespace: "default", + SkillName: "quant-notes", + Version: "0.1.0", + SHA256: hash, + BundleConfigMap: "quant-notes-skill-bundle", + DisplayName: "Quant Notes", + Description: "daily quant notes skill", + PayTo: "0x1111111111111111111111111111111111111111", + Chain: "base-sepolia", + Price: "0.25", + MaxTimeout: 300, + Registration: map[string]any{ + "enabled": true, + "skills": []string{"quant-notes"}, + "metadata": map[string]string{ + "skillName": "quant-notes", + "skillVersion": "0.1.0", + "skillSha256": strings.ToLower(hash), + }, + }, + } + m := buildSkillShareOfferManifest(in) + + spec := m["spec"].(map[string]any) + if spec["type"] != "skill" { + t.Fatalf("spec.type = %v, want skill", spec["type"]) + } + + skill := spec["skill"].(map[string]any) + if skill["name"] != "quant-notes" || skill["version"] != "0.1.0" { + t.Errorf("skill identity = %v", skill) + } + if skill["sha256"] != strings.ToLower(hash) { + t.Errorf("sha256 = %v, want lowercase %s (CRD pattern is lowercase-only)", skill["sha256"], strings.ToLower(hash)) + } + if skill["bundleConfigMap"] != "quant-notes-skill-bundle" { + t.Errorf("bundleConfigMap = %v", skill["bundleConfigMap"]) + } + if skill["displayName"] != "Quant Notes" || skill["description"] != "daily quant notes skill" { + t.Errorf("display fields = %v", skill) + } + + // Upstream is pinned to the controller's deterministic bundle-server + // name — the anti-spoof invariant the controller enforces. + up := spec["upstream"].(map[string]any) + if up["service"] != monetizeapi.SkillBundleWorkloadName("quant-notes") { + t.Errorf("upstream.service = %v, want %s", up["service"], monetizeapi.SkillBundleWorkloadName("quant-notes")) + } + if up["namespace"] != "default" || up["port"] != 8080 || up["healthPath"] != "/skill.json" { + t.Errorf("upstream = %v", up) + } + + pay := spec["payment"].(map[string]any) + if pay["network"] != "base-sepolia" || pay["payTo"] != in.PayTo { + t.Errorf("payment = %v", pay) + } + if price := pay["price"].(map[string]any); price["perRequest"] != "0.25" { + t.Errorf("price = %v", price) + } + if _, hasPath := spec["path"]; hasPath { + t.Error("spec.path must be omitted when unset") + } + if _, hasReg := spec["registration"]; !hasReg { + t.Error("spec.registration missing") + } + + // No-registration variant omits the block entirely. + in.Registration = nil + in.Path = "/services/custom" + m2 := buildSkillShareOfferManifest(in) + spec2 := m2["spec"].(map[string]any) + if _, hasReg := spec2["registration"]; hasReg { + t.Error("spec.registration must be omitted when nil") + } + if spec2["path"] != "/services/custom" { + t.Errorf("spec.path = %v", spec2["path"]) + } +} + +func TestBuildSkillShareOfferManifest_AssetTerms(t *testing.T) { + in := skillShareOfferInputs{ + OfferName: "x", Namespace: "default", SkillName: "x", Version: "1", + SHA256: strings.Repeat("a", 64), BundleConfigMap: "x-skill-bundle", + PayTo: "0x1111111111111111111111111111111111111111", Chain: "ethereum", + Price: "10", MaxTimeout: 300, + AssetTerms: schemas.AssetTerms{Address: "0xdead", Symbol: "OBOL", Decimals: 18}, + } + spec := buildSkillShareOfferManifest(in)["spec"].(map[string]any) + if _, ok := spec["payment"].(map[string]any)["asset"]; !ok { + t.Error("payment.asset missing for non-default token") + } +} + +func TestSkillOfferBundle_ShapeAndType(t *testing.T) { + cm := buildSkillBundleConfigMapManifest("x-skill-bundle", "default", []byte("gz")) + offer := buildSkillShareOfferManifest(skillShareOfferInputs{ + OfferName: "x", Namespace: "default", SkillName: "x", Version: "1", + SHA256: strings.Repeat("a", 64), BundleConfigMap: "x-skill-bundle", + PayTo: "0x1111111111111111111111111111111111111111", Chain: "base", + Price: "0.1", MaxTimeout: 300, + }) + bundle := skillOfferBundle("default", "x", cm, offer) + + if bundle["kind"] != "List" { + t.Fatalf("bundle kind = %v, want List", bundle["kind"]) + } + items := bundle["items"].([]any) + if len(items) != 2 { + t.Fatalf("items = %d, want 2", len(items)) + } + if items[0].(map[string]any)["kind"] != "ConfigMap" { + t.Error("first item must be the bundle ConfigMap (replayed before the offer)") + } + if items[1].(map[string]any)["kind"] != "ServiceOffer" { + t.Error("second item must be the ServiceOffer") + } + + // The resume ledger reports the inner offer's type for List bundles. + if got := manifestOfferType(bundle); got != "skill" { + t.Errorf("manifestOfferType = %q, want skill", got) + } + if ns, name := manifestNSName(bundle); ns != "default" || name != "x" { + t.Errorf("manifestNSName = (%q, %q)", ns, name) + } +} + +// TestSellSkill_RequiredFlagEnforced runs the command without +// --skill-version and expects urfave/cli's required-flag error before +// the action runs (no cluster involved). +func TestSellSkill_RequiredFlagEnforced(t *testing.T) { + cfg := newTestConfig(t) + root := &cli.Command{Commands: []*cli.Command{sellCommand(cfg)}} + err := root.Run(t.Context(), []string{"obol", "sell", "skill", "x", "--from", t.TempDir()}) + if err == nil || !strings.Contains(err.Error(), "skill-version") { + t.Fatalf("err = %v, want required-flag error naming skill-version", err) + } +} + +// TestPrintSkillPurchaseInstructions_BinarySafe pins the buyer-facing +// copy: buy.py pay is text-only (diagnostics before the body, lossy +// decode), so the printed instructions must point it at /skill.json and +// must never tell buyers to redirect it into the bundle file. +func TestPrintSkillPurchaseInstructions_BinarySafe(t *testing.T) { + var out, errOut bytes.Buffer + u := ui.NewForTest(&out, &errOut) + + printSkillPurchaseInstructions(u, "https://x.example.com", "/services/gas-skill", + "gas", "0.1.0", "base-sepolia", strings.Repeat("a", 64)) + got := out.String() + errOut.String() + + if strings.Contains(got, "buy.py pay https://x.example.com/services/gas-skill/bundle.tar.gz") { + t.Error("instructions must not run buy.py pay against bundle.tar.gz (text-only, corrupts gzip bytes)") + } + if strings.Contains(got, "> gas-0.1.0.tar.gz") { + t.Error("instructions must not redirect buy.py pay stdout into the bundle file") + } + for _, want := range []string{ + "buy.py pay https://x.example.com/services/gas-skill/skill.json", + "binary-safe x402 client", + "obol skills verify gas-0.1.0.tar.gz --agent-id --skill gas@0.1.0 --chain base-sepolia", + } { + if !strings.Contains(got, want) { + t.Errorf("instructions missing %q\noutput:\n%s", want, got) + } + } +} diff --git a/cmd/obol/skills.go b/cmd/obol/skills.go new file mode 100644 index 00000000..a82f21e1 --- /dev/null +++ b/cmd/obol/skills.go @@ -0,0 +1,442 @@ +package main + +// obol skills — skill-marketplace utilities on top of ERC-8004: +// anchoring a bundle's sha256 on the Identity Registry, rating skills +// via the Reputation Registry (ERC-8239 draft tag convention, obol +// interim form), reading aggregate reputation, and verifying a +// downloaded bundle against the on-chain hash. +// +// Calldata-printer pattern throughout: the CLI prints to+data, the +// OPERATOR (or buyer) submits with their own wallet. obol NEVER signs. +// +// Distinct from `obol openclaw skills`, which manages skill files on an +// OpenClaw instance's PVC. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "math/big" + "os" + "regexp" + "strings" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/erc8004" + "github.com/ObolNetwork/obol-stack/internal/stack" + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v3" +) + +func skillsCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "skills", + Usage: "Skill marketplace: anchor bundle hashes, rate skills, read reputation, verify downloads (ERC-8004)", + Commands: []*cli.Command{ + skillsCalldataCommand(cfg), + skillsReputationCommand(cfg), + skillsVerifyCommand(cfg), + }, + } +} + +func skillsCalldataCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "calldata", + Usage: "Print ERC-8004 calldata for skill operations (submitted with YOUR wallet — obol NEVER signs)", + Commands: []*cli.Command{ + skillsCalldataSetHashCommand(cfg), + skillsCalldataFeedbackCommand(cfg), + }, + } +} + +// skillsCalldataSetHashCommand prints IdentityRegistry.setMetadata +// calldata anchoring a skill bundle's sha256 under the key +// "skill.sha256:@". +func skillsCalldataSetHashCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "set-hash", + Usage: "Print IdentityRegistry setMetadata calldata anchoring a skill bundle's sha256", + ArgsUsage: "@", + Description: `Anchors the bundle hash on the seller's ERC-8004 agent so buyers can +verify a paid download against the chain (obol skills verify). + +The hash comes from --hash (printed by ` + "`obol sell skill`" + `) or is +computed from a local bundle with --from-bundle. The metadata value is +stored as the 64-char ASCII lowercase hex string. + +Example: + obol skills calldata set-hash quant-notes@0.1.0 --agent-id 42 --hash --chain base`, + Flags: []cli.Flag{ + &cli.Int64Flag{Name: "agent-id", Usage: "[REQUIRED] Your ERC-8004 agent id (Identity Registry tokenId)", Required: true}, + &cli.StringFlag{Name: "chain", Usage: "Registration chain (base, base-sepolia, ethereum)", Value: "base"}, + &cli.StringFlag{Name: "skill", Usage: "Skill ref @ (alternative to the positional argument)"}, + &cli.StringFlag{Name: "hash", Usage: "Bundle sha256 as 64 hex chars (with or without 0x prefix)"}, + &cli.StringFlag{Name: "from-bundle", Aliases: []string{"bundle"}, Usage: "Path to a bundle.tar.gz to hash instead of --hash"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + ref, err := skillRefFromCmd(cmd) + if err != nil { + return err + } + + hashArg := strings.TrimSpace(cmd.String("hash")) + bundlePath := strings.TrimSpace(cmd.String("from-bundle")) + var hexHash string + switch { + case hashArg != "" && bundlePath != "": + return fmt.Errorf("--hash and --from-bundle are mutually exclusive — pass exactly one") + case hashArg != "": + hexHash, err = parseSkillHashArg(hashArg) + if err != nil { + return err + } + case bundlePath != "": + hexHash, err = sha256File(bundlePath) + if err != nil { + return err + } + default: + return fmt.Errorf("hash source required: --hash 0x or --from-bundle ") + } + + net, err := erc8004.ResolveNetwork(cmd.String("chain")) + if err != nil { + return err + } + key := erc8004.SkillHashMetadataKey(ref) + calldata, err := erc8004.EncodeSetMetadata(big.NewInt(cmd.Int64("agent-id")), key, []byte(hexHash)) + if err != nil { + return err + } + + fmt.Printf("Skill: %s\n", ref) + fmt.Printf("Metadata key: %s\n", key) + fmt.Printf("Metadata value: %s (ASCII hex sha256)\n", hexHash) + fmt.Printf("IdentityRegistry (%s): %s\n", net.Name, net.RegistryAddress) + fmt.Printf("Calldata: 0x%x\n", calldata) + fmt.Println("Submit with YOUR wallet (the agent owner; e.g. the agent remote-signer or cast send) — the controller NEVER signs.") + fmt.Println("Note: re-submitting an unchanged value reverts on-chain (the registry rejects no-op writes).") + return nil + }, + } +} + +// skillsCalldataFeedbackCommand prints ReputationRegistry.giveFeedback +// calldata rating one skill of one agent, tagged with the ERC-8239 +// draft convention (tag1 "asr:skill", obol interim tag2). +func skillsCalldataFeedbackCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "feedback", + Usage: "Print ReputationRegistry giveFeedback calldata rating a skill (buyer-submitted)", + ArgsUsage: "@", + Description: `Rates one skill of one seller agent with a 0-100 score. The rating is +tagged tag1="asr:skill" and tag2 in the documented obol interim form of +the ERC-8239 draft, so per-skill reputation aggregates cleanly. + +Example: + obol skills calldata feedback quant-notes@0.1.0 --agent-id 42 --value 95 --chain base`, + Flags: []cli.Flag{ + &cli.Int64Flag{Name: "agent-id", Usage: "[REQUIRED] The SELLER's ERC-8004 agent id (Identity Registry tokenId)", Required: true}, + &cli.IntFlag{Name: "value", Usage: "[REQUIRED] Score 0-100", Required: true}, + &cli.StringFlag{Name: "chain", Usage: "Chain hosting the registries (base, base-sepolia, ethereum)", Value: "base"}, + &cli.StringFlag{Name: "skill", Usage: "Skill ref @ (alternative to the positional argument)"}, + &cli.StringFlag{Name: "endpoint", Usage: "Optional endpoint the rating refers to (e.g. the offer URL)"}, + &cli.StringFlag{Name: "feedback-uri", Aliases: []string{"uri"}, Usage: "Optional URI of an off-chain document backing the rating"}, + &cli.StringFlag{Name: "feedback-hash", Aliases: []string{"hash"}, Usage: "Optional 32-byte hash (0x...) of the feedback document"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + ref, err := skillRefFromCmd(cmd) + if err != nil { + return err + } + value := cmd.Int("value") + if value < 0 || value > 100 { + return fmt.Errorf("--value must be 0-100, got %d", value) + } + + net, err := erc8004.ResolveNetwork(cmd.String("chain")) + if err != nil { + return err + } + registry, err := erc8004.ReputationRegistryAddress(cmd.String("chain")) + if err != nil { + return err + } + agentID := big.NewInt(cmd.Int64("agent-id")) + tag2, err := erc8004.SkillTag2(net, agentID, ref) + if err != nil { + return err + } + + fbHash := common.Hash{} + if h := strings.TrimSpace(cmd.String("feedback-hash")); h != "" { + raw, err := hex.DecodeString(strings.TrimPrefix(strings.ToLower(h), "0x")) + if err != nil || len(raw) != 32 { + return fmt.Errorf("--feedback-hash must be 32 bytes of hex (0x + 64 chars), got %q", h) + } + fbHash = common.BytesToHash(raw) + } + + calldata, err := erc8004.EncodeGiveFeedback( + agentID, + big.NewInt(int64(value)), + 0, // score is already 0-100, no fixed-point scaling + erc8004.SkillTag1, + tag2, + strings.TrimSpace(cmd.String("endpoint")), + strings.TrimSpace(cmd.String("feedback-uri")), + fbHash, + ) + if err != nil { + return err + } + + fmt.Printf("Feedback: skill %s on agent %s, score %d/100\n", ref, agentID, value) + fmt.Printf("tag1: %s\n", erc8004.SkillTag1) + fmt.Printf("tag2: %s\n", tag2) + fmt.Printf("ReputationRegistry (%s): %s\n", net.Name, registry) + fmt.Printf("Calldata: 0x%x\n", calldata) + fmt.Println("Submit with YOUR wallet (the buyer's) — self-feedback from the agent owner reverts on-chain; the controller NEVER signs.") + return nil + }, + } +} + +// skillsReputationCommand reads the aggregate per-skill rating via +// getSummary, filtered to the skill's tag pair. +func skillsReputationCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "reputation", + Usage: "Read a skill's aggregate on-chain rating (ReputationRegistry getSummary)", + ArgsUsage: "@", + Flags: []cli.Flag{ + &cli.Int64Flag{Name: "agent-id", Usage: "[REQUIRED] The seller's ERC-8004 agent id", Required: true}, + &cli.StringFlag{Name: "chain", Usage: "Chain hosting the registries (base, base-sepolia, ethereum)", Value: "base"}, + &cli.StringFlag{Name: "skill", Usage: "Skill ref @ (alternative to the positional argument)"}, + &cli.StringSliceFlag{Name: "raters", Usage: "Optional whitelist of rater addresses (0x..., repeatable); empty = all raters"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + ref, err := skillRefFromCmd(cmd) + if err != nil { + return err + } + net, err := erc8004.ResolveNetwork(cmd.String("chain")) + if err != nil { + return err + } + registry, err := erc8004.ReputationRegistryAddress(cmd.String("chain")) + if err != nil { + return err + } + raters, err := parseRaterAddresses(cmd.StringSlice("raters")) + if err != nil { + return err + } + agentID := big.NewInt(cmd.Int64("agent-id")) + tag2, err := erc8004.SkillTag2(net, agentID, ref) + if err != nil { + return err + } + + // Read-only eRPC-backed client; no signer anywhere near this path. + client, err := erc8004.NewClientForNetwork(ctx, stack.LocalIngressURL(cfg)+"/rpc", net) + if err != nil { + return fmt.Errorf("connect to %s via eRPC: %w", net.Name, err) + } + defer client.Close() + + reader, err := erc8004.NewReputationReader(client.ETH(), registry) + if err != nil { + return err + } + summary, err := reader.Summary(ctx, agentID, raters, erc8004.SkillTag1, tag2) + if err != nil { + return err + } + + score := skillScoreString(summary.SummaryValue, summary.SummaryValueDecimals) + if u.IsJSON() { + return u.JSON(struct { + AgentID int64 `json:"agentId"` + Skill string `json:"skill"` + Network string `json:"network"` + Registry string `json:"registry"` + Tag1 string `json:"tag1"` + Tag2 string `json:"tag2"` + Count uint64 `json:"count"` + Score string `json:"score"` + }{ + AgentID: cmd.Int64("agent-id"), + Skill: ref, + Network: net.Name, + Registry: registry, + Tag1: erc8004.SkillTag1, + Tag2: tag2, + Count: summary.Count, + Score: score, + }) + } + + u.Printf("Skill: %s (agent %s on %s)", ref, agentID, net.Name) + u.Printf("tag2: %s", tag2) + u.Printf("Ratings: %d", summary.Count) + u.Printf("Score: %s / 100", score) + if len(raters) > 0 { + u.Printf("Raters: %d whitelisted", len(raters)) + } + return nil + }, + } +} + +// skillsVerifyCommand checks a downloaded bundle against the seller's +// on-chain hash anchor. Exit code is non-zero on mismatch or when no +// anchor exists, so scripts can gate installs on it. +func skillsVerifyCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "verify", + Usage: "Verify a downloaded skill bundle against the seller's on-chain sha256 anchor", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.Int64Flag{Name: "agent-id", Usage: "[REQUIRED] The seller's ERC-8004 agent id", Required: true}, + &cli.StringFlag{Name: "skill", Usage: "[REQUIRED] Skill ref @", Required: true}, + &cli.StringFlag{Name: "chain", Usage: "Chain hosting the Identity Registry (base, base-sepolia, ethereum)", Value: "base"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) + if cmd.NArg() != 1 { + return fmt.Errorf("bundle path required: obol skills verify --agent-id N --skill @") + } + bundlePath := cmd.Args().First() + + _, _, err := erc8004.ParseSkillRef(cmd.String("skill")) + if err != nil { + return err + } + ref := strings.TrimSpace(cmd.String("skill")) + + localHash, err := sha256File(bundlePath) + if err != nil { + return err + } + + net, err := erc8004.ResolveNetwork(cmd.String("chain")) + if err != nil { + return err + } + client, err := erc8004.NewClientForNetwork(ctx, stack.LocalIngressURL(cfg)+"/rpc", net) + if err != nil { + return fmt.Errorf("connect to %s via eRPC: %w", net.Name, err) + } + defer client.Close() + + key := erc8004.SkillHashMetadataKey(ref) + agentID := big.NewInt(cmd.Int64("agent-id")) + onChain, err := client.GetMetadata(ctx, agentID, key) + if err != nil { + return fmt.Errorf("read on-chain metadata %q for agent %s on %s: %w", key, agentID, net.Name, err) + } + if len(onChain) == 0 { + return fmt.Errorf("FAIL: no on-chain hash anchored for %s (agent %s, key %q, %s) — ask the seller to run `obol skills calldata set-hash`", + ref, agentID, key, net.Name) + } + + if !skillHashMatches(onChain, localHash) { + u.Errorf("MISMATCH — do not trust this bundle") + u.Printf(" local sha256: %s", localHash) + u.Printf(" on-chain anchor: %s", strings.TrimSpace(string(onChain))) + return fmt.Errorf("bundle %s does not match the on-chain hash for %s (agent %s, %s)", bundlePath, ref, agentID, net.Name) + } + + u.Successf("OK — bundle matches the on-chain anchor") + u.Printf(" skill: %s (agent %s on %s)", ref, agentID, net.Name) + u.Printf(" sha256: %s", localHash) + return nil + }, + } +} + +// ── pure helpers (unit-tested without a live chain) ───────────────────────── + +// skillRefFromCmd resolves the @ ref from the positional +// argument or --skill and validates it. +func skillRefFromCmd(cmd *cli.Command) (string, error) { + ref := strings.TrimSpace(cmd.Args().First()) + if ref == "" { + ref = strings.TrimSpace(cmd.String("skill")) + } + if ref == "" { + return "", fmt.Errorf("skill ref required: pass @ as the argument or via --skill") + } + if _, _, err := erc8004.ParseSkillRef(ref); err != nil { + return "", err + } + return ref, nil +} + +var skillHashRe = regexp.MustCompile(`^[a-f0-9]{64}$`) + +// parseSkillHashArg normalizes an operator-supplied sha256: trims, drops +// an optional 0x prefix, lowercases, and validates 64 hex chars. +func parseSkillHashArg(s string) (string, error) { + h := strings.ToLower(strings.TrimSpace(s)) + h = strings.TrimPrefix(h, "0x") + if !skillHashRe.MatchString(h) { + return "", fmt.Errorf("invalid sha256 %q: want 64 hex chars (optionally 0x-prefixed)", s) + } + return h, nil +} + +// sha256File hashes a file's bytes to lowercase hex. +func sha256File(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read %s: %w", path, err) + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]), nil +} + +// parseRaterAddresses validates and converts --raters values. +func parseRaterAddresses(raw []string) ([]common.Address, error) { + var out []common.Address + for _, r := range raw { + r = strings.TrimSpace(r) + if r == "" { + continue + } + if !common.IsHexAddress(r) { + return nil, fmt.Errorf("invalid rater address %q", r) + } + out = append(out, common.HexToAddress(r)) + } + return out, nil +} + +// skillHashMatches compares the on-chain metadata value (ASCII hex, +// possibly 0x-prefixed or differently cased) against the local +// lowercase hex hash. +func skillHashMatches(onChain []byte, localHex string) bool { + chain := strings.ToLower(strings.TrimSpace(string(onChain))) + chain = strings.TrimPrefix(chain, "0x") + return chain == strings.ToLower(strings.TrimSpace(localHex)) +} + +// skillScoreString renders getSummary's fixed-point aggregate +// (summaryValue × 10^-decimals) as a decimal string. +func skillScoreString(value *big.Int, decimals uint8) string { + if value == nil { + return "0" + } + if decimals == 0 { + return value.String() + } + f := new(big.Float).SetInt(value) + scale := new(big.Float).SetInt(new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil)) + f.Quo(f, scale) + return f.Text('f', int(decimals)) +} diff --git a/cmd/obol/skills_test.go b/cmd/obol/skills_test.go new file mode 100644 index 00000000..9de52e83 --- /dev/null +++ b/cmd/obol/skills_test.go @@ -0,0 +1,319 @@ +package main + +import ( + "math/big" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/urfave/cli/v3" +) + +func testSkillsCommand(t *testing.T) *cli.Command { + t.Helper() + return skillsCommand(newTestConfig(t)) +} + +// assertInt64FlagRequired covers *cli.Int64Flag, which the shared +// assertFlagRequired helper doesn't (ERC-8004 tokenIds exceed int32). +func assertInt64FlagRequired(t *testing.T, flags map[string]cli.Flag, name string) { + t.Helper() + f, ok := flags[name].(*cli.Int64Flag) + if !ok { + t.Fatalf("flag --%s is %T, want *cli.Int64Flag", name, flags[name]) + } + if !f.Required { + t.Errorf("flag --%s should be required", name) + } +} + +func TestSkillsCommand_Structure(t *testing.T) { + cmd := testSkillsCommand(t) + if cmd.Name != "skills" { + t.Fatalf("command name = %q, want skills", cmd.Name) + } + + calldata := findSubcommand(t, cmd, "calldata") + findSubcommand(t, calldata, "set-hash") + findSubcommand(t, calldata, "feedback") + findSubcommand(t, cmd, "reputation") + findSubcommand(t, cmd, "verify") +} + +func TestSkillsCalldataSetHash_Flags(t *testing.T) { + calldata := findSubcommand(t, testSkillsCommand(t), "calldata") + setHash := findSubcommand(t, calldata, "set-hash") + flags := flagMap(setHash) + + requireFlags(t, flags, "agent-id", "chain", "skill", "hash", "from-bundle") + assertStringDefault(t, flags, "chain", "base") + assertFlagHasAlias(t, flags, "from-bundle", "bundle") + assertInt64FlagRequired(t, flags, "agent-id") +} + +func TestSkillsCalldataFeedback_Flags(t *testing.T) { + calldata := findSubcommand(t, testSkillsCommand(t), "calldata") + feedback := findSubcommand(t, calldata, "feedback") + flags := flagMap(feedback) + + requireFlags(t, flags, "agent-id", "value", "chain", "skill", "endpoint", "feedback-uri", "feedback-hash") + assertStringDefault(t, flags, "chain", "base") + assertFlagHasAlias(t, flags, "feedback-uri", "uri") + assertFlagHasAlias(t, flags, "feedback-hash", "hash") + assertInt64FlagRequired(t, flags, "agent-id") + assertFlagRequired(t, flags, "value") +} + +func TestSkillsReputation_Flags(t *testing.T) { + reputation := findSubcommand(t, testSkillsCommand(t), "reputation") + flags := flagMap(reputation) + + requireFlags(t, flags, "agent-id", "chain", "skill", "raters") + assertStringDefault(t, flags, "chain", "base") + assertInt64FlagRequired(t, flags, "agent-id") + + if _, ok := flags["raters"].(*cli.StringSliceFlag); !ok { + t.Errorf("flag --raters is %T, want *cli.StringSliceFlag", flags["raters"]) + } +} + +func TestSkillsVerify_Flags(t *testing.T) { + verify := findSubcommand(t, testSkillsCommand(t), "verify") + flags := flagMap(verify) + + requireFlags(t, flags, "agent-id", "skill", "chain") + assertStringDefault(t, flags, "chain", "base") + assertInt64FlagRequired(t, flags, "agent-id") + assertFlagRequired(t, flags, "skill") +} + +// TestSkillsCalldataSetHash_PrintsCalldata runs the full command (no +// chain access — calldata building is pure) and checks the printer +// output carries the registry, the calldata, and the never-signs +// trailer. +func TestSkillsCalldataSetHash_PrintsCalldata(t *testing.T) { + out := captureStdout(t, func() error { + root := &cli.Command{Commands: []*cli.Command{skillsCommand(newTestConfig(t))}} + return root.Run(t.Context(), []string{ + "obol", "skills", "calldata", "set-hash", "quant-notes@0.1.0", + "--agent-id", "42", + "--chain", "base-sepolia", + "--hash", "0x" + strings.Repeat("ab", 32), + }) + }) + + for _, want := range []string{ + "skill.sha256:quant-notes@0.1.0", + "IdentityRegistry (base-sepolia): 0x8004A818BFB912233c491871b3d84c89A494BD9e", + "Calldata: 0x466648da", // setMetadata(uint256,string,bytes) selector + "NEVER signs", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q\noutput:\n%s", want, out) + } + } +} + +func TestSkillsCalldataFeedback_PrintsTagsAndCalldata(t *testing.T) { + out := captureStdout(t, func() error { + root := &cli.Command{Commands: []*cli.Command{skillsCommand(newTestConfig(t))}} + return root.Run(t.Context(), []string{ + "obol", "skills", "calldata", "feedback", "quant-notes@0.1.0", + "--agent-id", "42", + "--value", "95", + "--chain", "base-sepolia", + }) + }) + + for _, want := range []string{ + "tag1: asr:skill", + "tag2: eip155:84532:0x8004a818bfb912233c491871b3d84c89a494bd9e:42:quant-notes@0.1.0", + "ReputationRegistry (base-sepolia): 0x8004B663056A597Dffe9eCcC1965A193B7388713", + "Calldata: 0x3c036a7e", // giveFeedback selector + "self-feedback", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q\noutput:\n%s", want, out) + } + } +} + +func TestSkillsCalldataFeedback_RejectsOutOfRangeValue(t *testing.T) { + root := &cli.Command{Commands: []*cli.Command{skillsCommand(newTestConfig(t))}} + err := root.Run(t.Context(), []string{ + "obol", "skills", "calldata", "feedback", "x@1", "--agent-id", "1", "--value", "101", + }) + if err == nil || !strings.Contains(err.Error(), "0-100") { + t.Fatalf("err = %v, want 0-100 range error", err) + } +} + +func TestSkillsCalldataSetHash_HashSourceXOR(t *testing.T) { + run := func(args ...string) error { + root := &cli.Command{Commands: []*cli.Command{skillsCommand(newTestConfig(t))}} + full := append([]string{"obol", "skills", "calldata", "set-hash", "x@1", "--agent-id", "1"}, args...) + return root.Run(t.Context(), full) + } + + if err := run(); err == nil || !strings.Contains(err.Error(), "hash source required") { + t.Errorf("no source: err = %v, want hash-source error", err) + } + if err := run("--hash", strings.Repeat("ab", 32), "--from-bundle", "x.tar.gz"); err == nil || + !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("both sources: err = %v, want mutual-exclusion error", err) + } +} + +// ── pure helper tests ─────────────────────────────────────────────────────── + +func TestParseSkillHashArg(t *testing.T) { + valid := strings.Repeat("ab", 32) + tests := []struct { + name string + in string + want string + wantErr bool + }{ + {name: "plain", in: valid, want: valid}, + {name: "0x prefix", in: "0x" + valid, want: valid}, + {name: "uppercase normalized", in: strings.ToUpper(valid), want: valid}, + {name: "whitespace trimmed", in: " " + valid + "\n", want: valid}, + {name: "too short", in: valid[:62], wantErr: true}, + {name: "non-hex", in: strings.Repeat("zz", 32), wantErr: true}, + {name: "empty", in: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseSkillHashArg(tt.in) + if tt.wantErr { + if err == nil { + t.Fatalf("parseSkillHashArg(%q) = %q, want error", tt.in, got) + } + return + } + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Errorf("parseSkillHashArg(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestSkillHashMatches(t *testing.T) { + local := strings.Repeat("ab", 32) + tests := []struct { + name string + onChain string + want bool + }{ + {name: "exact", onChain: local, want: true}, + {name: "0x prefixed on chain", onChain: "0x" + local, want: true}, + {name: "uppercase on chain", onChain: strings.ToUpper(local), want: true}, + {name: "whitespace on chain", onChain: " " + local + "\n", want: true}, + {name: "mismatch", onChain: strings.Repeat("cd", 32), want: false}, + {name: "empty", onChain: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := skillHashMatches([]byte(tt.onChain), local); got != tt.want { + t.Errorf("skillHashMatches(%q) = %v, want %v", tt.onChain, got, tt.want) + } + }) + } +} + +func TestSkillScoreString(t *testing.T) { + tests := []struct { + name string + value *big.Int + decimals uint8 + want string + }{ + {name: "no scaling", value: big.NewInt(95), decimals: 0, want: "95"}, + {name: "two decimals", value: big.NewInt(9550), decimals: 2, want: "95.50"}, + {name: "zero", value: big.NewInt(0), decimals: 0, want: "0"}, + {name: "nil", value: nil, decimals: 2, want: "0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := skillScoreString(tt.value, tt.decimals); got != tt.want { + t.Errorf("skillScoreString = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseRaterAddresses(t *testing.T) { + addrs, err := parseRaterAddresses([]string{ + "0x1111111111111111111111111111111111111111", + " 0x2222222222222222222222222222222222222222 ", + "", + }) + if err != nil { + t.Fatal(err) + } + if len(addrs) != 2 { + t.Fatalf("len = %d, want 2 (empty entries skipped)", len(addrs)) + } + + if _, err := parseRaterAddresses([]string{"not-an-address"}); err == nil { + t.Error("invalid address should error") + } +} + +func TestSha256File(t *testing.T) { + p := filepath.Join(t.TempDir(), "bundle.tar.gz") + if err := os.WriteFile(p, []byte("abc"), 0o600); err != nil { + t.Fatal(err) + } + got, err := sha256File(p) + if err != nil { + t.Fatal(err) + } + // Well-known sha256("abc"). + if got != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" { + t.Errorf("sha256File = %s", got) + } + + if _, err := sha256File(filepath.Join(t.TempDir(), "missing")); err == nil { + t.Error("missing file should error") + } +} + +// captureStdout redirects os.Stdout around fn — the calldata printers +// write with fmt.Printf, mirroring bountyFeedbackCommand. +func captureStdout(t *testing.T, fn func() error) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + defer func() { os.Stdout = old }() + + runErr := fn() + + _ = w.Close() + buf := make([]byte, 0, 4096) + chunk := make([]byte, 4096) + for { + n, readErr := r.Read(chunk) + buf = append(buf, chunk[:n]...) + if readErr != nil { + break + } + } + os.Stdout = old + + if runErr != nil { + t.Fatalf("command failed: %v", runErr) + } + return string(buf) +} diff --git a/docs/guides/skill-marketplace.md b/docs/guides/skill-marketplace.md new file mode 100644 index 00000000..cf5e0673 --- /dev/null +++ b/docs/guides/skill-marketplace.md @@ -0,0 +1,330 @@ +# Skill Marketplace (v0) + +This guide walks you through selling a skill — a `SKILL.md` + scripts bundle, +the same shape as the skills shipped inside the `obol` binary — as a single +sellable and ratable unit, and through the buyer-side verification and +on-chain rating loop. + +A skill can be sold in two modes: + +- **SHARE** — the skill bundle itself is the product. A `type=skill` + ServiceOffer serves a hash-pinned `bundle.tar.gz` behind an x402 payment + gate; buyers pay per download. +- **SERVICE** — the skill stays private and buyers pay to *invoke* it: thin + sugar over the existing `type=agent` sell path, wrapping an agent that has + the skill installed. + +> [!IMPORTANT] +> The skill marketplace is alpha software (v0). If you encounter an issue, +> please open a [GitHub issue](https://github.com/ObolNetwork/obol-stack/issues). + +> [!NOTE] +> Rating and integrity ride ERC-8004 using a tag convention derived from the +> **ERC-8239 draft** ([ethereum/ERCs PR #1704](https://github.com/ethereum/ERCs/pull/1704)). +> ERC-8239 is an unmerged draft that we track; the obol interim form +> documented below may change if the final ERC diverges. See +> [The tag convention](#the-tag-convention-erc-8239-provenance). + +## System Overview + +``` +SELLER (obol stack cluster) + + obol sell skill --> bundle ConfigMap (binaryData bundle.tar.gz, <=900000 B) + --> ServiceOffer CR (type=skill, spec.skill.sha256 pins bytes) + | + v + serviceoffer-controller: + - verifies sha256(ConfigMap bytes) == spec.skill.sha256 + - renders bundle server so--bundle (busybox httpd :8080) + - publishes /services//* behind x402 ForwardAuth + +BUYER + + 1. GET /services//bundle.tar.gz -> 402 + extra.skill.sha256 + 2. Pay (x402) -> download bundle.tar.gz -> sha256sum == extra.skill.sha256 + 3. obol skills verify -> compare against on-chain pin + 4. obol skills calldata feedback -> rate it (operator submits tx) +``` + +## Prerequisites + +- A running Obol Stack (`obol stack init && obol stack up`) +- A wallet address to receive payments (`--pay-to`) +- For the on-chain steps: an ERC-8004 identity (`obol sell register`) and a + wallet with gas on the target chain — calldata printed by the CLI is + **submitted by you, the operator**; no obol component ever signs or sends + these transactions + +--- + +## Part 1: Sell a skill bundle (SHARE mode) + +Sell one of the skills embedded in the `obol` binary, or any local skill +directory (must contain a top-level `SKILL.md`): + +```bash +# From an embedded skill +obol sell skill my-skill \ + --from-embedded gas \ + --skill-version 0.1.0 \ + --per-request 0.25 \ + --chain base-sepolia \ + --pay-to 0xYourWalletAddress + +# From a local directory +obol sell skill my-skill \ + --from ./path/to/skill-dir \ + --skill-version 0.1.0 \ + --display-name "My Skill" \ + --description "What the skill does" \ + --per-request 0.25 \ + --chain base-sepolia \ + --pay-to 0xYourWalletAddress +``` + +Notes: + +- `--from` and `--from-embedded` are mutually exclusive; both paths share one + deterministic packer. +- Skills are priced **per request** (one flat price per download) — there is + no `--per-mtok`/`--per-hour` for skills in v0. +- Card payments (`--pay-with`) are not offered on `sell skill` in v0. +- Registration is on by default; use `--no-register` for local/private flows. + +What the CLI does: + +1. Packs the directory into a **deterministic** gzipped tar: entries sorted, + USTAR format, normalized modes (0644/0755), zeroed timestamps/owners, + max-compression gzip with a zeroed header. Same source tree, same bytes, + same hash — every time. +2. Enforces the compressed-size cap (**900000 bytes** — the artifact rides a + ConfigMap) and computes the lowercase hex sha256 of the gzipped bytes. +3. Writes the bundle ConfigMap (server-side apply — client-side apply would + blow the 256KiB annotation cap for larger bundles) and the `type=skill` + ServiceOffer pinning that sha256 in `spec.skill.sha256`. + +Wait for the controller to converge: + +```bash +obol sell status my-skill -n default +``` + +The controller refuses to publish unless the ConfigMap bytes hash to +`spec.skill.sha256`. Skill-specific `UpstreamHealthy=False` reasons: +`BundleMissing`, `BundleTooLarge`, `BundleHashMismatch`, and +`InvalidSkillUpstream` (a skill offer may only advertise its own +controller-rendered bundle server `so--bundle`). Once `Ready=True`, +the offer also appears on the public catalog surfaces (`/skill.md`, +`/api/services.json`) with `type=skill`. + +The offer is replayed by `obol sell resume` / `obol stack up` after a host +reboot (the bundle ConfigMap is persisted alongside the offer manifest). + +## Part 2: Verify the 402 (buyer, before paying) + +An unpaid request returns the x402 payment requirements **plus the bundle's +identity and integrity hash** — for free: + +```bash +curl -s http://obol.stack:8080/services/my-skill/bundle.tar.gz | python3 -m json.tool +``` + +Look at `accepts[0].extra.skill`: + +```json +{ + "name": "gas", + "version": "0.1.0", + "sha256": "3f8e…64-hex…b21c" +} +``` + +This is the sha256 of the exact gzipped bytes the seller's controller +verified and serves. Record it before paying — it is what you will check the +download against. + +## Part 3: Buy the bundle + +Any x402-capable client works: probe, sign one payment authorization for the +advertised price, retry with the `X-PAYMENT` header, save the response body. +Two gated paths exist on the route, each costing one `perRequest` payment: + +- `/services//bundle.tar.gz` — the artifact (binary) +- `/services//skill.json` — metadata JSON (name, version, sha256, …) + +From an obol-stack agent, the `buy-x402` skill's `buy.py pay` performs the +one-shot paid request (probe → pre-sign one auth → send; max loss = one +request price): + +```bash +python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py pay \ + "http://traefik.traefik.svc.cluster.local/services/my-skill/skill.json" +``` + +> [!NOTE] +> `buy.py pay` prints the response body as text on stdout, so use it for +> `skill.json` (or to exercise the paid loop). For the binary +> `bundle.tar.gz`, use an x402 client that writes the raw response body to +> disk. From a pod, use the in-cluster Traefik address shown above; +> `obol.stack:8080` only resolves on the host. + +## Part 4: Verify the downloaded bundle + +```bash +# Hash must equal the 402-advertised extra.skill.sha256 +sha256sum bundle.tar.gz + +# Well-formedness: gzipped tar with a top-level SKILL.md +tar -tzf bundle.tar.gz | head +``` + +If the seller has pinned the hash on-chain (Part 5), verify against the +chain too — exits non-zero on mismatch or missing metadata: + +```bash +obol skills verify bundle.tar.gz \ + --agent-id \ + --skill gas@0.1.0 \ + --chain base-sepolia +``` + +## Part 5: Pin the hash on-chain (seller operator) + +Pin the bundle hash in the ERC-8004 Identity Registry under the metadata key +`skill.sha256:@` (the value is the 64-char ASCII lowercase hex, +explorer-friendly). The CLI prints the target contract and calldata; **you +submit it with your own wallet** — the controller and agents never sign: + +```bash +obol skills calldata set-hash \ + --agent-id \ + --skill gas@0.1.0 \ + --bundle bundle.tar.gz \ + --chain base-sepolia +# IdentityRegistry (base-sepolia): 0x… +# Calldata: 0x… +``` + +Submit with any wallet that owns the agent (example with foundry's `cast`; +never paste a private key into a shared shell or commit it anywhere): + +```bash +cast send \ + --rpc-url --private-key "$YOUR_OPERATOR_KEY" +``` + +Because the canonical packer is deterministic, republishing the same skill +source yields the same hash — the on-chain pin stays valid across offer +re-creation until the skill content actually changes (then bump `--skill-version` +and pin the new ref). + +## Part 6: Rate a skill (buyer operator) + +Feedback rides the ERC-8004 Reputation Registry with the skill tag pair, so +ratings are queryable per skill-version, not just per agent: + +```bash +obol skills calldata feedback \ + --agent-id \ + --skill gas@0.1.0 \ + --value 92 \ + --chain base-sepolia +# ReputationRegistry (base-sepolia): 0x… +# Calldata: 0x… +``` + +Submit the printed calldata with **your own** wallet (same operator-submits +rule as Part 5). Self-feedback from the agent's owner wallet reverts +on-chain. This is the same calldata-printer pattern as the bounty/evaluator +feedback path (`obol bounty feedback`), which writes verdict-derived scores +to the same Reputation Registry. + +Read the aggregate back: + +```bash +obol skills reputation \ + --agent-id \ + --skill gas@0.1.0 \ + --chain base-sepolia \ + [--raters 0xAddr1,0xAddr2] # optional whitelist; default: all raters +``` + +## Selling execution instead of bytes + +When buyers should pay to *use* the skill rather than own a copy, sell the +agent that has the skill installed — through the existing agent path, with +no skill-specific flag: + +```bash +obol agent new quant --skills gas,addresses --model --create-wallet +obol sell agent quant --price 0.001 --chain base-sepolia +``` + +The offer is `type=agent`; the 402 surfaces `extra.agentModel`/ +`extra.agentSkills` and buyers call the agent's OpenAI-compatible endpoint +(`/services//v1/chat/completions`) — prefer `stream: true` for long +generations through a quick tunnel. In short: `obol sell skill` sells the +bundle bytes, `obol sell agent` sells execution. + +## Agents self-publishing skills + +A running agent can publish one of its own skills without the host CLI by +creating the bundle ConfigMap + ServiceOffer directly — its ConfigMap write +RBAC is namespace-scoped (`hermes-skill-publish` Role in +`hermes-obol-agent`), so **both objects must live in the agent's own +namespace**. The full raw-K8s recipe (canonical packing rules, ConfigMap + +ServiceOffer YAML, condition checks) lives in the embedded `sell` skill: +"Selling a Skill Bundle (type=skill)". The on-chain steps (Parts 5-6) remain +operator-only: agents surface the printed calldata, humans submit it. + +## The tag convention (ERC-8239 provenance) + +Skill feedback uses the two ERC-8004 feedback tags as follows: + +| Tag | Value | Example | +|-----|-------|---------| +| `tag1` | `asr:skill` (constant) | `asr:skill` | +| `tag2` | `eip155::::@` | `eip155:84532:0x8004a818…:42:gas@0.1.0` | + +Normalization rules (chosen for determinism): + +- `` is the lowercase hex Identity Registry address of + the chain the offer pays on +- `` is the seller's ERC-8004 token id in decimal +- `@` is the skill ref from `spec.skill` (neither part + may contain `:`) + +This is the **obol interim form** of the tag2 scheme proposed in the +ERC-8239 draft ([ethereum/ERCs PR #1704](https://github.com/ethereum/ERCs/pull/1704)). +The draft is unmerged; we track it and will migrate if the merged form +differs. The matching on-chain integrity pin uses the Identity Registry +metadata key `skill.sha256:@` with the ASCII lowercase +hex sha256 as the value. + +## Limits & caveats (v0) + +- **900000-byte compressed cap** — the artifact rides a ConfigMap. Larger + skills need trimming (or wait for a future artifact backend). +- **Per-request pricing only**; single-shot x402 pay (no buyer sidecar, no + pre-authorized pools) — exactly right for a one-shot download. +- **No card payments** on `sell skill` in v0. +- **Every gated path costs one payment** — `skill.json` included. Use the + free 402 `extra.skill` for pre-purchase checks. +- **Hash semantics**: the binding contract is `sha256(served bytes) == + spec.skill.sha256 == extra.skill.sha256`. Cross-implementation + reproducibility (re-pack from source → same hash) holds only for the + canonical packer; gzip output is implementation-specific. +- **Calldata is operator-submitted.** `obol skills calldata …` and + `obol sell register` print transactions; you sign and send them with your + own wallet. The serviceoffer-controller and agents never sign. +- Quick-tunnel hostnames change on restart; registration documents re-render + on the next reconcile. + +## Related + +- `flows/flow-19-skill-sale.sh` — end-to-end smoke for this guide +- [How to Monetize Your Inference](./monetize-inference.md) — the underlying + sell/x402/registration machinery +- Embedded skills: `sell` ("Selling a Skill Bundle (type=skill)"), + `monetize-guide`, `buy-x402` diff --git a/flows/flow-19-skill-sale.sh b/flows/flow-19-skill-sale.sh new file mode 100755 index 00000000..fb669632 --- /dev/null +++ b/flows/flow-19-skill-sale.sh @@ -0,0 +1,389 @@ +#!/bin/bash +# Flow 19: Skill Sale — type=skill ServiceOffer (skill marketplace v0). +# +# Sells an embedded skill as a paid, hash-pinned bundle download and checks +# the integrity surfaces end to end: +# +# 1. Build obol, confirm the `obol sell skill` / `obol skills` CLI surfaces +# exist (skip cleanly when this branch predates them). +# 2. `obol sell skill --from-embedded ` → bundle ConfigMap + +# type=skill ServiceOffer; controller renders the so--bundle +# busybox httpd and gates /services//* behind x402. +# 3. Assert the bundle ConfigMap bytes hash to spec.skill.sha256 and stay +# under the 900000-byte compressed cap. +# 4. Unpaid probe → 402 with accepts[0].extra.skill {name,version,sha256}; +# sha256 must equal the ConfigMap bytes hash (pre-purchase integrity). +# 5. sha256-verify the served bundle artifact and check it is a well-formed +# gzipped tar with a top-level SKILL.md. +# 6. (Gated: FLOW19_PAID_FETCH=true + funded agent wallet) one-shot paid +# fetch via the buy-x402 skill's buy.py pay. +# 7. Derive ERC-8004 set-hash + feedback (tag1=asr:skill) calldata via +# `obol skills calldata` and assert non-empty, deterministic hex. The +# calldata is OPERATOR-submitted — this flow never signs or sends a tx. +# +# Requires for the cluster section: flows 01-02 (running stack). The calldata +# section runs without a cluster. Cleanup is opt-in via FLOW_CLEANUP=1. +source "$(dirname "$0")/lib.sh" + +SKILL_NAME="${FLOW19_SKILL:-gas}" +SKILL_VERSION="${FLOW19_SKILL_VERSION:-0.1.0}" +OFFER_NAME="${FLOW19_OFFER_NAME:-flow19-${SKILL_NAME}}" +OFFER_NS="${FLOW19_NS:-default}" +PRICE="${FLOW19_PRICE:-0.001}" +FLOW19_CHAIN="${FLOW19_CHAIN:-base-sepolia}" +# Deterministic Hardhat/Anvil test address (account #1) — public test +# constant, same address lib.sh derives from the well-known mnemonic. +PAY_TO="${FLOW19_PAY_TO:-${SELLER_WALLET:-0x70997970C51812dc3A010C7d01b50e0d17dc79C8}}" +FEEDBACK_AGENT_ID="${FLOW19_AGENT_ID:-1}" +FEEDBACK_VALUE="${FLOW19_FEEDBACK_VALUE:-95}" +BUNDLE_FILE="" +CM_SHA="" + +sha256_file() { + python3 - "$1" <<'PY' +import hashlib, sys +with open(sys.argv[1], "rb") as f: + print(hashlib.sha256(f.read()).hexdigest()) +PY +} + +# §0: Prerequisites + build +step "required local tools are available" +require_tool python3 +pass "required tools found" + +step "build obol CLI" +if [ "${FLOW19_SKIP_BUILD:-false}" = "true" ] && [ -x "$OBOL" ]; then + skip "FLOW19_SKIP_BUILD=true — using existing $OBOL" +elif command -v go >/dev/null 2>&1; then + if build_out=$(cd "$OBOL_ROOT" && go build -o "$OBOL_BIN_DIR/obol" ./cmd/obol 2>&1); then + pass "built $OBOL_BIN_DIR/obol" + else + fail "go build failed — ${build_out:0:300}" + emit_metrics + exit 1 + fi +elif [ -x "$OBOL" ]; then + skip "go not on PATH — using existing $OBOL" +else + fail "no go toolchain and no prebuilt obol at $OBOL" + emit_metrics + exit 1 +fi + +# §1: CLI surface gates — skip cleanly on branches that predate the +# skill-marketplace CLI instead of failing the whole flow. +HAVE_SELL_SKILL=0 +step "obol sell skill subcommand present" +sell_help=$("$OBOL" sell --help 2>&1 || true) +if echo "$sell_help" | grep -qE '^[[:space:]]+skill([[:space:],]|$)'; then + HAVE_SELL_SKILL=1 + pass "obol sell skill is available" +else + skip "obol sell skill not in this build — skipping sell/cluster section" +fi + +HAVE_SKILLS_CMD=0 +step "obol skills command group present" +root_help=$("$OBOL" --help 2>&1 || true) +if echo "$root_help" | grep -qE '^[[:space:]]+skills([[:space:],]|$)'; then + HAVE_SKILLS_CMD=1 + pass "obol skills is available" +else + skip "obol skills not in this build — skipping calldata section" +fi + +# §2: Cluster gate +CLUSTER_OK=0 +step "local stack reachable" +if [ -f "$OBOL_CONFIG_DIR/.stack-id" ] && [ -f "$OBOL_CONFIG_DIR/kubeconfig.yaml" ] \ + && "$OBOL" kubectl cluster-info >/dev/null 2>&1; then + CLUSTER_OK=1 + pass "cluster reachable via $OBOL_CONFIG_DIR/kubeconfig.yaml" +else + skip "no running local stack — skipping sell/cluster section" +fi + +if [ "$HAVE_SELL_SKILL" = "1" ] && [ "$CLUSTER_OK" = "1" ]; then + # §3: Sell the embedded skill + step "obol sell skill $OFFER_NAME --from-embedded $SKILL_NAME" + sell_out=$("$OBOL" sell skill "$OFFER_NAME" \ + --from-embedded "$SKILL_NAME" \ + --skill-version "$SKILL_VERSION" \ + --per-request "$PRICE" \ + --chain "$FLOW19_CHAIN" \ + --pay-to "$PAY_TO" \ + --no-register \ + -n "$OFFER_NS" 2>&1) && sell_rc=0 || sell_rc=$? + if [ "${sell_rc:-1}" -eq 0 ]; then + pass "sell skill accepted" + else + fail "obol sell skill exited $sell_rc — ${sell_out:0:300}" + fi + + # §3.1: ServiceOffer landed with type=skill + spec.skill block + step "ServiceOffer $OFFER_NAME has spec.type=skill and spec.skill" + so_type=$("$OBOL" kubectl get serviceoffer "$OFFER_NAME" -n "$OFFER_NS" \ + -o jsonpath='{.spec.type}' 2>/dev/null || true) + SPEC_SHA=$("$OBOL" kubectl get serviceoffer "$OFFER_NAME" -n "$OFFER_NS" \ + -o jsonpath='{.spec.skill.sha256}' 2>/dev/null || true) + SPEC_VERSION=$("$OBOL" kubectl get serviceoffer "$OFFER_NAME" -n "$OFFER_NS" \ + -o jsonpath='{.spec.skill.version}' 2>/dev/null || true) + BUNDLE_CM=$("$OBOL" kubectl get serviceoffer "$OFFER_NAME" -n "$OFFER_NS" \ + -o jsonpath='{.spec.skill.bundleConfigMap}' 2>/dev/null || true) + if [ "$so_type" = "skill" ] && echo "$SPEC_SHA" | grep -qE '^[a-f0-9]{64}$' \ + && [ "$SPEC_VERSION" = "$SKILL_VERSION" ] && [ -n "$BUNDLE_CM" ]; then + pass "spec.skill: version=$SPEC_VERSION cm=$BUNDLE_CM sha256=${SPEC_SHA:0:12}…" + else + fail "spec mismatch: type=$so_type version=$SPEC_VERSION cm=$BUNDLE_CM sha=$SPEC_SHA" + fi + + # §3.2: Bundle ConfigMap bytes — size cap + hash pin. These are the exact + # bytes the controller hash-verifies and the bundle server serves; a + # mismatch here means the controller must refuse to publish + # (BundleHashMismatch). + step "bundle ConfigMap bytes hash to spec.skill.sha256 (<=900000 bytes)" + BUNDLE_FILE="$(mktemp -t flow19-bundle-XXXXXX).tar.gz" + "$OBOL" kubectl get configmap "$BUNDLE_CM" -n "$OFFER_NS" \ + -o jsonpath='{.binaryData.bundle\.tar\.gz}' 2>/dev/null \ + | python3 -c 'import base64,sys; sys.stdout.buffer.write(base64.b64decode(sys.stdin.read()))' \ + > "$BUNDLE_FILE" || true + bundle_size=$(wc -c < "$BUNDLE_FILE" | tr -d ' ') + CM_SHA=$(sha256_file "$BUNDLE_FILE" 2>/dev/null || true) + if [ "${bundle_size:-0}" -gt 0 ] && [ "${bundle_size:-0}" -le 900000 ] \ + && [ "$CM_SHA" = "$SPEC_SHA" ]; then + pass "bundle $bundle_size bytes, sha256 matches spec (${CM_SHA:0:12}…)" + else + fail "bundle bytes invalid: size=$bundle_size cmSha=$CM_SHA specSha=$SPEC_SHA" + fi + + # §3.3: Controller convergence. Registration is disabled (--no-register), + # so the full ladder ends at Ready=True. Anchored grep — a bare + # "Ready=True" would substring-match "PaymentGateReady=True". + step "ServiceOffer $OFFER_NAME reaches Ready=True (polling, max 60x5s)" + so_ready="" + conds="" + for _ in $(seq 1 60); do + conds=$("$OBOL" kubectl get serviceoffer "$OFFER_NAME" -n "$OFFER_NS" \ + -o jsonpath='{range .status.conditions[*]}{.type}={.status} {end}' 2>/dev/null || true) + if echo "$conds" | grep -qE '(^| )Ready=True'; then + so_ready="yes" + break + fi + sleep 5 + done + if [ -n "$so_ready" ]; then + pass "ServiceOffer Ready (conditions: $conds)" + else + fail "ServiceOffer not Ready within 300s — conditions: ${conds:-unreadable}" + "$OBOL" kubectl get serviceoffer "$OFFER_NAME" -n "$OFFER_NS" \ + -o jsonpath='{range .status.conditions[*]}{.type}: {.reason} — {.message}{"\n"}{end}' 2>/dev/null || true + fi + + # §3.4: Controller-rendered bundle server children exist + step "bundle server so-$OFFER_NAME-bundle rendered" + bundle_deploy=$("$OBOL" kubectl get deploy "so-$OFFER_NAME-bundle" -n "$OFFER_NS" \ + -o jsonpath='{.metadata.name}' 2>/dev/null || true) + if [ "$bundle_deploy" = "so-$OFFER_NAME-bundle" ]; then + pass "Deployment so-$OFFER_NAME-bundle exists" + else + fail "bundle server Deployment missing in $OFFER_NS" + fi + + # §4: Unpaid probe → 402 carries extra.skill with the pinned hash. + # Retry loop per pitfall 14 (first-request race on a fresh verifier route). + refresh_obol_ingress_env + BASE_URL="${OBOL_INGRESS_URL%/}" + if [[ "$BASE_URL" == *"obol.stack"* ]]; then + CURL_BASE="$CURL_OBOL" + else + CURL_BASE="curl" + fi + + step "402 response carries extra.skill {name,version,sha256} (polling, max 12x5s)" + EXTRA_SHA="" + body_402="" + for _ in $(seq 1 12); do + body_402=$($CURL_BASE -s --max-time 10 \ + "$BASE_URL/services/$OFFER_NAME/bundle.tar.gz" 2>&1) || true + EXTRA_SHA=$(echo "$body_402" | python3 -c " +import json, re, sys +d = json.load(sys.stdin) +skill = (d['accepts'][0].get('extra') or {}).get('skill') or {} +assert skill.get('name'), 'skill.name missing' +assert skill.get('version') == '$SKILL_VERSION', 'skill.version mismatch' +assert re.fullmatch(r'[a-f0-9]{64}', skill.get('sha256', '')), 'skill.sha256 malformed' +print(skill['sha256']) +" 2>/dev/null) || EXTRA_SHA="" + [ -n "$EXTRA_SHA" ] && break + sleep 5 + done + if [ -n "$EXTRA_SHA" ]; then + pass "402 extra.skill present (sha256 ${EXTRA_SHA:0:12}…)" + else + fail "402 missing/invalid extra.skill — ${body_402:0:300}" + fi + + step "402-advertised sha256 equals served bundle bytes hash" + if [ -n "$EXTRA_SHA" ] && [ "$EXTRA_SHA" = "$CM_SHA" ]; then + pass "pre-purchase integrity holds: extra.skill.sha256 == sha256(bundle bytes)" + else + fail "hash mismatch: 402=$EXTRA_SHA bundle=$CM_SHA" + fi + + # §4.1: Artifact well-formedness — gzipped tar with top-level SKILL.md. + # A bundle without SKILL.md is not a skill. + step "bundle is a gzipped tar containing top-level SKILL.md" + tar_list=$(tar -tzf "$BUNDLE_FILE" 2>&1 || true) + if echo "$tar_list" | grep -qx "SKILL.md"; then + pass "SKILL.md present ($(echo "$tar_list" | grep -c . ) entries)" + else + fail "top-level SKILL.md missing from bundle — entries: $(echo "$tar_list" | head -5 | tr '\n' ' ')" + fi + + # §5: One-shot paid fetch via buy-x402 buy.py pay (gated — needs a funded + # agent wallet on $FLOW19_CHAIN; spends one auth = $PRICE). + # + # Target /skill.json (text JSON): buy.py pay prints the response body via + # decode(errors="replace") and is not binary-safe, so the gzip artifact is + # byte-verified from the ConfigMap above; the paid request proves the + # 402 → sign → X-PAYMENT → 200 loop on the same gated route. + step "one-shot paid fetch via buy.py pay (FLOW19_PAID_FETCH gate)" + if [ "${FLOW19_PAID_FETCH:-false}" != "true" ]; then + skip "FLOW19_PAID_FETCH != true — paid fetch not attempted" + elif ! "$OBOL" kubectl get deploy hermes -n hermes-obol-agent >/dev/null 2>&1; then + skip "hermes agent deployment not found — paid fetch needs the default agent" + else + pay_out=$("$OBOL" kubectl exec -n hermes-obol-agent deploy/hermes -c hermes -- \ + python3 /data/.hermes/obol-skills/buy-x402/scripts/buy.py pay \ + "http://traefik.traefik.svc.cluster.local/services/$OFFER_NAME/skill.json" \ + --timeout 60 2>&1) || true + if echo "$pay_out" | grep -q "HTTP 200" \ + && echo "$pay_out" | grep -q "$CM_SHA"; then + pass "paid fetch returned 200 with the pinned sha256 in skill.json" + else + fail "paid fetch failed — ${pay_out:0:400}" + fi + fi +fi + +# §6: ERC-8004 calldata derivation (no cluster, no chain, no signing). +# set-hash pins sha256(bundle) under metadata key skill.sha256:@; +# feedback scores the skill with tag1=asr:skill. Both print calldata for the +# OPERATOR to submit with their own wallet — assert non-empty deterministic hex. +if [ "$HAVE_SKILLS_CMD" = "1" ]; then + SKILL_REF="$SKILL_NAME@$SKILL_VERSION" + + # Without a cluster run there is no ConfigMap bundle — pack the embedded + # skill dir from the repo so set-hash has bytes to hash. Determinism of + # the calldata is asserted by running each command twice on the same + # input, not by reproducing the canonical Go packer here. + if [ -z "$BUNDLE_FILE" ] || [ ! -s "$BUNDLE_FILE" ]; then + step "pack local fallback bundle for calldata derivation" + BUNDLE_FILE="$(mktemp -t flow19-bundle-XXXXXX).tar.gz" + if python3 - "$OBOL_ROOT/internal/embed/skills/$SKILL_NAME" "$BUNDLE_FILE" <<'PY' +import gzip, io, os, sys, tarfile + +src, out = sys.argv[1], sys.argv[2] +if not os.path.isfile(os.path.join(src, "SKILL.md")): + raise SystemExit(f"not a skill dir (no SKILL.md): {src}") +paths = [] +for root, dirs, files in os.walk(src): + dirs[:] = sorted(d for d in dirs if d != "__pycache__") + for name in sorted(dirs + files): + p = os.path.join(root, name) + if os.path.islink(p): + raise SystemExit(f"symlink not allowed: {p}") + if name.endswith(".pyc"): + continue + paths.append(p) +paths.sort(key=lambda p: os.path.relpath(p, src).replace(os.sep, "/")) +buf = io.BytesIO() +with tarfile.open(fileobj=buf, mode="w", format=tarfile.USTAR_FORMAT) as tf: + for p in paths: + rel = os.path.relpath(p, src).replace(os.sep, "/") + if os.path.isdir(p): + info = tarfile.TarInfo(rel + "/") + info.type, info.mode = tarfile.DIRTYPE, 0o755 + info.mtime = info.uid = info.gid = 0 + info.uname = info.gname = "" + tf.addfile(info) + else: + with open(p, "rb") as f: + data = f.read() + info = tarfile.TarInfo(rel) + info.size = len(data) + info.mode = 0o755 if os.stat(p).st_mode & 0o111 else 0o644 + info.mtime = info.uid = info.gid = 0 + info.uname = info.gname = "" + tf.addfile(info, io.BytesIO(data)) +# filename="" — a named fileobj would leak the output path into the gzip +# FNAME header and break run-to-run determinism. +with open(out, "wb") as f: + with gzip.GzipFile(filename="", fileobj=f, mode="wb", compresslevel=9, mtime=0) as gz: + gz.write(buf.getvalue()) +PY + then + pass "fallback bundle packed ($(wc -c < "$BUNDLE_FILE" | tr -d ' ') bytes)" + else + fail "could not pack fallback bundle from internal/embed/skills/$SKILL_NAME" + fi + fi + + step "obol skills calldata set-hash prints deterministic calldata" + sethash_1=$("$OBOL" skills calldata set-hash \ + --agent-id "$FEEDBACK_AGENT_ID" \ + --chain "$FLOW19_CHAIN" \ + --skill "$SKILL_REF" \ + --bundle "$BUNDLE_FILE" 2>&1) || true + sethash_2=$("$OBOL" skills calldata set-hash \ + --agent-id "$FEEDBACK_AGENT_ID" \ + --chain "$FLOW19_CHAIN" \ + --skill "$SKILL_REF" \ + --bundle "$BUNDLE_FILE" 2>&1) || true + sethash_hex=$(echo "$sethash_1" | grep -oE 'Calldata: 0x[0-9a-fA-F]+' | head -1 | awk '{print $2}') + if [ -n "$sethash_hex" ] && [ ${#sethash_hex} -gt 10 ] \ + && [ "$sethash_1" = "$sethash_2" ] \ + && echo "$sethash_1" | grep -qE 'IdentityRegistry.*0x[0-9a-fA-F]{40}'; then + pass "set-hash calldata deterministic (${#sethash_hex} hex chars)" + else + fail "set-hash calldata missing or non-deterministic — ${sethash_1:0:300}" + fi + + step "obol skills calldata feedback (tag1=asr:skill) prints deterministic calldata" + fb_1=$("$OBOL" skills calldata feedback \ + --agent-id "$FEEDBACK_AGENT_ID" \ + --skill "$SKILL_REF" \ + --value "$FEEDBACK_VALUE" \ + --chain "$FLOW19_CHAIN" 2>&1) || true + fb_2=$("$OBOL" skills calldata feedback \ + --agent-id "$FEEDBACK_AGENT_ID" \ + --skill "$SKILL_REF" \ + --value "$FEEDBACK_VALUE" \ + --chain "$FLOW19_CHAIN" 2>&1) || true + fb_hex=$(echo "$fb_1" | grep -oE 'Calldata: 0x[0-9a-fA-F]+' | head -1 | awk '{print $2}') + if [ -n "$fb_hex" ] && [ ${#fb_hex} -gt 10 ] \ + && [ "$fb_1" = "$fb_2" ] \ + && echo "$fb_1" | grep -qE 'ReputationRegistry.*0x[0-9a-fA-F]{40}'; then + pass "feedback calldata deterministic (${#fb_hex} hex chars)" + else + fail "feedback calldata missing or non-deterministic — ${fb_1:0:300}" + fi + + step "set-hash and feedback calldata differ (distinct selectors/payloads)" + if [ -n "$sethash_hex" ] && [ -n "$fb_hex" ] && [ "$sethash_hex" != "$fb_hex" ]; then + pass "calldata payloads are distinct" + else + fail "calldata sanity failed: set-hash=$sethash_hex feedback=$fb_hex" + fi +fi + +# §7: Cleanup — opt-in so a successful run leaves the offer for inspection. +if [ "${FLOW_CLEANUP:-0}" = "1" ] && [ "$CLUSTER_OK" = "1" ] && [ "$HAVE_SELL_SKILL" = "1" ]; then + "$OBOL" sell delete "$OFFER_NAME" -n "$OFFER_NS" >/dev/null 2>&1 || true + [ -n "${BUNDLE_CM:-}" ] && "$OBOL" kubectl delete configmap "$BUNDLE_CM" -n "$OFFER_NS" \ + --ignore-not-found >/dev/null 2>&1 || true +fi +[ -n "$BUNDLE_FILE" ] && rm -f "$BUNDLE_FILE" 2>/dev/null || true + +emit_metrics diff --git a/internal/embed/embed_crd_test.go b/internal/embed/embed_crd_test.go index e8b45085..0927ecb7 100644 --- a/internal/embed/embed_crd_test.go +++ b/internal/embed/embed_crd_test.go @@ -241,6 +241,103 @@ func TestServiceOfferCRD_WalletValidation(t *testing.T) { } } +// TestServiceOfferCRD_SkillFields guards the type=skill marketplace +// schema: the enum value, the spec.skill block (bundle identity + +// integrity hash + bundle ConfigMap reference), and the spec-level CEL +// rule that makes spec.skill mandatory for skill offers. +func TestServiceOfferCRD_SkillFields(t *testing.T) { + data, err := ReadInfrastructureFile("base/templates/serviceoffer-crd.yaml") + if err != nil { + t.Fatalf("ReadInfrastructureFile: %v", err) + } + + crd := findDoc(multiDoc(data), "CustomResourceDefinition") + if crd == nil { + t.Fatal("no CRD document found") + } + + versions := nested(crd, "spec", "versions").([]any) + v0 := versions[0].(map[string]any) + spec, ok := nested(v0, "schema", "openAPIV3Schema", "properties", "spec").(map[string]any) + if !ok { + t.Fatal("spec schema missing") + } + props := spec["properties"].(map[string]any) + + // type enum gains "skill". + typeProp := props["type"].(map[string]any) + gotEnum := map[string]bool{} + for _, e := range typeProp["enum"].([]any) { + gotEnum[e.(string)] = true + } + if !gotEnum["skill"] { + t.Errorf("spec.type.enum = %v, want it to include skill", typeProp["enum"]) + } + + // spec.skill block with required identity + integrity fields. + skill, ok := props["skill"].(map[string]any) + if !ok { + t.Fatal("spec.skill property missing") + } + required := map[string]bool{} + for _, r := range skill["required"].([]any) { + required[r.(string)] = true + } + for _, want := range []string{"name", "version", "sha256", "bundleConfigMap"} { + if !required[want] { + t.Errorf("spec.skill.required missing %q (got %v)", want, skill["required"]) + } + } + + skillProps := skill["properties"].(map[string]any) + wantPatterns := map[string]string{ + "name": "^[a-z0-9][a-z0-9-]*$", + "version": "^[A-Za-z0-9][A-Za-z0-9._-]*$", + "sha256": "^[a-f0-9]{64}$", + } + for field, want := range wantPatterns { + fp, ok := skillProps[field].(map[string]any) + if !ok { + t.Errorf("spec.skill.%s property missing", field) + continue + } + if fp["pattern"] != want { + t.Errorf("spec.skill.%s.pattern = %v, want %s", field, fp["pattern"], want) + } + } + + wantMaxLen := map[string]int{ + "name": 64, + "version": 64, + "bundleConfigMap": 253, + "displayName": 128, + "description": 1024, + } + for field, want := range wantMaxLen { + fp, ok := skillProps[field].(map[string]any) + if !ok { + t.Errorf("spec.skill.%s property missing", field) + continue + } + if fp["maxLength"] != want { + t.Errorf("spec.skill.%s.maxLength = %v, want %d", field, fp["maxLength"], want) + } + } + + // Spec-level CEL: spec.skill is required when type=skill. + rules, ok := spec["x-kubernetes-validations"].([]any) + if !ok { + t.Fatal("spec.x-kubernetes-validations missing") + } + joined := "" + for _, r := range rules { + joined += r.(map[string]any)["rule"].(string) + "\n" + } + if !strings.Contains(joined, "self.type != 'skill' || has(self.skill)") { + t.Errorf("spec CEL rules missing skill requirement; got:\n%s", joined) + } +} + func TestRegistrationRequestCRD_Parses(t *testing.T) { data, err := ReadInfrastructureFile("base/templates/registrationrequest-crd.yaml") if err != nil { @@ -1047,6 +1144,94 @@ func assertAgentRBACRulesTight(t *testing.T, roleName string, role map[string]an } } +// TestSkillPublishRBAC_NamespaceScopedConfigMapsOnly pins the shape of the +// skill-bundle publish grant. The agent self-publish path (`obol sell +// skill` from inside the mother agent) needs to write the bundle ConfigMap +// next to its ServiceOffer, but that grant must stay a NAMESPACED Role in +// hermes-obol-agent — a core/configmaps write on the cluster-wide +// openclaw-monetize-write ClusterRole would hand every agent write access +// to every namespace's ConfigMaps (LiteLLM config, x402 pricing, buyer +// auth pools) and is hard-failed by assertAgentRBACRulesTight above. +func TestSkillPublishRBAC_NamespaceScopedConfigMapsOnly(t *testing.T) { + data, err := ReadInfrastructureFile("base/templates/obol-agent-monetize-rbac.yaml") + if err != nil { + t.Fatalf("ReadInfrastructureFile: %v", err) + } + docs := multiDoc(data) + + role := findDocByName(docs, "Role", "hermes-skill-publish") + if role == nil { + t.Fatal("no Role 'hermes-skill-publish' found (must be a namespaced Role, not a ClusterRole)") + } + if findDocByName(docs, "ClusterRole", "hermes-skill-publish") != nil { + t.Fatal("hermes-skill-publish must not exist as a ClusterRole") + } + if ns := nested(role, "metadata", "namespace"); ns != "hermes-obol-agent" { + t.Errorf("Role namespace = %v, want hermes-obol-agent", ns) + } + + rules, ok := role["rules"].([]any) + if !ok || len(rules) != 1 { + t.Fatalf("hermes-skill-publish must carry exactly one rule, got %v", role["rules"]) + } + rule, ok := rules[0].(map[string]any) + if !ok { + t.Fatalf("malformed rule: %T", rules[0]) + } + + groups := stringSet(rule["apiGroups"]) + if len(groups) != 1 || !groups[""] { + t.Errorf("apiGroups = %v, want exactly [\"\"]", groups) + } + resources := stringSet(rule["resources"]) + if len(resources) != 1 || !resources["configmaps"] { + t.Errorf("resources = %v, want exactly [configmaps]", resources) + } + + verbs := stringSet(rule["verbs"]) + for _, want := range []string{"create", "get", "update", "patch"} { + if !verbs[want] { + t.Errorf("verbs missing %q: %v", want, verbs) + } + } + for _, banned := range []string{"list", "watch", "delete", "deletecollection", "*"} { + if verbs[banned] { + t.Errorf("verbs must not include %q: %v", banned, verbs) + } + } + if len(verbs) != 4 { + t.Errorf("verbs = %v, want exactly {create,get,update,patch}", verbs) + } + + // Never any secrets in this Role, under any rule shape. + for _, r := range rules { + rm, _ := r.(map[string]any) + if stringSet(rm["resources"])["secrets"] { + t.Error("hermes-skill-publish must never grant secrets access") + } + } + + binding := findDocByName(docs, "RoleBinding", "hermes-skill-publish-binding") + if binding == nil { + t.Fatal("no RoleBinding 'hermes-skill-publish-binding' found") + } + if ns := nested(binding, "metadata", "namespace"); ns != "hermes-obol-agent" { + t.Errorf("RoleBinding namespace = %v, want hermes-obol-agent", ns) + } + if ref := nested(binding, "roleRef", "kind"); ref != "Role" { + t.Errorf("roleRef.kind = %v, want Role", ref) + } + if ref := nested(binding, "roleRef", "name"); ref != "hermes-skill-publish" { + t.Errorf("roleRef.name = %v, want hermes-skill-publish", ref) + } + if !bindingHasSubject(binding, "hermes", "hermes-obol-agent") { + t.Error("binding missing hermes-obol-agent/hermes subject") + } + if bindingHasSubject(binding, "openclaw", "openclaw-obol-agent") { + t.Error("binding must not include the openclaw subject — the grant is hermes mother ns only") + } +} + func stringSet(v any) map[string]bool { out := make(map[string]bool) @@ -1172,6 +1357,7 @@ func TestAdmissionPolicy_Parses(t *testing.T) { "ForwardAuth middlewares must target x402-verifier.x402.svc", "Agent-created namespaces must be factory-owned agent-* namespaces", "Agent-created Secrets must be hermes-env or hermes-profile-seed inside agent-* namespaces", + "Agent-written ConfigMaps must be *-skill-bundle skill bundles (hermes-config and other operator ConfigMaps are off-limits)", "Agent-created Agent CRs must be Hermes agents in their matching agent-* namespace", } if len(validations) != len(wantMessages) { diff --git a/internal/embed/infrastructure/base/templates/obol-agent-admission-policy.yaml b/internal/embed/infrastructure/base/templates/obol-agent-admission-policy.yaml index 98723b4d..fb5e5697 100644 --- a/internal/embed/infrastructure/base/templates/obol-agent-admission-policy.yaml +++ b/internal/embed/infrastructure/base/templates/obol-agent-admission-policy.yaml @@ -28,7 +28,7 @@ spec: operations: ["CREATE", "UPDATE"] - apiGroups: [""] apiVersions: ["v1"] - resources: ["namespaces", "secrets"] + resources: ["namespaces", "secrets", "configmaps"] operations: ["CREATE", "UPDATE"] - apiGroups: ["obol.org"] apiVersions: ["*"] @@ -46,6 +46,8 @@ spec: message: "Agent-created namespaces must be factory-owned agent-* namespaces" - expression: 'object.kind != "Secret" || (object.metadata.namespace.startsWith("agent-") && (object.metadata.name == "hermes-env" || object.metadata.name == "hermes-profile-seed"))' message: "Agent-created Secrets must be hermes-env or hermes-profile-seed inside agent-* namespaces" + - expression: 'object.kind != "ConfigMap" || object.metadata.name.endsWith("-skill-bundle")' + message: "Agent-written ConfigMaps must be *-skill-bundle skill bundles (hermes-config and other operator ConfigMaps are off-limits)" - expression: 'object.kind != "Agent" || (object.metadata.namespace == "agent-" + object.metadata.name && (!has(object.spec.runtime) || object.spec.runtime == "hermes"))' message: "Agent-created Agent CRs must be Hermes agents in their matching agent-* namespace" diff --git a/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml b/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml index bf2890af..7a5af548 100644 --- a/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml +++ b/internal/embed/infrastructure/base/templates/obol-agent-monetize-rbac.yaml @@ -156,3 +156,29 @@ subjects: - kind: ServiceAccount name: openclaw namespace: openclaw-obol-agent + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: hermes-skill-publish + namespace: hermes-obol-agent +rules: + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["create", "get", "update", "patch"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: hermes-skill-publish-binding + namespace: hermes-obol-agent +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: hermes-skill-publish +subjects: + - kind: ServiceAccount + name: hermes + namespace: hermes-obol-agent diff --git a/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml b/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml index 7b67e13a..8608b7fb 100644 --- a/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml +++ b/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml @@ -273,17 +273,67 @@ spec: type: string type: array type: object + skill: + description: |- + Required when type='skill' (enforced by the spec-level XValidation + rule). Describes the downloadable skill bundle being sold: identity + (name@version), integrity hash, and the ConfigMap carrying the + artifact. The controller renders a static bundle server from this + block and refuses to publish when the ConfigMap bytes do not match + sha256. + properties: + bundleConfigMap: + description: |- + Name of a ConfigMap in the offer's namespace whose + binaryData["bundle.tar.gz"] is the artifact (key: SkillBundleKey). + maxLength: 253 + type: string + description: + description: Short human-readable description for catalog surfaces. + maxLength: 1024 + type: string + displayName: + description: Human-friendly display name for catalog surfaces. + maxLength: 128 + type: string + name: + description: |- + Skill name (e.g. buy-x402). Combined with Version it forms the + skill ref @ used by ERC-8004 feedback tags. + maxLength: 64 + pattern: ^[a-z0-9][a-z0-9-]*$ + type: string + sha256: + description: |- + Lowercase hex sha256 of the gzipped bundle bytes (the exact bytes + stored in the bundle ConfigMap and served to buyers). + pattern: ^[a-f0-9]{64}$ + type: string + version: + description: Skill version (e.g. 0.1.0). + maxLength: 64 + pattern: ^[A-Za-z0-9][A-Za-z0-9._-]*$ + type: string + required: + - bundleConfigMap + - name + - sha256 + - version + type: object type: default: http description: |- Service type. 'inference' enables model management; 'http' for any HTTP service; 'agent' references an Agent CR via spec.agent.ref and the - controller derives upstream + model + skills from the agent's status. + controller derives upstream + model + skills from the agent's status; + 'skill' sells a downloadable skill bundle described by spec.skill and + served from a controller-rendered bundle server. enum: - inference - fine-tuning - http - agent + - skill type: string upstream: description: In-cluster service that handles the actual workload. @@ -313,6 +363,9 @@ spec: required: - payment type: object + x-kubernetes-validations: + - message: spec.skill is required when type=skill + rule: self.type != 'skill' || has(self.skill) status: properties: agentId: diff --git a/internal/embed/skills/monetize-guide/SKILL.md b/internal/embed/skills/monetize-guide/SKILL.md index 14b11d56..fe9d4ef3 100644 --- a/internal/embed/skills/monetize-guide/SKILL.md +++ b/internal/embed/skills/monetize-guide/SKILL.md @@ -116,6 +116,7 @@ python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/discovery/scripts/discovery.p | LLM inference (large, >14B) | 0.005–0.02 USDC/req | High quality, slower | | Data API / indexer | 0.0001–0.001 USDC/req | Depends on query complexity | | Compute-heavy (GPU hours) | 0.10–1.00 USDC/hour | Fine-tuning, training | +| Skill bundle (one-shot download) | 0.05–5 USDC/download | Priced per download, not per use | **Always present your research and recommendation to the user and ask them to confirm the price before proceeding.** @@ -166,6 +167,43 @@ The `--endpoint` must include `/v1` if the upstream is an OpenAI-compatible serv LAN IPs (e.g., `http://192.168.0.202:8000/v1`) are reachable from inside the k3d cluster without any additional network configuration. +#### Skill Bundle (paid download of one of your skills) + +A skill directory (`SKILL.md` + scripts) can be sold as a single hash-pinned +gzipped bundle behind x402 (`type=skill` ServiceOffer): + +```bash +# --from-embedded , or --from for a custom skill +obol sell skill \ + --from-embedded \ + --skill-version 0.1.0 \ + --per-request \ + --chain base-sepolia \ + --pay-to +``` + +The CLI packs the bundle deterministically (compressed cap 900000 bytes), +pins its sha256 in the offer, and the controller serves it from a tiny +bundle server at `/services//bundle.tar.gz` (+ `/skill.json` +metadata). Buyers verify the download against the sha256 advertised in the +402 response's `extra.skill` block before AND after paying. + +When you (the agent) need to publish a skill yourself without the host CLI, +use raw K8s objects — see the `sell` skill's "Selling a Skill Bundle +(type=skill)" section. Your ConfigMap write RBAC is limited to your own +namespace, so both the bundle ConfigMap and the ServiceOffer must be +created there. + +On-chain hash pinning and ratings (`obol skills calldata set-hash` / +`obol skills calldata feedback`, tag1=`asr:skill`) only PRINT calldata — +the OPERATOR submits it with their own wallet. Never sign or submit these +transactions yourself; present the printed command and calldata to the user. + +To instead sell the skill as a live, invocable service, sell the agent that +carries it: `obol agent new --skills ` then `obol sell +agent `. `obol sell skill` sells the bundle bytes; `obol sell +agent` sells execution. + #### HTTP Service (in-cluster) ```bash diff --git a/internal/embed/skills/sell/SKILL.md b/internal/embed/skills/sell/SKILL.md index 07e85b6d..1c7f4f57 100644 --- a/internal/embed/skills/sell/SKILL.md +++ b/internal/embed/skills/sell/SKILL.md @@ -1,6 +1,6 @@ --- name: sell -description: "Sell access to services via x402 payment gating. Create ServiceOffer CRDs that automatically health-check upstreams, create payment-gated routes, and optionally pull models and register on ERC-8004. Supports inference, HTTP, and fine-tuning service types." +description: "Sell access to services via x402 payment gating. Create ServiceOffer CRDs that automatically health-check upstreams, create payment-gated routes, and optionally pull models and register on ERC-8004. Supports inference, HTTP, fine-tuning, agent, and skill (paid skill-bundle download) service types." metadata: { "openclaw": { "emoji": "\ud83d\udcb0", "requires": { "bins": ["python3"] } } } --- @@ -12,6 +12,7 @@ Sell access to services via ServiceOffer custom resources. Each ServiceOffer des - Exposing a local Ollama model for paid inference - Creating payment-gated routes for any upstream service +- Selling one of your own skills as a paid, hash-pinned bundle download (`type=skill` — see "Selling a Skill Bundle" below) - Checking the status of monetized services - Listing or deleting existing service offers - Processing pending offers that haven't been fully reconciled @@ -63,6 +64,264 @@ python3 scripts/monetize.py delete my-inference --namespace llm | `process --all` | Wait for all non-Ready offers to converge | | `delete --namespace ` | Delete an offer and its owned resources | +## Selling a Skill Bundle (type=skill) + +A skill — a directory with a top-level `SKILL.md` plus optional `scripts/` +and `references/` — can itself be sold as a single downloadable, ratable +unit. A `type=skill` ServiceOffer points at a ConfigMap holding the gzipped +bundle; the `serviceoffer-controller` hash-verifies the bytes, renders a +static bundle server (`so--bundle`, busybox httpd on port 8080) in the +offer's namespace, and gates `/services//*` behind x402 like any +other offer. Buyers pay the flat `perRequest` price per download. + +Two ways to publish: + +1. **Host CLI (operator runs it)**: `obol sell skill --from ` + or `--from-embedded ` packs canonically, writes the ConfigMap + (server-side apply), and creates the offer in one shot. Prefer this when + a human is driving. +2. **Raw K8s objects (you, the agent)**: create the bundle ConfigMap and the + ServiceOffer yourself with the RBAC you already have. Documented below. + +### Where your objects must live (RBAC) + +Your ServiceAccount can CRUD `serviceoffers` cluster-wide, but ConfigMap +writes are granted ONLY in your own namespace (`hermes-obol-agent`) through +the namespaced `hermes-skill-publish` Role (verbs: create/get/update/patch — +no list, watch, or delete). The controller reads the bundle ConfigMap from +the **offer's** namespace. Consequence: create BOTH the ConfigMap AND the +ServiceOffer in your own namespace, side by side. + +### Packaging contract (deterministic) + +The artifact is a gzipped tar of the skill directory. The canonical packer +(`obol sell skill` / `internal/skillpkg.Pack`) normalizes: + +- A top-level `SKILL.md` is REQUIRED — a bundle without it is not a skill. +- `__pycache__/` dirs and `*.pyc` files are skipped; symlinks are rejected. +- Entries are sorted by slash-separated path; tar format is USTAR. +- File mode normalized to `0644` (`0755` when any exec bit is set on the + source); directories `0755`; mtime epoch 0; uid/gid 0; empty uname/gname. +- gzip at max compression with an empty name, mtime 0, OS byte 255. +- **Cap**: the compressed bundle must be <= 900000 bytes (`MaxSkillBundleBytes`, + enforced by the CLI and again by the controller). Trim the skill if over. +- `spec.skill.sha256` is the lowercase hex SHA-256 of the **gzipped bytes** — + it MUST equal the hash of the exact bytes stored in + `binaryData["bundle.tar.gz"]`, or the controller refuses to publish + (`BundleHashMismatch`). + +Determinism caveat: the same source tree packed by the canonical Go packer +always yields the same hash (audit-friendly, keeps an on-chain pin stable +across republish). DEFLATE output is implementation-specific, so a Python +repack of identical files produces a different — still valid — hash. The +binding contract is only ever `sha256(uploaded bytes) == spec.skill.sha256`; +whatever bytes you upload, hash those. + +### Pack + publish from inside the pod + +One self-contained script: pack (mirroring the canonical normalization), +upload the ConfigMap, create the offer. Adjust `SRC`, names, price, and +`payTo` (your wallet from `signer.py accounts`). + +```python +import base64, gzip, hashlib, io, json, os, sys, tarfile + +SKILLS = os.environ.get("OBOL_SKILLS_DIR", "/data/.hermes/obol-skills") +sys.path.insert(0, os.path.join(SKILLS, "obol-stack", "scripts")) +from kube import load_sa, make_ssl_context, api_get, api_post, api_patch + +SRC = os.path.join(SKILLS, "my-skill") # must contain SKILL.md at top level +NS = "hermes-obol-agent" # YOUR namespace — see RBAC note +OFFER, VERSION = "my-skill", "0.1.0" +CM = f"{OFFER}-skill-bundle" +PAY_TO = "0xYourWalletAddress" + +# -- canonical pack ---------------------------------------------------- +if not os.path.isfile(os.path.join(SRC, "SKILL.md")): + raise SystemExit("not a skill: top-level SKILL.md missing") +paths = [] +for root, dirs, files in os.walk(SRC): + dirs[:] = sorted(d for d in dirs if d != "__pycache__") + for name in sorted(dirs + files): + p = os.path.join(root, name) + if os.path.islink(p): + raise SystemExit(f"symlink not allowed: {p}") + if not name.endswith(".pyc"): + paths.append(p) +paths.sort(key=lambda p: os.path.relpath(p, SRC).replace(os.sep, "/")) +tar_buf = io.BytesIO() +with tarfile.open(fileobj=tar_buf, mode="w", format=tarfile.USTAR_FORMAT) as tf: + for p in paths: + rel = os.path.relpath(p, SRC).replace(os.sep, "/") + info = tarfile.TarInfo(rel + "/" if os.path.isdir(p) else rel) + info.mtime = info.uid = info.gid = 0 + info.uname = info.gname = "" + if os.path.isdir(p): + info.type, info.mode = tarfile.DIRTYPE, 0o755 + tf.addfile(info) + else: + data = open(p, "rb").read() + info.size = len(data) + info.mode = 0o755 if os.stat(p).st_mode & 0o111 else 0o644 + tf.addfile(info, io.BytesIO(data)) +gz_buf = io.BytesIO() +# filename="" keeps the gzip FNAME header empty (determinism rule). +with gzip.GzipFile(filename="", fileobj=gz_buf, mode="wb", compresslevel=9, mtime=0) as gz: + gz.write(tar_buf.getvalue()) +bundle = gz_buf.getvalue() +if len(bundle) > 900000: + raise SystemExit(f"bundle {len(bundle)} bytes > 900000-byte cap — trim the skill") +sha = hashlib.sha256(bundle).hexdigest() +print(f"bundle: {len(bundle)} bytes sha256={sha}") + +# -- bundle ConfigMap (create, or merge-patch when it exists) ---------- +token, _ = load_sa() +ctx = make_ssl_context() +cm = {"apiVersion": "v1", "kind": "ConfigMap", + "metadata": {"name": CM, "namespace": NS}, + "binaryData": {"bundle.tar.gz": base64.b64encode(bundle).decode()}} +try: + api_get(f"/api/v1/namespaces/{NS}/configmaps/{CM}", token, ctx, quiet=True) + api_patch(f"/api/v1/namespaces/{NS}/configmaps/{CM}", cm, token, ctx) +except SystemExit: + api_post(f"/api/v1/namespaces/{NS}/configmaps", cm, token, ctx) + +# -- the ServiceOffer --------------------------------------------------- +offer = { + "apiVersion": "obol.org/v1alpha1", "kind": "ServiceOffer", + "metadata": {"name": OFFER, "namespace": NS}, + "spec": { + "type": "skill", + "skill": {"name": OFFER, "version": VERSION, "sha256": sha, + "bundleConfigMap": CM, + "displayName": "My Skill", + "description": "What the skill does, one line."}, + # Anti-spoof invariants — the controller rejects anything else: + "upstream": {"service": f"so-{OFFER}-bundle", # MUST be so--bundle + "namespace": NS, # MUST equal offer namespace + "port": 8080, # MUST be 8080 + "healthPath": "/skill.json"}, + "payment": {"scheme": "exact", "network": "base-sepolia", + "payTo": PAY_TO, "maxTimeoutSeconds": 300, + "price": {"perRequest": "0.25"}}, + "registration": {"enabled": False}, + }, +} +api_post(f"/apis/obol.org/v1alpha1/namespaces/{NS}/serviceoffers", offer, token, ctx) +print(f"ServiceOffer {NS}/{OFFER} created") +``` + +Equivalent standalone YAML (for an operator with kubectl): + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-skill-skill-bundle + namespace: hermes-obol-agent +binaryData: + bundle.tar.gz: +--- +apiVersion: obol.org/v1alpha1 +kind: ServiceOffer +metadata: + name: my-skill + namespace: hermes-obol-agent +spec: + type: skill + skill: + name: my-skill # ^[a-z0-9][a-z0-9-]*$, max 64 + version: "0.1.0" # ^[A-Za-z0-9][A-Za-z0-9._-]*$, max 64 + sha256: "<64-char lowercase hex of the gzipped bundle bytes>" + bundleConfigMap: my-skill-skill-bundle + displayName: "My Skill" + description: "What the skill does, one line." + upstream: + service: so-my-skill-bundle # MUST be so--bundle + namespace: hermes-obol-agent # MUST equal the offer namespace + port: 8080 # MUST be 8080 + healthPath: /skill.json + payment: + scheme: exact + network: base-sepolia + payTo: "0xYourWalletAddress" + maxTimeoutSeconds: 300 + price: + perRequest: "0.25" + registration: + enabled: false +``` + +Note: apply the ConfigMap **server-side** (`kubectl apply --server-side`). +Client-side apply writes the whole object into the last-applied-configuration +annotation, which blows the 256KiB annotation cap for bundles over ~190KB. + +### Watch reconciliation + +```bash +python3 scripts/monetize.py status my-skill --namespace hermes-obol-agent +``` + +The usual ladder applies (ModelReady is `True/Skipped` for skills). Before +`UpstreamHealthy` can pass, the controller verifies the bundle; skill-specific +`UpstreamHealthy=False` reasons: + +| Reason | Meaning / fix | +|--------|---------------| +| `InvalidSkillUpstream` | `spec.upstream` is not the controller-rendered bundle server (`so--bundle` / offer namespace / port 8080). Fix the spec — a skill offer may only ever advertise its own bundle server. | +| `BundleMissing` | `spec.skill.bundleConfigMap` not found in the offer's namespace. Create it (controller requeues automatically). | +| `BundleTooLarge` | Compressed bytes exceed 900000. Trim the skill and republish. | +| `BundleHashMismatch` | `sha256(binaryData["bundle.tar.gz"]) != spec.skill.sha256`. Re-hash the exact uploaded bytes. | + +Republishing new bundle bytes + updating `spec.skill.sha256` rolls the bundle +server pod automatically (content-hash annotation). + +### What buyers see (pre-purchase integrity) + +An unpaid `GET /services//bundle.tar.gz` returns 402 with the skill +identity in `accepts[0].extra.skill`: + +```json +{"name": "my-skill", "version": "0.1.0", "sha256": "<64-hex>"} +``` + +Point buyers at that `extra.skill.sha256` BEFORE they pay: it is the same +hash the controller verified against the served bytes, so after a paid +download they verify with `sha256sum bundle.tar.gz`. Paid paths on the route: +`/services//bundle.tar.gz` (the artifact) and +`/services//skill.json` (metadata JSON: name, version, sha256, +displayName, description, offer, namespace). Each request costs one +`perRequest` payment. + +### Alternative: sell the skill as a live service instead + +If buyers should *invoke* the skill rather than download it, sell the agent +that carries it through the normal agent path — no skill-specific flag: + +```bash +obol agent new --skills # if not already created +obol sell agent --price 0.001 --chain base-sepolia +``` + +The 402 surfaces `extra.agentSkills` via the normal agent machinery. `obol +sell skill` sells the bundle bytes; `obol sell agent` sells execution. + +### On-chain integrity + rating (OPERATOR-submitted — never you) + +Skill hash pinning and ratings ride ERC-8004 with the tag convention +`tag1="asr:skill"`, `tag2="eip155::::@"`. +The obol CLI only PRINTS calldata; a human operator submits it with their +own wallet. The controller never signs, and **you must never sign or submit +these transactions either** — surface the commands to the user instead: + +```bash +# Pin sha256(bundle) under metadata key skill.sha256:@ +obol skills calldata set-hash --agent-id --skill my-skill@0.1.0 --bundle --chain base-sepolia + +# Rate a skill 0-100 (buyer side; self-feedback from the owner reverts on-chain) +obol skills calldata feedback --agent-id --skill my-skill@0.1.0 --value 95 --chain base-sepolia +``` + ## Reconciliation Flow The `serviceoffer-controller` drives these stages: diff --git a/internal/embed/skills/sell/references/serviceoffer-spec.md b/internal/embed/skills/sell/references/serviceoffer-spec.md index 5ead12d7..d15ae893 100644 --- a/internal/embed/skills/sell/references/serviceoffer-spec.md +++ b/internal/embed/skills/sell/references/serviceoffer-spec.md @@ -62,7 +62,8 @@ spec: | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `spec.type` | string | No | `http` | Workload type: `inference`, `fine-tuning`, or `http` | +| `spec.type` | string | No | `http` | Workload type: `inference`, `fine-tuning`, `http`, `agent`, or `skill` | +| `spec.skill` | object | Required when `type=skill` | — | Skill bundle identity, integrity hash, and artifact ConfigMap (CEL-validated at admission) | | `spec.model` | object | No | — | Model metadata for LLM-backed offers | | `spec.upstream` | object | Yes | — | In-cluster Service that handles the workload | | `spec.payment` | object | Yes | — | x402-aligned payment terms | @@ -70,6 +71,66 @@ spec: | `spec.provenance` | object | No | — | Optional experiment or training provenance metadata | | `spec.registration` | object | No | — | ERC-8004 publication metadata | +### `spec.skill` + +Populated when `spec.type == "skill"` — sells a downloadable skill bundle +(gzipped tar of a `SKILL.md` + scripts directory). The controller verifies +that the ConfigMap bytes hash to `sha256` before rendering the bundle server +(`so--bundle`: busybox httpd, port 8080, serving `/bundle.tar.gz` and +`/skill.json`), and the x402-verifier surfaces name/version/sha256 in the 402 +response's `extra.skill` block for pre-purchase verification. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `spec.skill.name` | string | Yes | Skill name, `^[a-z0-9][a-z0-9-]*$`, max 64. With `version` it forms the skill ref `@` used by ERC-8004 skill tags | +| `spec.skill.version` | string | Yes | Skill version, `^[A-Za-z0-9][A-Za-z0-9._-]*$`, max 64 | +| `spec.skill.sha256` | string | Yes | Lowercase hex SHA-256 of the gzipped bundle bytes, `^[a-f0-9]{64}$` | +| `spec.skill.bundleConfigMap` | string | Yes | Name of a ConfigMap in the **offer's namespace** whose `binaryData["bundle.tar.gz"]` is the artifact (compressed size <= 900000 bytes) | +| `spec.skill.displayName` | string | No | Human-friendly display name, max 128 | +| `spec.skill.description` | string | No | Short description for catalog surfaces, max 1024 | + +Constraints enforced by the controller for `type=skill`: + +- `spec.upstream` MUST be `{service: so--bundle, namespace: + , port: 8080}` — anything else is rejected with + `UpstreamHealthy=False reason=InvalidSkillUpstream` (a skill offer may only + advertise its own controller-rendered bundle server). +- Bundle gate reasons on `UpstreamHealthy=False`: `BundleMissing`, + `BundleTooLarge` (compressed bytes > 900000), `BundleHashMismatch`. +- A spec-level CEL rule rejects `type=skill` offers without `spec.skill` at + admission time. + +Skill example: + +```yaml +apiVersion: obol.org/v1alpha1 +kind: ServiceOffer +metadata: + name: my-skill + namespace: hermes-obol-agent +spec: + type: skill + skill: + name: my-skill + version: "0.1.0" + sha256: "<64-char lowercase hex of the gzipped bundle bytes>" + bundleConfigMap: my-skill-skill-bundle + displayName: "My Skill" + description: "What the skill does." + upstream: + service: so-my-skill-bundle + namespace: hermes-obol-agent + port: 8080 + healthPath: /skill.json + payment: + scheme: exact + network: base-sepolia + payTo: "0xYourWalletAddress" + maxTimeoutSeconds: 300 + price: + perRequest: "0.25" +``` + ### `spec.model` | Field | Type | Required | Description | diff --git a/internal/erc8004/calldata.go b/internal/erc8004/calldata.go new file mode 100644 index 00000000..f2070bb0 --- /dev/null +++ b/internal/erc8004/calldata.go @@ -0,0 +1,99 @@ +// Identity Registry calldata builders (calldata-printer pattern). +// +// The transact path for setMetadata exists on Client +// (SetMetadataWithOpts), but agent operators frequently hold the +// registration key in a wallet the CLI never sees. These encoders build +// the raw to+data pair so the CLI can print it and the OPERATOR submits +// with their own wallet — the controller NEVER signs. + +package erc8004 + +import ( + "fmt" + "math/big" + "strings" + "sync" + + "github.com/ethereum/go-ethereum/accounts/abi" +) + +var ( + identityABIOnce sync.Once + identityABIParsed abi.ABI + identityABIErr error +) + +// identityABI lazily parses the embedded Identity Registry ABI once. +// Client.newClient keeps its own parse (it predates this helper and +// owns a bound contract); encoders share this copy. +func identityABI() (abi.ABI, error) { + identityABIOnce.Do(func() { + identityABIParsed, identityABIErr = abi.JSON(strings.NewReader(identityRegistryABI)) + }) + if identityABIErr != nil { + return abi.ABI{}, fmt.Errorf("erc8004: parse identity registry abi: %w", identityABIErr) + } + return identityABIParsed, nil +} + +// EncodeSetMetadata builds calldata for +// setMetadata(uint256 agentId, string metadataKey, bytes metadataValue) +// on the ERC-8004 Identity Registry. Must be submitted by the agent +// owner's wallet. Note the registry's reference implementation reverts +// when the new value equals the stored value (see SetMetadataWithOpts), +// so re-submitting an unchanged hash fails on-chain as a no-op guard. +func EncodeSetMetadata(agentID *big.Int, key string, value []byte) ([]byte, error) { + if err := checkAgentID(agentID); err != nil { + return nil, err + } + if strings.TrimSpace(key) == "" { + return nil, fmt.Errorf("erc8004: metadata key must not be empty") + } + + parsed, err := identityABI() + if err != nil { + return nil, err + } + data, err := parsed.Pack("setMetadata", agentID, key, value) + if err != nil { + return nil, fmt.Errorf("erc8004: pack setMetadata: %w", err) + } + return data, nil +} + +// SetMetadataCall is the decoded argument set of a setMetadata call. +type SetMetadataCall struct { + AgentID *big.Int + Key string + Value []byte +} + +// DecodeSetMetadataCalldata decodes setMetadata calldata (selector + +// ABI-encoded args). Useful for provenance checks on observed +// transactions and for tests. +func DecodeSetMetadataCalldata(data []byte) (SetMetadataCall, error) { + parsed, err := identityABI() + if err != nil { + return SetMetadataCall{}, err + } + values, err := unpackCalldata(parsed, "setMetadata", data) + if err != nil { + return SetMetadataCall{}, err + } + if len(values) != 3 { + return SetMetadataCall{}, fmt.Errorf("erc8004: setMetadata arg count = %d, want 3", len(values)) + } + + out := SetMetadataCall{} + var ok bool + if out.AgentID, ok = values[0].(*big.Int); !ok { + return SetMetadataCall{}, fmt.Errorf("erc8004: agentId type = %T", values[0]) + } + if out.Key, ok = values[1].(string); !ok { + return SetMetadataCall{}, fmt.Errorf("erc8004: metadataKey type = %T", values[1]) + } + if out.Value, ok = values[2].([]byte); !ok { + return SetMetadataCall{}, fmt.Errorf("erc8004: metadataValue type = %T", values[2]) + } + return out, nil +} diff --git a/internal/erc8004/reputation.go b/internal/erc8004/reputation.go new file mode 100644 index 00000000..a9765289 --- /dev/null +++ b/internal/erc8004/reputation.go @@ -0,0 +1,437 @@ +package erc8004 + +// ERC-8004 Reputation Registry (v2.0.0) calldata builders and read helpers. +// +// IMPORTANT — signing model: the serviceoffer/servicebounty controller NEVER +// signs feedback transactions. Client agents submit giveFeedback (and +// revokeFeedback) with THEIR OWN wallets; agent operators submit +// appendResponse with theirs. This package only builds calldata and reads +// recorded feedback. +// +// Function signatures verified against: +// - Spec: https://eips.ethereum.org/EIPS/eip-8004 (Reputation Registry) +// - Reference impl + official ABI: +// https://github.com/erc-8004/erc-8004-contracts +// (abis/ReputationRegistry.json, contracts/ReputationRegistryUpgradeable.sol, +// getVersion() == "2.0.0") +// +// giveFeedback(uint256 agentId, int128 value, uint8 valueDecimals, string tag1, string tag2, string endpoint, string feedbackURI, bytes32 feedbackHash) +// revokeFeedback(uint256 agentId, uint64 feedbackIndex) +// appendResponse(uint256 agentId, address clientAddress, uint64 feedbackIndex, string responseURI, bytes32 responseHash) +// getSummary(uint256 agentId, address[] clientAddresses, string tag1, string tag2) -> (uint64 count, int128 summaryValue, uint8 summaryValueDecimals) +// readFeedback(uint256 agentId, address clientAddress, uint64 feedbackIndex) -> (int128, uint8, string, string, bool) +// getLastIndex(uint256 agentId, address clientAddress) -> uint64 +// getClients(uint256 agentId) -> address[] + +import ( + "context" + _ "embed" + "fmt" + "math/big" + "strings" + "sync" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" +) + +//go:embed reputation_registry.abi.json +var reputationRegistryABI string + +// ReputationRegistryMainnet is the ERC-8004 v2.0.0 Reputation Registry on +// Ethereum mainnet and Base mainnet (deployed at the same address via +// CREATE2). The Base Sepolia deployment is the existing +// ReputationRegistryBaseSepolia constant in abi.go. +// Source: https://github.com/erc-8004/erc-8004-contracts README + +// scripts/addresses.ts; on-chain: code present on both chains, +// getVersion() == "2.0.0". +const ReputationRegistryMainnet = "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63" + +// MaxFeedbackValueDecimals is the maximum valueDecimals accepted by +// giveFeedback. The contract reverts with "too many decimals" above this. +const MaxFeedbackValueDecimals = 18 + +// maxFeedbackAbsValue mirrors the contract's MAX_ABS_VALUE = 1e38 bound on +// the int128 feedback value. +var maxFeedbackAbsValue = new(big.Int).Exp(big.NewInt(10), big.NewInt(38), nil) + +var ( + reputationABIOnce sync.Once + reputationABIParsed abi.ABI + reputationABIErr error +) + +// reputationABI lazily parses the embedded Reputation Registry ABI once. +func reputationABI() (abi.ABI, error) { + reputationABIOnce.Do(func() { + reputationABIParsed, reputationABIErr = abi.JSON(strings.NewReader(reputationRegistryABI)) + }) + if reputationABIErr != nil { + return abi.ABI{}, fmt.Errorf("erc8004: parse reputation registry abi: %w", reputationABIErr) + } + return reputationABIParsed, nil +} + +// ReputationRegistryAddress maps a supported network name to the deployed +// ERC-8004 v2.0.0 Reputation Registry address. It accepts the same aliases +// as ResolveNetwork. Networks without an on-chain-verified deployment return +// an error rather than a guessed address. +func ReputationRegistryAddress(network string) (string, error) { + net, err := ResolveNetwork(network) + if err != nil { + return "", fmt.Errorf("erc8004: reputation registry: %w", err) + } + switch net.Name { + case BaseSepolia.Name: + return ReputationRegistryBaseSepolia, nil + case Base.Name, Ethereum.Name: + return ReputationRegistryMainnet, nil + default: + return "", fmt.Errorf("erc8004: no verified reputation registry deployment for network %q", net.Name) + } +} + +// EncodeGiveFeedback builds calldata for +// giveFeedback(uint256,int128,uint8,string,string,string,string,bytes32). +// value is a fixed-point score scaled by 10^valueDecimals (|value| <= 1e38, +// valueDecimals <= 18). The transaction must be submitted by the client +// agent's own wallet — the contract forbids self-feedback from the agent's +// owner/operators, and the controller never signs. tag1, tag2, endpoint, +// feedbackURI, and feedbackHash are optional per spec and may be zero values. +func EncodeGiveFeedback(agentID *big.Int, value *big.Int, valueDecimals uint8, tag1, tag2, endpoint, feedbackURI string, feedbackHash common.Hash) ([]byte, error) { + if err := checkAgentID(agentID); err != nil { + return nil, err + } + if value == nil { + return nil, fmt.Errorf("erc8004: feedback value must not be nil") + } + if value.CmpAbs(maxFeedbackAbsValue) > 0 { + return nil, fmt.Errorf("erc8004: feedback value %s out of range [-1e38, 1e38]", value) + } + if valueDecimals > MaxFeedbackValueDecimals { + return nil, fmt.Errorf("erc8004: valueDecimals %d out of range [0,%d]", valueDecimals, MaxFeedbackValueDecimals) + } + + parsed, err := reputationABI() + if err != nil { + return nil, err + } + data, err := parsed.Pack("giveFeedback", agentID, value, valueDecimals, tag1, tag2, endpoint, feedbackURI, feedbackHash) + if err != nil { + return nil, fmt.Errorf("erc8004: pack giveFeedback: %w", err) + } + return data, nil +} + +// EncodeRevokeFeedback builds calldata for revokeFeedback(uint256,uint64). +// Must be submitted by the wallet that gave the feedback. Feedback indices +// are 1-based. +func EncodeRevokeFeedback(agentID *big.Int, feedbackIndex uint64) ([]byte, error) { + if err := checkAgentID(agentID); err != nil { + return nil, err + } + if feedbackIndex == 0 { + return nil, fmt.Errorf("erc8004: feedbackIndex must be > 0 (indices are 1-based)") + } + + parsed, err := reputationABI() + if err != nil { + return nil, err + } + data, err := parsed.Pack("revokeFeedback", agentID, feedbackIndex) + if err != nil { + return nil, fmt.Errorf("erc8004: pack revokeFeedback: %w", err) + } + return data, nil +} + +// EncodeAppendResponse builds calldata for +// appendResponse(uint256,address,uint64,string,bytes32) — an on-chain reply +// to existing feedback. Submitted by the responder's own wallet. +func EncodeAppendResponse(agentID *big.Int, clientAddress common.Address, feedbackIndex uint64, responseURI string, responseHash common.Hash) ([]byte, error) { + if err := checkAgentID(agentID); err != nil { + return nil, err + } + if clientAddress == (common.Address{}) { + return nil, fmt.Errorf("erc8004: clientAddress must not be the zero address") + } + if feedbackIndex == 0 { + return nil, fmt.Errorf("erc8004: feedbackIndex must be > 0 (indices are 1-based)") + } + if responseURI == "" { + return nil, fmt.Errorf("erc8004: responseURI must not be empty") + } + + parsed, err := reputationABI() + if err != nil { + return nil, err + } + data, err := parsed.Pack("appendResponse", agentID, clientAddress, feedbackIndex, responseURI, responseHash) + if err != nil { + return nil, fmt.Errorf("erc8004: pack appendResponse: %w", err) + } + return data, nil +} + +// GiveFeedbackCall is the decoded argument set of a giveFeedback call. +type GiveFeedbackCall struct { + AgentID *big.Int + Value *big.Int + ValueDecimals uint8 + Tag1 string + Tag2 string + Endpoint string + FeedbackURI string + FeedbackHash common.Hash +} + +// DecodeGiveFeedbackCalldata decodes giveFeedback calldata (selector + +// ABI-encoded args). Useful for provenance checks on observed transactions +// and for tests. +func DecodeGiveFeedbackCalldata(data []byte) (GiveFeedbackCall, error) { + parsed, err := reputationABI() + if err != nil { + return GiveFeedbackCall{}, err + } + values, err := unpackCalldata(parsed, "giveFeedback", data) + if err != nil { + return GiveFeedbackCall{}, err + } + if len(values) != 8 { + return GiveFeedbackCall{}, fmt.Errorf("erc8004: giveFeedback arg count = %d, want 8", len(values)) + } + + out := GiveFeedbackCall{} + var ok bool + if out.AgentID, ok = values[0].(*big.Int); !ok { + return GiveFeedbackCall{}, fmt.Errorf("erc8004: agentId type = %T", values[0]) + } + if out.Value, ok = values[1].(*big.Int); !ok { + return GiveFeedbackCall{}, fmt.Errorf("erc8004: value type = %T", values[1]) + } + if out.ValueDecimals, ok = values[2].(uint8); !ok { + return GiveFeedbackCall{}, fmt.Errorf("erc8004: valueDecimals type = %T", values[2]) + } + if out.Tag1, ok = values[3].(string); !ok { + return GiveFeedbackCall{}, fmt.Errorf("erc8004: tag1 type = %T", values[3]) + } + if out.Tag2, ok = values[4].(string); !ok { + return GiveFeedbackCall{}, fmt.Errorf("erc8004: tag2 type = %T", values[4]) + } + if out.Endpoint, ok = values[5].(string); !ok { + return GiveFeedbackCall{}, fmt.Errorf("erc8004: endpoint type = %T", values[5]) + } + if out.FeedbackURI, ok = values[6].(string); !ok { + return GiveFeedbackCall{}, fmt.Errorf("erc8004: feedbackURI type = %T", values[6]) + } + hash, ok := values[7].([32]byte) + if !ok { + return GiveFeedbackCall{}, fmt.Errorf("erc8004: feedbackHash type = %T", values[7]) + } + out.FeedbackHash = common.Hash(hash) + return out, nil +} + +// RevokeFeedbackCall is the decoded argument set of a revokeFeedback call. +type RevokeFeedbackCall struct { + AgentID *big.Int + FeedbackIndex uint64 +} + +// DecodeRevokeFeedbackCalldata decodes revokeFeedback calldata. +func DecodeRevokeFeedbackCalldata(data []byte) (RevokeFeedbackCall, error) { + parsed, err := reputationABI() + if err != nil { + return RevokeFeedbackCall{}, err + } + values, err := unpackCalldata(parsed, "revokeFeedback", data) + if err != nil { + return RevokeFeedbackCall{}, err + } + if len(values) != 2 { + return RevokeFeedbackCall{}, fmt.Errorf("erc8004: revokeFeedback arg count = %d, want 2", len(values)) + } + + out := RevokeFeedbackCall{} + var ok bool + if out.AgentID, ok = values[0].(*big.Int); !ok { + return RevokeFeedbackCall{}, fmt.Errorf("erc8004: agentId type = %T", values[0]) + } + if out.FeedbackIndex, ok = values[1].(uint64); !ok { + return RevokeFeedbackCall{}, fmt.Errorf("erc8004: feedbackIndex type = %T", values[1]) + } + return out, nil +} + +// AppendResponseCall is the decoded argument set of an appendResponse call. +type AppendResponseCall struct { + AgentID *big.Int + ClientAddress common.Address + FeedbackIndex uint64 + ResponseURI string + ResponseHash common.Hash +} + +// DecodeAppendResponseCalldata decodes appendResponse calldata. +func DecodeAppendResponseCalldata(data []byte) (AppendResponseCall, error) { + parsed, err := reputationABI() + if err != nil { + return AppendResponseCall{}, err + } + values, err := unpackCalldata(parsed, "appendResponse", data) + if err != nil { + return AppendResponseCall{}, err + } + if len(values) != 5 { + return AppendResponseCall{}, fmt.Errorf("erc8004: appendResponse arg count = %d, want 5", len(values)) + } + + out := AppendResponseCall{} + var ok bool + if out.AgentID, ok = values[0].(*big.Int); !ok { + return AppendResponseCall{}, fmt.Errorf("erc8004: agentId type = %T", values[0]) + } + if out.ClientAddress, ok = values[1].(common.Address); !ok { + return AppendResponseCall{}, fmt.Errorf("erc8004: clientAddress type = %T", values[1]) + } + if out.FeedbackIndex, ok = values[2].(uint64); !ok { + return AppendResponseCall{}, fmt.Errorf("erc8004: feedbackIndex type = %T", values[2]) + } + if out.ResponseURI, ok = values[3].(string); !ok { + return AppendResponseCall{}, fmt.Errorf("erc8004: responseURI type = %T", values[3]) + } + hash, ok := values[4].([32]byte) + if !ok { + return AppendResponseCall{}, fmt.Errorf("erc8004: responseHash type = %T", values[4]) + } + out.ResponseHash = common.Hash(hash) + return out, nil +} + +// FeedbackSummary mirrors the reputation getSummary return values. The +// aggregate score is SummaryValue scaled by 10^-SummaryValueDecimals. +type FeedbackSummary struct { + Count uint64 + SummaryValue *big.Int + SummaryValueDecimals uint8 +} + +// FeedbackEntry mirrors readFeedback return values. +type FeedbackEntry struct { + Value *big.Int + ValueDecimals uint8 + Tag1 string + Tag2 string + IsRevoked bool +} + +// ReputationReader provides read-only access to a Reputation Registry. The +// controller uses it to observe recorded feedback; it holds no signer. +type ReputationReader struct { + contract *bind.BoundContract +} + +// NewReputationReader binds a read-only Reputation Registry at +// registryAddress. caller is typically (*erc8004.Client).ETH() or any +// *ethclient.Client. +func NewReputationReader(caller bind.ContractCaller, registryAddress string) (*ReputationReader, error) { + if caller == nil { + return nil, fmt.Errorf("erc8004: reputation reader: caller must not be nil") + } + if !common.IsHexAddress(registryAddress) { + return nil, fmt.Errorf("erc8004: reputation reader: invalid registry address %q", registryAddress) + } + parsed, err := reputationABI() + if err != nil { + return nil, err + } + return &ReputationReader{ + contract: bind.NewBoundContract(common.HexToAddress(registryAddress), parsed, caller, nil, nil), + }, nil +} + +// Summary reads getSummary(agentId, clientAddresses, tag1, tag2). +func (r *ReputationReader) Summary(ctx context.Context, agentID *big.Int, clientAddresses []common.Address, tag1, tag2 string) (FeedbackSummary, error) { + if err := checkAgentID(agentID); err != nil { + return FeedbackSummary{}, err + } + if clientAddresses == nil { + clientAddresses = []common.Address{} + } + var out []interface{} + if err := r.contract.Call(&bind.CallOpts{Context: ctx}, &out, "getSummary", agentID, clientAddresses, tag1, tag2); err != nil { + return FeedbackSummary{}, fmt.Errorf("erc8004: reputation getSummary: %w", err) + } + if len(out) != 3 { + return FeedbackSummary{}, fmt.Errorf("erc8004: reputation getSummary returned %d values, want 3", len(out)) + } + + summary := FeedbackSummary{} + var ok bool + if summary.Count, ok = out[0].(uint64); !ok { + return FeedbackSummary{}, fmt.Errorf("erc8004: reputation getSummary count type = %T", out[0]) + } + if summary.SummaryValue, ok = out[1].(*big.Int); !ok { + return FeedbackSummary{}, fmt.Errorf("erc8004: reputation getSummary summaryValue type = %T", out[1]) + } + if summary.SummaryValueDecimals, ok = out[2].(uint8); !ok { + return FeedbackSummary{}, fmt.Errorf("erc8004: reputation getSummary summaryValueDecimals type = %T", out[2]) + } + return summary, nil +} + +// ReadFeedback reads readFeedback(agentId, clientAddress, feedbackIndex). +// Feedback indices are 1-based. +func (r *ReputationReader) ReadFeedback(ctx context.Context, agentID *big.Int, clientAddress common.Address, feedbackIndex uint64) (FeedbackEntry, error) { + if err := checkAgentID(agentID); err != nil { + return FeedbackEntry{}, err + } + var out []interface{} + if err := r.contract.Call(&bind.CallOpts{Context: ctx}, &out, "readFeedback", agentID, clientAddress, feedbackIndex); err != nil { + return FeedbackEntry{}, fmt.Errorf("erc8004: readFeedback: %w", err) + } + if len(out) != 5 { + return FeedbackEntry{}, fmt.Errorf("erc8004: readFeedback returned %d values, want 5", len(out)) + } + + entry := FeedbackEntry{} + var ok bool + if entry.Value, ok = out[0].(*big.Int); !ok { + return FeedbackEntry{}, fmt.Errorf("erc8004: readFeedback value type = %T", out[0]) + } + if entry.ValueDecimals, ok = out[1].(uint8); !ok { + return FeedbackEntry{}, fmt.Errorf("erc8004: readFeedback valueDecimals type = %T", out[1]) + } + if entry.Tag1, ok = out[2].(string); !ok { + return FeedbackEntry{}, fmt.Errorf("erc8004: readFeedback tag1 type = %T", out[2]) + } + if entry.Tag2, ok = out[3].(string); !ok { + return FeedbackEntry{}, fmt.Errorf("erc8004: readFeedback tag2 type = %T", out[3]) + } + if entry.IsRevoked, ok = out[4].(bool); !ok { + return FeedbackEntry{}, fmt.Errorf("erc8004: readFeedback isRevoked type = %T", out[4]) + } + return entry, nil +} + +// LastIndex reads getLastIndex(agentId, clientAddress) — the most recent +// (1-based) feedback index the client has submitted for the agent; 0 when +// none. +func (r *ReputationReader) LastIndex(ctx context.Context, agentID *big.Int, clientAddress common.Address) (uint64, error) { + if err := checkAgentID(agentID); err != nil { + return 0, err + } + var out []interface{} + if err := r.contract.Call(&bind.CallOpts{Context: ctx}, &out, "getLastIndex", agentID, clientAddress); err != nil { + return 0, fmt.Errorf("erc8004: getLastIndex: %w", err) + } + if len(out) != 1 { + return 0, fmt.Errorf("erc8004: getLastIndex returned %d values, want 1", len(out)) + } + idx, ok := out[0].(uint64) + if !ok { + return 0, fmt.Errorf("erc8004: getLastIndex type = %T", out[0]) + } + return idx, nil +} diff --git a/internal/erc8004/reputation_registry.abi.json b/internal/erc8004/reputation_registry.abi.json new file mode 100644 index 00000000..9948315b --- /dev/null +++ b/internal/erc8004/reputation_registry.abi.json @@ -0,0 +1,391 @@ +[ + { + "inputs": [ + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "internalType": "int128", + "name": "value", + "type": "int128" + }, + { + "internalType": "uint8", + "name": "valueDecimals", + "type": "uint8" + }, + { + "internalType": "string", + "name": "tag1", + "type": "string" + }, + { + "internalType": "string", + "name": "tag2", + "type": "string" + }, + { + "internalType": "string", + "name": "endpoint", + "type": "string" + }, + { + "internalType": "string", + "name": "feedbackURI", + "type": "string" + }, + { + "internalType": "bytes32", + "name": "feedbackHash", + "type": "bytes32" + } + ], + "name": "giveFeedback", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "internalType": "uint64", + "name": "feedbackIndex", + "type": "uint64" + } + ], + "name": "revokeFeedback", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "clientAddress", + "type": "address" + }, + { + "internalType": "uint64", + "name": "feedbackIndex", + "type": "uint64" + }, + { + "internalType": "string", + "name": "responseURI", + "type": "string" + }, + { + "internalType": "bytes32", + "name": "responseHash", + "type": "bytes32" + } + ], + "name": "appendResponse", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "clientAddresses", + "type": "address[]" + }, + { + "internalType": "string", + "name": "tag1", + "type": "string" + }, + { + "internalType": "string", + "name": "tag2", + "type": "string" + } + ], + "name": "getSummary", + "outputs": [ + { + "internalType": "uint64", + "name": "count", + "type": "uint64" + }, + { + "internalType": "int128", + "name": "summaryValue", + "type": "int128" + }, + { + "internalType": "uint8", + "name": "summaryValueDecimals", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "clientAddress", + "type": "address" + }, + { + "internalType": "uint64", + "name": "feedbackIndex", + "type": "uint64" + } + ], + "name": "readFeedback", + "outputs": [ + { + "internalType": "int128", + "name": "value", + "type": "int128" + }, + { + "internalType": "uint8", + "name": "valueDecimals", + "type": "uint8" + }, + { + "internalType": "string", + "name": "tag1", + "type": "string" + }, + { + "internalType": "string", + "name": "tag2", + "type": "string" + }, + { + "internalType": "bool", + "name": "isRevoked", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "clientAddress", + "type": "address" + } + ], + "name": "getLastIndex", + "outputs": [ + { + "internalType": "uint64", + "name": "", + "type": "uint64" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + } + ], + "name": "getClients", + "outputs": [ + { + "internalType": "address[]", + "name": "", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getIdentityRegistry", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "clientAddress", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "feedbackIndex", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "int128", + "name": "value", + "type": "int128" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "valueDecimals", + "type": "uint8" + }, + { + "indexed": true, + "internalType": "string", + "name": "indexedTag1", + "type": "string" + }, + { + "indexed": false, + "internalType": "string", + "name": "tag1", + "type": "string" + }, + { + "indexed": false, + "internalType": "string", + "name": "tag2", + "type": "string" + }, + { + "indexed": false, + "internalType": "string", + "name": "endpoint", + "type": "string" + }, + { + "indexed": false, + "internalType": "string", + "name": "feedbackURI", + "type": "string" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "feedbackHash", + "type": "bytes32" + } + ], + "name": "NewFeedback", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "clientAddress", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint64", + "name": "feedbackIndex", + "type": "uint64" + } + ], + "name": "FeedbackRevoked", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "clientAddress", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "feedbackIndex", + "type": "uint64" + }, + { + "indexed": true, + "internalType": "address", + "name": "responder", + "type": "address" + }, + { + "indexed": false, + "internalType": "string", + "name": "responseURI", + "type": "string" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "responseHash", + "type": "bytes32" + } + ], + "name": "ResponseAppended", + "type": "event" + } +] diff --git a/internal/erc8004/reputation_test.go b/internal/erc8004/reputation_test.go new file mode 100644 index 00000000..a225b5e0 --- /dev/null +++ b/internal/erc8004/reputation_test.go @@ -0,0 +1,410 @@ +package erc8004 + +import ( + "context" + "encoding/hex" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +func TestReputationABI_Parses(t *testing.T) { + if _, err := reputationABI(); err != nil { + t.Fatalf("embedded reputation ABI failed to parse: %v", err) + } +} + +// TestReputationABI_SelectorGoldenValues pins the 4-byte selectors of the +// verified v2.0.0 signatures (spec: https://eips.ethereum.org/EIPS/eip-8004; +// ABI: https://github.com/erc-8004/erc-8004-contracts). Each golden value is +// cross-checked against keccak256 of the canonical signature string and the +// parsed ABI method. +func TestReputationABI_SelectorGoldenValues(t *testing.T) { + parsed, err := reputationABI() + if err != nil { + t.Fatal(err) + } + + tests := []struct { + method string + sig string + selector string + }{ + {"giveFeedback", "giveFeedback(uint256,int128,uint8,string,string,string,string,bytes32)", "3c036a7e"}, + {"revokeFeedback", "revokeFeedback(uint256,uint64)", "4ab3ca99"}, + {"appendResponse", "appendResponse(uint256,address,uint64,string,bytes32)", "c2349ab2"}, + {"getSummary", "getSummary(uint256,address[],string,string)", "81bbba58"}, + {"readFeedback", "readFeedback(uint256,address,uint64)", "232b0810"}, + {"getLastIndex", "getLastIndex(uint256,address)", "f2d81759"}, + {"getClients", "getClients(uint256)", "42dd519c"}, + {"getIdentityRegistry", "getIdentityRegistry()", "bc4d861b"}, + } + + for _, tt := range tests { + t.Run(tt.method, func(t *testing.T) { + m, ok := parsed.Methods[tt.method] + if !ok { + t.Fatalf("method %q missing from parsed ABI", tt.method) + } + if m.Sig != tt.sig { + t.Errorf("signature = %q, want %q", m.Sig, tt.sig) + } + if got := hex.EncodeToString(m.ID); got != tt.selector { + t.Errorf("parsed selector = 0x%s, want 0x%s", got, tt.selector) + } + if got := hex.EncodeToString(crypto.Keccak256([]byte(tt.sig))[:4]); got != tt.selector { + t.Errorf("keccak256(%q)[:4] = 0x%s, want 0x%s", tt.sig, got, tt.selector) + } + }) + } +} + +func TestReputationABI_EventsPresent(t *testing.T) { + parsed, err := reputationABI() + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"NewFeedback", "FeedbackRevoked", "ResponseAppended"} { + if _, ok := parsed.Events[name]; !ok { + t.Errorf("missing event %q in parsed ABI", name) + } + } +} + +func TestEncodeGiveFeedback_RoundTrip(t *testing.T) { + agentID := big.NewInt(42) + value := big.NewInt(-875) // -87.5 with valueDecimals=1 + feedbackHash := crypto.Keccak256Hash([]byte("feedback payload")) + + data, err := EncodeGiveFeedback(agentID, value, 1, "code-review", "go", "https://agent.example/v1", "ipfs://bafy.../fb.json", feedbackHash) + if err != nil { + t.Fatalf("EncodeGiveFeedback: %v", err) + } + if got := hex.EncodeToString(data[:4]); got != "3c036a7e" { + t.Errorf("selector = 0x%s, want 0x3c036a7e", got) + } + + decoded, err := DecodeGiveFeedbackCalldata(data) + if err != nil { + t.Fatalf("DecodeGiveFeedbackCalldata: %v", err) + } + if decoded.AgentID.Cmp(agentID) != 0 { + t.Errorf("agentId = %s, want %s", decoded.AgentID, agentID) + } + if decoded.Value.Cmp(value) != 0 { + t.Errorf("value = %s, want %s", decoded.Value, value) + } + if decoded.ValueDecimals != 1 { + t.Errorf("valueDecimals = %d, want 1", decoded.ValueDecimals) + } + if decoded.Tag1 != "code-review" || decoded.Tag2 != "go" { + t.Errorf("tags = (%q, %q), want (code-review, go)", decoded.Tag1, decoded.Tag2) + } + if decoded.Endpoint != "https://agent.example/v1" { + t.Errorf("endpoint = %q", decoded.Endpoint) + } + if decoded.FeedbackURI != "ipfs://bafy.../fb.json" { + t.Errorf("feedbackURI = %q", decoded.FeedbackURI) + } + if decoded.FeedbackHash != feedbackHash { + t.Errorf("feedbackHash = %s, want %s", decoded.FeedbackHash, feedbackHash) + } +} + +func TestEncodeRevokeFeedback_RoundTrip(t *testing.T) { + data, err := EncodeRevokeFeedback(big.NewInt(42), 7) + if err != nil { + t.Fatalf("EncodeRevokeFeedback: %v", err) + } + if got := hex.EncodeToString(data[:4]); got != "4ab3ca99" { + t.Errorf("selector = 0x%s, want 0x4ab3ca99", got) + } + + decoded, err := DecodeRevokeFeedbackCalldata(data) + if err != nil { + t.Fatalf("DecodeRevokeFeedbackCalldata: %v", err) + } + if decoded.AgentID.Cmp(big.NewInt(42)) != 0 || decoded.FeedbackIndex != 7 { + t.Errorf("decoded = %+v, want agentId=42 feedbackIndex=7", decoded) + } +} + +func TestEncodeAppendResponse_RoundTrip(t *testing.T) { + client := common.HexToAddress("0x4444444444444444444444444444444444444444") + respHash := crypto.Keccak256Hash([]byte("response payload")) + + data, err := EncodeAppendResponse(big.NewInt(42), client, 7, "ipfs://bafy.../resp.json", respHash) + if err != nil { + t.Fatalf("EncodeAppendResponse: %v", err) + } + if got := hex.EncodeToString(data[:4]); got != "c2349ab2" { + t.Errorf("selector = 0x%s, want 0xc2349ab2", got) + } + + decoded, err := DecodeAppendResponseCalldata(data) + if err != nil { + t.Fatalf("DecodeAppendResponseCalldata: %v", err) + } + if decoded.AgentID.Cmp(big.NewInt(42)) != 0 { + t.Errorf("agentId = %s, want 42", decoded.AgentID) + } + if decoded.ClientAddress != client { + t.Errorf("clientAddress = %s, want %s", decoded.ClientAddress, client) + } + if decoded.FeedbackIndex != 7 { + t.Errorf("feedbackIndex = %d, want 7", decoded.FeedbackIndex) + } + if decoded.ResponseURI != "ipfs://bafy.../resp.json" { + t.Errorf("responseURI = %q", decoded.ResponseURI) + } + if decoded.ResponseHash != respHash { + t.Errorf("responseHash = %s, want %s", decoded.ResponseHash, respHash) + } +} + +func TestEncodeGiveFeedback_BadInput(t *testing.T) { + hash := crypto.Keccak256Hash([]byte("x")) + overMax := new(big.Int).Add(maxFeedbackAbsValue, big.NewInt(1)) + underMin := new(big.Int).Neg(overMax) + + tests := []struct { + name string + fn func() ([]byte, error) + }{ + {"nil agentId", func() ([]byte, error) { + return EncodeGiveFeedback(nil, big.NewInt(1), 0, "", "", "", "", hash) + }}, + {"negative agentId", func() ([]byte, error) { + return EncodeGiveFeedback(big.NewInt(-1), big.NewInt(1), 0, "", "", "", "", hash) + }}, + {"nil value", func() ([]byte, error) { + return EncodeGiveFeedback(big.NewInt(1), nil, 0, "", "", "", "", hash) + }}, + {"value over 1e38", func() ([]byte, error) { + return EncodeGiveFeedback(big.NewInt(1), overMax, 0, "", "", "", "", hash) + }}, + {"value under -1e38", func() ([]byte, error) { + return EncodeGiveFeedback(big.NewInt(1), underMin, 0, "", "", "", "", hash) + }}, + {"valueDecimals 19", func() ([]byte, error) { + return EncodeGiveFeedback(big.NewInt(1), big.NewInt(1), 19, "", "", "", "", hash) + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := tt.fn(); err == nil { + t.Error("expected error, got nil") + } + }) + } + + // Boundary values must be accepted. + if _, err := EncodeGiveFeedback(big.NewInt(1), maxFeedbackAbsValue, MaxFeedbackValueDecimals, "", "", "", "", common.Hash{}); err != nil { + t.Errorf("value 1e38, decimals 18 should be accepted: %v", err) + } + if _, err := EncodeGiveFeedback(big.NewInt(1), new(big.Int).Neg(maxFeedbackAbsValue), 0, "", "", "", "", common.Hash{}); err != nil { + t.Errorf("value -1e38 should be accepted: %v", err) + } +} + +func TestEncodeRevokeFeedback_BadInput(t *testing.T) { + if _, err := EncodeRevokeFeedback(nil, 1); err == nil { + t.Error("nil agentId: expected error") + } + if _, err := EncodeRevokeFeedback(big.NewInt(1), 0); err == nil { + t.Error("feedbackIndex 0: expected error") + } +} + +func TestEncodeAppendResponse_BadInput(t *testing.T) { + client := common.HexToAddress("0x4444444444444444444444444444444444444444") + if _, err := EncodeAppendResponse(nil, client, 1, "u", common.Hash{}); err == nil { + t.Error("nil agentId: expected error") + } + if _, err := EncodeAppendResponse(big.NewInt(1), common.Address{}, 1, "u", common.Hash{}); err == nil { + t.Error("zero clientAddress: expected error") + } + if _, err := EncodeAppendResponse(big.NewInt(1), client, 0, "u", common.Hash{}); err == nil { + t.Error("feedbackIndex 0: expected error") + } + if _, err := EncodeAppendResponse(big.NewInt(1), client, 1, "", common.Hash{}); err == nil { + t.Error("empty responseURI: expected error") + } +} + +func TestDecodeReputationCalldata_Errors(t *testing.T) { + t.Run("too short", func(t *testing.T) { + if _, err := DecodeGiveFeedbackCalldata([]byte{0x3c}); err == nil { + t.Error("expected error for short calldata") + } + }) + + t.Run("wrong selector", func(t *testing.T) { + data, err := EncodeRevokeFeedback(big.NewInt(1), 1) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeGiveFeedbackCalldata(data); err == nil { + t.Error("expected selector mismatch error") + } else if !strings.Contains(err.Error(), "selector mismatch") { + t.Errorf("error = %v, want selector mismatch", err) + } + }) + + t.Run("truncated args", func(t *testing.T) { + data, err := EncodeGiveFeedback(big.NewInt(1), big.NewInt(50), 0, "t1", "t2", "e", "u", common.Hash{}) + if err != nil { + t.Fatal(err) + } + // Cut the entire trailing dynamic section so the feedbackURI offset + // points past the end of the payload. + if _, err := DecodeGiveFeedbackCalldata(data[:len(data)-96]); err == nil { + t.Error("expected error for truncated calldata") + } + }) +} + +func TestReputationRegistryAddress(t *testing.T) { + tests := []struct { + network string + want string + wantErr bool + }{ + {"base-sepolia", ReputationRegistryBaseSepolia, false}, + {"base", ReputationRegistryMainnet, false}, + {"base-mainnet", ReputationRegistryMainnet, false}, + {"ethereum", ReputationRegistryMainnet, false}, + {"mainnet", ReputationRegistryMainnet, false}, + {"solana", "", true}, + {"", "", true}, + } + for _, tt := range tests { + t.Run(tt.network, func(t *testing.T) { + got, err := ReputationRegistryAddress(tt.network) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for %q, got address %s", tt.network, got) + } + return + } + if err != nil { + t.Fatalf("ReputationRegistryAddress(%q): %v", tt.network, err) + } + if got != tt.want { + t.Errorf("address = %s, want %s", got, tt.want) + } + }) + } +} + +func TestNewReputationReader_BadInput(t *testing.T) { + if _, err := NewReputationReader(nil, ReputationRegistryBaseSepolia); err == nil { + t.Error("nil caller: expected error") + } + if _, err := NewReputationReader(&stubCaller{}, "0xZZ"); err == nil { + t.Error("bad address: expected error") + } +} + +func TestReputationReader_Summary(t *testing.T) { + parsed, err := reputationABI() + if err != nil { + t.Fatal(err) + } + ret, err := parsed.Methods["getSummary"].Outputs.Pack(uint64(12), big.NewInt(925), uint8(1)) + if err != nil { + t.Fatal(err) + } + + caller := &stubCaller{ret: ret} + reader, err := NewReputationReader(caller, ReputationRegistryBaseSepolia) + if err != nil { + t.Fatal(err) + } + + summary, err := reader.Summary(context.Background(), big.NewInt(42), nil, "code-review", "") + if err != nil { + t.Fatalf("Summary: %v", err) + } + if summary.Count != 12 { + t.Errorf("count = %d, want 12", summary.Count) + } + if summary.SummaryValue.Cmp(big.NewInt(925)) != 0 { + t.Errorf("summaryValue = %s, want 925", summary.SummaryValue) + } + if summary.SummaryValueDecimals != 1 { + t.Errorf("summaryValueDecimals = %d, want 1", summary.SummaryValueDecimals) + } + + wantData, err := parsed.Pack("getSummary", big.NewInt(42), []common.Address{}, "code-review", "") + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(caller.lastCall.Data) != hex.EncodeToString(wantData) { + t.Errorf("call data = 0x%x, want 0x%x", caller.lastCall.Data, wantData) + } + + if _, err := reader.Summary(context.Background(), nil, nil, "", ""); err == nil { + t.Error("nil agentId: expected error") + } +} + +func TestReputationReader_ReadFeedback(t *testing.T) { + parsed, err := reputationABI() + if err != nil { + t.Fatal(err) + } + ret, err := parsed.Methods["readFeedback"].Outputs.Pack(big.NewInt(-50), uint8(0), "code-review", "go", true) + if err != nil { + t.Fatal(err) + } + + reader, err := NewReputationReader(&stubCaller{ret: ret}, ReputationRegistryBaseSepolia) + if err != nil { + t.Fatal(err) + } + + entry, err := reader.ReadFeedback(context.Background(), big.NewInt(42), common.HexToAddress("0x4444444444444444444444444444444444444444"), 3) + if err != nil { + t.Fatalf("ReadFeedback: %v", err) + } + if entry.Value.Cmp(big.NewInt(-50)) != 0 { + t.Errorf("value = %s, want -50", entry.Value) + } + if entry.ValueDecimals != 0 { + t.Errorf("valueDecimals = %d, want 0", entry.ValueDecimals) + } + if entry.Tag1 != "code-review" || entry.Tag2 != "go" { + t.Errorf("tags = (%q, %q)", entry.Tag1, entry.Tag2) + } + if !entry.IsRevoked { + t.Error("isRevoked = false, want true") + } +} + +func TestReputationReader_LastIndex(t *testing.T) { + parsed, err := reputationABI() + if err != nil { + t.Fatal(err) + } + ret, err := parsed.Methods["getLastIndex"].Outputs.Pack(uint64(9)) + if err != nil { + t.Fatal(err) + } + + reader, err := NewReputationReader(&stubCaller{ret: ret}, ReputationRegistryBaseSepolia) + if err != nil { + t.Fatal(err) + } + + idx, err := reader.LastIndex(context.Background(), big.NewInt(42), common.HexToAddress("0x4444444444444444444444444444444444444444")) + if err != nil { + t.Fatalf("LastIndex: %v", err) + } + if idx != 9 { + t.Errorf("lastIndex = %d, want 9", idx) + } +} diff --git a/internal/erc8004/skill_tags.go b/internal/erc8004/skill_tags.go new file mode 100644 index 00000000..2ac23140 --- /dev/null +++ b/internal/erc8004/skill_tags.go @@ -0,0 +1,106 @@ +// Skill marketplace ↔ ERC-8004 tag + metadata-key convention. +// +// Skill ratings ride the Reputation Registry's giveFeedback tag pair +// using the ERC-8239 draft "Agent Skill Rating" convention: +// +// tag1 = "asr:skill" +// tag2 = "eip155::::@" +// +// This file implements the obol interim form of the ERC-8239 draft +// (ethereum/EIPs PR #1704) tag2: the registry address is lowercased hex +// for determinism (giveFeedback tags are exact-match strings on-chain, +// so a mixed-case address would silently fork the rating namespace) and +// the agentId is rendered in decimal. The skill ref is "@". +// +// Bundle integrity is anchored on the Identity Registry via setMetadata +// with key "skill.sha256:@" and the 64-char ASCII +// lowercase hex sha256 of the gzipped bundle bytes as the value — +// ASCII hex rather than raw bytes so block explorers render it legibly +// and GetMetadata comparison is a bytes.Equal on the hex string. +// +// Signing model is identical to the rest of this package: the CLI only +// builds calldata; the operator/buyer submits with their OWN wallet. +// The controller NEVER signs. + +package erc8004 + +import ( + "fmt" + "math/big" + "strings" +) + +// SkillTag1 is the fixed tag1 for skill-rating feedback entries +// (ERC-8239 draft "asr" = agent skill rating). +const SkillTag1 = "asr:skill" + +// skillHashKeyPrefix prefixes the Identity Registry setMetadata key +// that anchors a skill bundle's sha256. +const skillHashKeyPrefix = "skill.sha256:" + +// SkillRef builds the canonical "@" skill reference. +// Both parts must be non-empty and free of ':' (tag2 is colon- +// delimited) and '@' (the ref separator). +func SkillRef(name, version string) (string, error) { + if err := checkSkillRefPart("skill name", name); err != nil { + return "", err + } + if err := checkSkillRefPart("skill version", version); err != nil { + return "", err + } + return name + "@" + version, nil +} + +// ParseSkillRef splits a "@" reference and re-validates +// both parts. Use it to normalize operator-supplied refs before they +// reach a tag or metadata key. +func ParseSkillRef(ref string) (name, version string, err error) { + name, version, ok := strings.Cut(strings.TrimSpace(ref), "@") + if !ok { + return "", "", fmt.Errorf("erc8004: skill ref %q must be @ (e.g. buy-x402@0.1.0)", ref) + } + if _, err := SkillRef(name, version); err != nil { + return "", "", err + } + return name, version, nil +} + +func checkSkillRefPart(what, v string) error { + if strings.TrimSpace(v) == "" { + return fmt.Errorf("erc8004: %s must not be empty", what) + } + if strings.ContainsAny(v, ":@") { + return fmt.Errorf("erc8004: %s %q must not contain ':' or '@'", what, v) + } + return nil +} + +// SkillTag2 builds the ERC-8239-style tag2 binding a rating to one +// skill of one agent on one registry deployment: +// +// eip155:::: +// +// skillRef must be a valid "@" reference (see SkillRef). +func SkillTag2(net NetworkConfig, agentID *big.Int, skillRef string) (string, error) { + if err := checkAgentID(agentID); err != nil { + return "", err + } + if _, _, err := ParseSkillRef(skillRef); err != nil { + return "", err + } + return fmt.Sprintf("eip155:%d:%s:%s:%s", + net.ChainID, + strings.ToLower(net.RegistryAddress), + agentID.String(), + skillRef, + ), nil +} + +// SkillHashMetadataKey returns the Identity Registry setMetadata key +// under which a skill bundle's sha256 is anchored: +// "skill.sha256:@". The metadata VALUE is the 64-char +// ASCII lowercase hex sha256 of the gzipped bundle bytes, stored as +// []byte(hex). +func SkillHashMetadataKey(skillRef string) string { + return skillHashKeyPrefix + skillRef +} diff --git a/internal/erc8004/skill_tags_test.go b/internal/erc8004/skill_tags_test.go new file mode 100644 index 00000000..eb51b43f --- /dev/null +++ b/internal/erc8004/skill_tags_test.go @@ -0,0 +1,303 @@ +package erc8004 + +import ( + "bytes" + "encoding/hex" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +func TestSkillTag1_Constant(t *testing.T) { + // ERC-8239 draft "asr" tag1 — changing this forks the rating + // namespace for every previously submitted skill feedback entry. + if SkillTag1 != "asr:skill" { + t.Fatalf("SkillTag1 = %q, want %q", SkillTag1, "asr:skill") + } +} + +func TestSkillRef(t *testing.T) { + tests := []struct { + name string + skill string + version string + want string + wantErr string + }{ + {name: "ok", skill: "buy-x402", version: "0.1.0", want: "buy-x402@0.1.0"}, + {name: "ok with prerelease", skill: "monetize", version: "1.0.0-rc1", want: "monetize@1.0.0-rc1"}, + {name: "empty name", skill: "", version: "0.1.0", wantErr: "must not be empty"}, + {name: "empty version", skill: "buy-x402", version: "", wantErr: "must not be empty"}, + {name: "colon in name", skill: "buy:x402", version: "0.1.0", wantErr: "must not contain"}, + {name: "colon in version", skill: "buy-x402", version: "0:1", wantErr: "must not contain"}, + {name: "at in name", skill: "buy@x402", version: "0.1.0", wantErr: "must not contain"}, + {name: "at in version", skill: "buy-x402", version: "0@1", wantErr: "must not contain"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := SkillRef(tt.skill, tt.version) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want substring %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Errorf("SkillRef = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseSkillRef(t *testing.T) { + tests := []struct { + ref string + wantName string + wantVersion string + wantErr bool + }{ + {ref: "buy-x402@0.1.0", wantName: "buy-x402", wantVersion: "0.1.0"}, + {ref: " buy-x402@0.1.0 ", wantName: "buy-x402", wantVersion: "0.1.0"}, + {ref: "buy-x402", wantErr: true}, + {ref: "@0.1.0", wantErr: true}, + {ref: "buy-x402@", wantErr: true}, + {ref: "a@b@c", wantErr: true}, // version part keeps the second '@' + {ref: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.ref, func(t *testing.T) { + name, version, err := ParseSkillRef(tt.ref) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseSkillRef(%q) = (%q, %q), want error", tt.ref, name, version) + } + return + } + if err != nil { + t.Fatal(err) + } + if name != tt.wantName || version != tt.wantVersion { + t.Errorf("ParseSkillRef(%q) = (%q, %q), want (%q, %q)", tt.ref, name, version, tt.wantName, tt.wantVersion) + } + }) + } +} + +// TestSkillTag2_Golden pins the documented obol interim form of the +// ERC-8239 draft (PR #1704) tag2: +// eip155::::@. +func TestSkillTag2_Golden(t *testing.T) { + tests := []struct { + name string + net NetworkConfig + agentID *big.Int + ref string + want string + }{ + { + name: "base-sepolia", + net: BaseSepolia, + agentID: big.NewInt(42), + ref: "buy-x402@0.1.0", + want: "eip155:84532:0x8004a818bfb912233c491871b3d84c89a494bd9e:42:buy-x402@0.1.0", + }, + { + name: "base mainnet", + net: Base, + agentID: big.NewInt(7), + ref: "monetize@1.2.3", + want: "eip155:8453:0x8004a169fb4a3325136eb29fa0ceb6d2e539a432:7:monetize@1.2.3", + }, + { + name: "ethereum mainnet", + net: Ethereum, + agentID: big.NewInt(1001), + ref: "quant@0.0.1", + want: "eip155:1:0x8004a169fb4a3325136eb29fa0ceb6d2e539a432:1001:quant@0.0.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := SkillTag2(tt.net, tt.agentID, tt.ref) + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Errorf("SkillTag2 = %q, want %q", got, tt.want) + } + // The registry segment must be lowercase: tags are + // exact-match strings on-chain. + if got != strings.ToLower(got) { + t.Errorf("SkillTag2 = %q contains uppercase", got) + } + }) + } +} + +func TestSkillTag2_BadInput(t *testing.T) { + tests := []struct { + name string + agentID *big.Int + ref string + }{ + {name: "nil agent id", agentID: nil, ref: "buy-x402@0.1.0"}, + {name: "negative agent id", agentID: big.NewInt(-1), ref: "buy-x402@0.1.0"}, + {name: "ref without version", agentID: big.NewInt(1), ref: "buy-x402"}, + {name: "empty ref", agentID: big.NewInt(1), ref: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := SkillTag2(BaseSepolia, tt.agentID, tt.ref); err == nil { + t.Fatal("expected error, got nil") + } + }) + } +} + +func TestSkillHashMetadataKey(t *testing.T) { + if got := SkillHashMetadataKey("buy-x402@0.1.0"); got != "skill.sha256:buy-x402@0.1.0" { + t.Fatalf("SkillHashMetadataKey = %q, want %q", got, "skill.sha256:buy-x402@0.1.0") + } +} + +// TestEncodeSetMetadata_Golden pins the exact calldata for fixed inputs +// and cross-checks the 4-byte selector against keccak256 of the +// canonical signature. +func TestEncodeSetMetadata_Golden(t *testing.T) { + const ( + wantSelector = "466648da" // keccak256("setMetadata(uint256,string,bytes)")[:4] + wantCalldata = "466648da" + + "000000000000000000000000000000000000000000000000000000000000002a" + + "0000000000000000000000000000000000000000000000000000000000000060" + + "00000000000000000000000000000000000000000000000000000000000000a0" + + "000000000000000000000000000000000000000000000000000000000000001b" + + "736b696c6c2e7368613235363a6275792d7834303240302e312e300000000000" + + "0000000000000000000000000000000000000000000000000000000000000040" + + "3966383664303831383834633764363539613266656161306335356164303135" + + "6133626634663162326230623832326364313564366331356230663030613038" + ) + + if got := hex.EncodeToString(crypto.Keccak256([]byte("setMetadata(uint256,string,bytes)"))[:4]); got != wantSelector { + t.Fatalf("keccak selector = %s, want %s", got, wantSelector) + } + + hash := "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + data, err := EncodeSetMetadata(big.NewInt(42), SkillHashMetadataKey("buy-x402@0.1.0"), []byte(hash)) + if err != nil { + t.Fatal(err) + } + if got := hex.EncodeToString(data[:4]); got != wantSelector { + t.Errorf("calldata selector = %s, want %s", got, wantSelector) + } + if got := hex.EncodeToString(data); got != wantCalldata { + t.Errorf("calldata = %s\nwant %s", got, wantCalldata) + } +} + +func TestEncodeSetMetadata_RoundTrip(t *testing.T) { + agentID := big.NewInt(123456) + key := SkillHashMetadataKey("monetize@2.0.0") + value := []byte(strings.Repeat("ab", 32)) + + data, err := EncodeSetMetadata(agentID, key, value) + if err != nil { + t.Fatal(err) + } + + decoded, err := DecodeSetMetadataCalldata(data) + if err != nil { + t.Fatal(err) + } + if decoded.AgentID.Cmp(agentID) != 0 { + t.Errorf("agentID = %s, want %s", decoded.AgentID, agentID) + } + if decoded.Key != key { + t.Errorf("key = %q, want %q", decoded.Key, key) + } + if !bytes.Equal(decoded.Value, value) { + t.Errorf("value = %x, want %x", decoded.Value, value) + } +} + +func TestEncodeSetMetadata_BadInput(t *testing.T) { + tests := []struct { + name string + agentID *big.Int + key string + }{ + {name: "nil agent id", agentID: nil, key: "skill.sha256:a@1"}, + {name: "negative agent id", agentID: big.NewInt(-5), key: "skill.sha256:a@1"}, + {name: "empty key", agentID: big.NewInt(1), key: ""}, + {name: "whitespace key", agentID: big.NewInt(1), key: " "}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := EncodeSetMetadata(tt.agentID, tt.key, []byte("x")); err == nil { + t.Fatal("expected error, got nil") + } + }) + } +} + +func TestDecodeSetMetadataCalldata_Errors(t *testing.T) { + // Wrong selector (giveFeedback's) must be rejected. + wrong, err := EncodeGiveFeedback(big.NewInt(1), big.NewInt(1), 0, "", "", "", "", common.Hash{}) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeSetMetadataCalldata(wrong); err == nil { + t.Fatal("expected selector mismatch error, got nil") + } + if _, err := DecodeSetMetadataCalldata([]byte{0x01}); err == nil { + t.Fatal("expected too-short error, got nil") + } +} + +// TestEncodeGiveFeedback_SkillTags_Golden pins the full calldata of a +// skill rating: tag1="asr:skill", tag2 in the documented interim +// ERC-8239 form, score 95/100 with no fixed-point scaling. +func TestEncodeGiveFeedback_SkillTags_Golden(t *testing.T) { + const wantCalldata = "3c036a7e000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000005f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000014000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000096173723a736b696c6c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000496569703135353a38343533323a3078383030346138313862666239313232333363343931383731623364383463383961343934626439653a34323a6275792d7834303240302e312e30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + + tag2, err := SkillTag2(BaseSepolia, big.NewInt(42), "buy-x402@0.1.0") + if err != nil { + t.Fatal(err) + } + data, err := EncodeGiveFeedback(big.NewInt(42), big.NewInt(95), 0, SkillTag1, tag2, "", "", common.Hash{}) + if err != nil { + t.Fatal(err) + } + + if got := hex.EncodeToString(data[:4]); got != "3c036a7e" { + t.Errorf("selector = %s, want 3c036a7e (giveFeedback)", got) + } + if got := hex.EncodeToString(data); got != wantCalldata { + t.Errorf("calldata mismatch:\n got %s\nwant %s", got, wantCalldata) + } + + // And it must decode back to the skill-tag pair. + decoded, err := DecodeGiveFeedbackCalldata(data) + if err != nil { + t.Fatal(err) + } + if decoded.Tag1 != SkillTag1 { + t.Errorf("tag1 = %q, want %q", decoded.Tag1, SkillTag1) + } + if decoded.Tag2 != tag2 { + t.Errorf("tag2 = %q, want %q", decoded.Tag2, tag2) + } + if decoded.Value.Int64() != 95 { + t.Errorf("value = %s, want 95", decoded.Value) + } +} diff --git a/internal/erc8004/validation.go b/internal/erc8004/validation.go new file mode 100644 index 00000000..8aa46a8b --- /dev/null +++ b/internal/erc8004/validation.go @@ -0,0 +1,401 @@ +package erc8004 + +// ERC-8004 Validation Registry (v2.0.0) calldata builders and read helpers. +// +// IMPORTANT — signing model: the serviceoffer/servicebounty controller NEVER +// signs validation transactions. Poster agents submit validationRequest and +// evaluator agents submit validationResponse with THEIR OWN wallets; this +// package only builds calldata for them and reads/records results on-chain. +// +// Function signatures verified against: +// - Spec: https://eips.ethereum.org/EIPS/eip-8004 (Validation Registry) +// - Reference impl + official ABI: +// https://github.com/erc-8004/erc-8004-contracts +// (abis/ValidationRegistry.json, contracts/ValidationRegistryUpgradeable.sol, +// getVersion() == "2.0.0") +// +// validationRequest(address validatorAddress, uint256 agentId, string requestURI, bytes32 requestHash) +// validationResponse(bytes32 requestHash, uint8 response, string responseURI, bytes32 responseHash, string tag) +// getValidationStatus(bytes32 requestHash) -> (address, uint256, uint8, bytes32, string, uint256) +// getSummary(uint256 agentId, address[] validatorAddresses, string tag) -> (uint64 count, uint8 avgResponse) +// getAgentValidations(uint256 agentId) -> bytes32[] +// getValidatorRequests(address validatorAddress) -> bytes32[] + +import ( + "bytes" + "context" + _ "embed" + "fmt" + "math/big" + "strings" + "sync" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" +) + +//go:embed validation_registry.abi.json +var validationRegistryABI string + +const ( + // ValidationRegistryV2BaseSepolia is the ERC-8004 v2.0.0 Validation + // Registry on Base Sepolia (CREATE2 vanity proxy, same address on all + // supported testnets). + // + // NOTE: this intentionally differs from the legacy + // ValidationRegistryBaseSepolia constant in abi.go + // (0x8004CB39f29c09145F24Ad9dDe2A108C1A2cdfC5): that address has NO code + // on Base Sepolia — it is a v1.0.0 deployment that only exists on + // Ethereum Sepolia (verified via eth_getCode + getVersion(), 2026-06-10). + // Source: https://github.com/erc-8004/erc-8004-contracts + // (scripts/addresses.ts TESTNET_ADDRESSES.validationRegistry); on-chain: + // getVersion() == "2.0.0", getIdentityRegistry() == + // IdentityRegistryBaseSepolia. + ValidationRegistryV2BaseSepolia = "0x8004Cb1BF31DAf7788923b405b754f57acEB4272" + + // ValidationRegistryV2Mainnet is the ERC-8004 v2.0.0 Validation Registry + // on Ethereum mainnet and Base mainnet (deployed at the same address via + // CREATE2). Source: https://github.com/erc-8004/erc-8004-contracts + // (scripts/addresses.ts MAINNET_ADDRESSES.validationRegistry); on-chain: + // code present on both chains, getVersion() == "2.0.0", + // getIdentityRegistry() == IdentityRegistryMainnet. + ValidationRegistryV2Mainnet = "0x8004Cc8439f36fd5F9F049D9fF86523Df6dAAB58" + + // MaxValidationResponse is the maximum validationResponse score. The + // contract reverts with "resp>100" above this. + MaxValidationResponse = 100 +) + +var ( + validationABIOnce sync.Once + validationABIParsed abi.ABI + validationABIErr error +) + +// validationABI lazily parses the embedded Validation Registry ABI once. +func validationABI() (abi.ABI, error) { + validationABIOnce.Do(func() { + validationABIParsed, validationABIErr = abi.JSON(strings.NewReader(validationRegistryABI)) + }) + if validationABIErr != nil { + return abi.ABI{}, fmt.Errorf("erc8004: parse validation registry abi: %w", validationABIErr) + } + return validationABIParsed, nil +} + +// ValidationRegistryAddress maps a supported network name to the deployed +// ERC-8004 v2.0.0 Validation Registry address. It accepts the same aliases as +// ResolveNetwork. Networks without an on-chain-verified deployment return an +// error rather than a guessed address. +func ValidationRegistryAddress(network string) (string, error) { + net, err := ResolveNetwork(network) + if err != nil { + return "", fmt.Errorf("erc8004: validation registry: %w", err) + } + switch net.Name { + case BaseSepolia.Name: + return ValidationRegistryV2BaseSepolia, nil + case Base.Name, Ethereum.Name: + return ValidationRegistryV2Mainnet, nil + default: + return "", fmt.Errorf("erc8004: no verified validation registry deployment for network %q", net.Name) + } +} + +// checkAgentID rejects agent ids that cannot be ABI-encoded as uint256. +func checkAgentID(agentID *big.Int) error { + if agentID == nil { + return fmt.Errorf("erc8004: agentId must not be nil") + } + if agentID.Sign() < 0 { + return fmt.Errorf("erc8004: agentId must not be negative (got %s)", agentID) + } + if agentID.BitLen() > 256 { + return fmt.Errorf("erc8004: agentId does not fit in uint256") + } + return nil +} + +// unpackCalldata verifies the 4-byte selector against the named method and +// unpacks the argument payload. +func unpackCalldata(parsed abi.ABI, name string, data []byte) ([]interface{}, error) { + method, ok := parsed.Methods[name] + if !ok { + return nil, fmt.Errorf("erc8004: method %q not in ABI", name) + } + if len(data) < 4 { + return nil, fmt.Errorf("erc8004: calldata too short (%d bytes, need at least 4)", len(data)) + } + if !bytes.Equal(data[:4], method.ID) { + return nil, fmt.Errorf("erc8004: selector mismatch: got 0x%x, want 0x%x (%s)", data[:4], method.ID, method.Sig) + } + values, err := method.Inputs.Unpack(data[4:]) + if err != nil { + return nil, fmt.Errorf("erc8004: unpack %s calldata: %w", name, err) + } + return values, nil +} + +// EncodeValidationRequest builds calldata for +// validationRequest(address,uint256,string,bytes32). The transaction must be +// submitted by the owner or an approved operator of agentId (the poster +// agent's own wallet) — never by the controller. +func EncodeValidationRequest(validatorAddress common.Address, agentID *big.Int, requestURI string, requestHash common.Hash) ([]byte, error) { + if validatorAddress == (common.Address{}) { + return nil, fmt.Errorf("erc8004: validatorAddress must not be the zero address") + } + if err := checkAgentID(agentID); err != nil { + return nil, err + } + if requestHash == (common.Hash{}) { + return nil, fmt.Errorf("erc8004: requestHash must not be the zero hash") + } + + parsed, err := validationABI() + if err != nil { + return nil, err + } + data, err := parsed.Pack("validationRequest", validatorAddress, agentID, requestURI, requestHash) + if err != nil { + return nil, fmt.Errorf("erc8004: pack validationRequest: %w", err) + } + return data, nil +} + +// EncodeValidationResponse builds calldata for +// validationResponse(bytes32,uint8,string,bytes32,string). response is the +// 0-100 score; the transaction must be submitted by the validator address +// named in the matching validationRequest (the evaluator's own wallet) — +// never by the controller. responseURI, responseHash, and tag are optional +// per spec and may be zero values. +func EncodeValidationResponse(requestHash common.Hash, response uint8, responseURI string, responseHash common.Hash, tag string) ([]byte, error) { + if requestHash == (common.Hash{}) { + return nil, fmt.Errorf("erc8004: requestHash must not be the zero hash") + } + if response > MaxValidationResponse { + return nil, fmt.Errorf("erc8004: response %d out of range [0,%d]", response, MaxValidationResponse) + } + + parsed, err := validationABI() + if err != nil { + return nil, err + } + data, err := parsed.Pack("validationResponse", requestHash, response, responseURI, responseHash, tag) + if err != nil { + return nil, fmt.Errorf("erc8004: pack validationResponse: %w", err) + } + return data, nil +} + +// ValidationRequestCall is the decoded argument set of a validationRequest call. +type ValidationRequestCall struct { + ValidatorAddress common.Address + AgentID *big.Int + RequestURI string + RequestHash common.Hash +} + +// DecodeValidationRequestCalldata decodes validationRequest calldata +// (selector + ABI-encoded args). Useful for provenance checks on observed +// transactions and for tests. +func DecodeValidationRequestCalldata(data []byte) (ValidationRequestCall, error) { + parsed, err := validationABI() + if err != nil { + return ValidationRequestCall{}, err + } + values, err := unpackCalldata(parsed, "validationRequest", data) + if err != nil { + return ValidationRequestCall{}, err + } + if len(values) != 4 { + return ValidationRequestCall{}, fmt.Errorf("erc8004: validationRequest arg count = %d, want 4", len(values)) + } + + out := ValidationRequestCall{} + var ok bool + if out.ValidatorAddress, ok = values[0].(common.Address); !ok { + return ValidationRequestCall{}, fmt.Errorf("erc8004: validatorAddress type = %T", values[0]) + } + if out.AgentID, ok = values[1].(*big.Int); !ok { + return ValidationRequestCall{}, fmt.Errorf("erc8004: agentId type = %T", values[1]) + } + if out.RequestURI, ok = values[2].(string); !ok { + return ValidationRequestCall{}, fmt.Errorf("erc8004: requestURI type = %T", values[2]) + } + hash, ok := values[3].([32]byte) + if !ok { + return ValidationRequestCall{}, fmt.Errorf("erc8004: requestHash type = %T", values[3]) + } + out.RequestHash = common.Hash(hash) + return out, nil +} + +// ValidationResponseCall is the decoded argument set of a validationResponse call. +type ValidationResponseCall struct { + RequestHash common.Hash + Response uint8 + ResponseURI string + ResponseHash common.Hash + Tag string +} + +// DecodeValidationResponseCalldata decodes validationResponse calldata +// (selector + ABI-encoded args). Useful for provenance checks on observed +// evaluator transactions and for tests. +func DecodeValidationResponseCalldata(data []byte) (ValidationResponseCall, error) { + parsed, err := validationABI() + if err != nil { + return ValidationResponseCall{}, err + } + values, err := unpackCalldata(parsed, "validationResponse", data) + if err != nil { + return ValidationResponseCall{}, err + } + if len(values) != 5 { + return ValidationResponseCall{}, fmt.Errorf("erc8004: validationResponse arg count = %d, want 5", len(values)) + } + + out := ValidationResponseCall{} + reqHash, ok := values[0].([32]byte) + if !ok { + return ValidationResponseCall{}, fmt.Errorf("erc8004: requestHash type = %T", values[0]) + } + out.RequestHash = common.Hash(reqHash) + if out.Response, ok = values[1].(uint8); !ok { + return ValidationResponseCall{}, fmt.Errorf("erc8004: response type = %T", values[1]) + } + if out.ResponseURI, ok = values[2].(string); !ok { + return ValidationResponseCall{}, fmt.Errorf("erc8004: responseURI type = %T", values[2]) + } + respHash, ok := values[3].([32]byte) + if !ok { + return ValidationResponseCall{}, fmt.Errorf("erc8004: responseHash type = %T", values[3]) + } + out.ResponseHash = common.Hash(respHash) + if out.Tag, ok = values[4].(string); !ok { + return ValidationResponseCall{}, fmt.Errorf("erc8004: tag type = %T", values[4]) + } + return out, nil +} + +// ValidationStatus mirrors getValidationStatus(bytes32) return values. +type ValidationStatus struct { + ValidatorAddress common.Address + AgentID *big.Int + Response uint8 + ResponseHash common.Hash + Tag string + LastUpdate *big.Int +} + +// ValidationReader provides read-only access to a Validation Registry. The +// controller uses it to observe evaluator responses; it holds no signer. +type ValidationReader struct { + contract *bind.BoundContract +} + +// NewValidationReader binds a read-only Validation Registry at +// registryAddress. caller is typically (*erc8004.Client).ETH() or any +// *ethclient.Client. +func NewValidationReader(caller bind.ContractCaller, registryAddress string) (*ValidationReader, error) { + if caller == nil { + return nil, fmt.Errorf("erc8004: validation reader: caller must not be nil") + } + if !common.IsHexAddress(registryAddress) { + return nil, fmt.Errorf("erc8004: validation reader: invalid registry address %q", registryAddress) + } + parsed, err := validationABI() + if err != nil { + return nil, err + } + return &ValidationReader{ + contract: bind.NewBoundContract(common.HexToAddress(registryAddress), parsed, caller, nil, nil), + }, nil +} + +// ValidationStatus reads getValidationStatus(requestHash). +func (r *ValidationReader) ValidationStatus(ctx context.Context, requestHash common.Hash) (ValidationStatus, error) { + var out []interface{} + if err := r.contract.Call(&bind.CallOpts{Context: ctx}, &out, "getValidationStatus", requestHash); err != nil { + return ValidationStatus{}, fmt.Errorf("erc8004: getValidationStatus: %w", err) + } + if len(out) != 6 { + return ValidationStatus{}, fmt.Errorf("erc8004: getValidationStatus returned %d values, want 6", len(out)) + } + + status := ValidationStatus{} + var ok bool + if status.ValidatorAddress, ok = out[0].(common.Address); !ok { + return ValidationStatus{}, fmt.Errorf("erc8004: getValidationStatus validatorAddress type = %T", out[0]) + } + if status.AgentID, ok = out[1].(*big.Int); !ok { + return ValidationStatus{}, fmt.Errorf("erc8004: getValidationStatus agentId type = %T", out[1]) + } + if status.Response, ok = out[2].(uint8); !ok { + return ValidationStatus{}, fmt.Errorf("erc8004: getValidationStatus response type = %T", out[2]) + } + respHash, ok := out[3].([32]byte) + if !ok { + return ValidationStatus{}, fmt.Errorf("erc8004: getValidationStatus responseHash type = %T", out[3]) + } + status.ResponseHash = common.Hash(respHash) + if status.Tag, ok = out[4].(string); !ok { + return ValidationStatus{}, fmt.Errorf("erc8004: getValidationStatus tag type = %T", out[4]) + } + if status.LastUpdate, ok = out[5].(*big.Int); !ok { + return ValidationStatus{}, fmt.Errorf("erc8004: getValidationStatus lastUpdate type = %T", out[5]) + } + return status, nil +} + +// Summary reads getSummary(agentId, validatorAddresses, tag) and returns the +// response count and 0-100 average. +func (r *ValidationReader) Summary(ctx context.Context, agentID *big.Int, validatorAddresses []common.Address, tag string) (count uint64, avgResponse uint8, err error) { + if err := checkAgentID(agentID); err != nil { + return 0, 0, err + } + if validatorAddresses == nil { + validatorAddresses = []common.Address{} + } + var out []interface{} + if err := r.contract.Call(&bind.CallOpts{Context: ctx}, &out, "getSummary", agentID, validatorAddresses, tag); err != nil { + return 0, 0, fmt.Errorf("erc8004: validation getSummary: %w", err) + } + if len(out) != 2 { + return 0, 0, fmt.Errorf("erc8004: validation getSummary returned %d values, want 2", len(out)) + } + count, ok := out[0].(uint64) + if !ok { + return 0, 0, fmt.Errorf("erc8004: validation getSummary count type = %T", out[0]) + } + avgResponse, ok = out[1].(uint8) + if !ok { + return 0, 0, fmt.Errorf("erc8004: validation getSummary avgResponse type = %T", out[1]) + } + return count, avgResponse, nil +} + +// AgentValidations reads getAgentValidations(agentId) — all request hashes +// recorded for the agent. +func (r *ValidationReader) AgentValidations(ctx context.Context, agentID *big.Int) ([]common.Hash, error) { + if err := checkAgentID(agentID); err != nil { + return nil, err + } + var out []interface{} + if err := r.contract.Call(&bind.CallOpts{Context: ctx}, &out, "getAgentValidations", agentID); err != nil { + return nil, fmt.Errorf("erc8004: getAgentValidations: %w", err) + } + if len(out) != 1 { + return nil, fmt.Errorf("erc8004: getAgentValidations returned %d values, want 1", len(out)) + } + raw, ok := out[0].([][32]byte) + if !ok { + return nil, fmt.Errorf("erc8004: getAgentValidations type = %T", out[0]) + } + hashes := make([]common.Hash, len(raw)) + for i, h := range raw { + hashes[i] = common.Hash(h) + } + return hashes, nil +} diff --git a/internal/erc8004/validation_registry.abi.json b/internal/erc8004/validation_registry.abi.json new file mode 100644 index 00000000..a73a65bb --- /dev/null +++ b/internal/erc8004/validation_registry.abi.json @@ -0,0 +1,272 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "validatorAddress", + "type": "address" + }, + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "internalType": "string", + "name": "requestURI", + "type": "string" + }, + { + "internalType": "bytes32", + "name": "requestHash", + "type": "bytes32" + } + ], + "name": "validationRequest", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "requestHash", + "type": "bytes32" + }, + { + "internalType": "uint8", + "name": "response", + "type": "uint8" + }, + { + "internalType": "string", + "name": "responseURI", + "type": "string" + }, + { + "internalType": "bytes32", + "name": "responseHash", + "type": "bytes32" + }, + { + "internalType": "string", + "name": "tag", + "type": "string" + } + ], + "name": "validationResponse", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "requestHash", + "type": "bytes32" + } + ], + "name": "getValidationStatus", + "outputs": [ + { + "internalType": "address", + "name": "validatorAddress", + "type": "address" + }, + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "response", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "responseHash", + "type": "bytes32" + }, + { + "internalType": "string", + "name": "tag", + "type": "string" + }, + { + "internalType": "uint256", + "name": "lastUpdate", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "internalType": "address[]", + "name": "validatorAddresses", + "type": "address[]" + }, + { + "internalType": "string", + "name": "tag", + "type": "string" + } + ], + "name": "getSummary", + "outputs": [ + { + "internalType": "uint64", + "name": "count", + "type": "uint64" + }, + { + "internalType": "uint8", + "name": "avgResponse", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + } + ], + "name": "getAgentValidations", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "", + "type": "bytes32[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "validatorAddress", + "type": "address" + } + ], + "name": "getValidatorRequests", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "", + "type": "bytes32[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "getIdentityRegistry", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "validatorAddress", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "string", + "name": "requestURI", + "type": "string" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "requestHash", + "type": "bytes32" + } + ], + "name": "ValidationRequest", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "validatorAddress", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "agentId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "requestHash", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "response", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "string", + "name": "responseURI", + "type": "string" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "responseHash", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "string", + "name": "tag", + "type": "string" + } + ], + "name": "ValidationResponse", + "type": "event" + } +] diff --git a/internal/erc8004/validation_test.go b/internal/erc8004/validation_test.go new file mode 100644 index 00000000..939bbf5a --- /dev/null +++ b/internal/erc8004/validation_test.go @@ -0,0 +1,404 @@ +package erc8004 + +import ( + "context" + "encoding/hex" + "math/big" + "strings" + "testing" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// stubCaller is a bind.ContractCaller that returns canned ABI-encoded output. +// Shared by validation and reputation reader tests. Never hits the network. +type stubCaller struct { + ret []byte + err error + lastCall ethereum.CallMsg +} + +func (s *stubCaller) CodeAt(_ context.Context, _ common.Address, _ *big.Int) ([]byte, error) { + return []byte{0x01}, nil +} + +func (s *stubCaller) CallContract(_ context.Context, call ethereum.CallMsg, _ *big.Int) ([]byte, error) { + s.lastCall = call + return s.ret, s.err +} + +func TestValidationABI_Parses(t *testing.T) { + if _, err := validationABI(); err != nil { + t.Fatalf("embedded validation ABI failed to parse: %v", err) + } +} + +// TestValidationABI_SelectorGoldenValues pins the 4-byte selectors of the +// verified v2.0.0 signatures (spec: https://eips.ethereum.org/EIPS/eip-8004; +// ABI: https://github.com/erc-8004/erc-8004-contracts). Each golden value is +// cross-checked against keccak256 of the canonical signature string and the +// parsed ABI method. +func TestValidationABI_SelectorGoldenValues(t *testing.T) { + parsed, err := validationABI() + if err != nil { + t.Fatal(err) + } + + tests := []struct { + method string + sig string + selector string + }{ + {"validationRequest", "validationRequest(address,uint256,string,bytes32)", "aaf400c4"}, + {"validationResponse", "validationResponse(bytes32,uint8,string,bytes32,string)", "3d659a96"}, + {"getValidationStatus", "getValidationStatus(bytes32)", "ff2febfc"}, + {"getSummary", "getSummary(uint256,address[],string)", "1b7cabd6"}, + {"getAgentValidations", "getAgentValidations(uint256)", "8d5d0c2d"}, + {"getValidatorRequests", "getValidatorRequests(address)", "4bf3158c"}, + {"getIdentityRegistry", "getIdentityRegistry()", "bc4d861b"}, + } + + for _, tt := range tests { + t.Run(tt.method, func(t *testing.T) { + m, ok := parsed.Methods[tt.method] + if !ok { + t.Fatalf("method %q missing from parsed ABI", tt.method) + } + if m.Sig != tt.sig { + t.Errorf("signature = %q, want %q", m.Sig, tt.sig) + } + if got := hex.EncodeToString(m.ID); got != tt.selector { + t.Errorf("parsed selector = 0x%s, want 0x%s", got, tt.selector) + } + if got := hex.EncodeToString(crypto.Keccak256([]byte(tt.sig))[:4]); got != tt.selector { + t.Errorf("keccak256(%q)[:4] = 0x%s, want 0x%s", tt.sig, got, tt.selector) + } + }) + } +} + +func TestValidationABI_EventsPresent(t *testing.T) { + parsed, err := validationABI() + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"ValidationRequest", "ValidationResponse"} { + if _, ok := parsed.Events[name]; !ok { + t.Errorf("missing event %q in parsed ABI", name) + } + } +} + +func TestEncodeValidationRequest_RoundTrip(t *testing.T) { + validator := common.HexToAddress("0x1111111111111111111111111111111111111111") + agentID := big.NewInt(42) + requestURI := "https://example.org/bounty/42/request.json" + requestHash := crypto.Keccak256Hash([]byte("request payload")) + + data, err := EncodeValidationRequest(validator, agentID, requestURI, requestHash) + if err != nil { + t.Fatalf("EncodeValidationRequest: %v", err) + } + if got := hex.EncodeToString(data[:4]); got != "aaf400c4" { + t.Errorf("selector = 0x%s, want 0xaaf400c4", got) + } + + decoded, err := DecodeValidationRequestCalldata(data) + if err != nil { + t.Fatalf("DecodeValidationRequestCalldata: %v", err) + } + if decoded.ValidatorAddress != validator { + t.Errorf("validatorAddress = %s, want %s", decoded.ValidatorAddress, validator) + } + if decoded.AgentID.Cmp(agentID) != 0 { + t.Errorf("agentId = %s, want %s", decoded.AgentID, agentID) + } + if decoded.RequestURI != requestURI { + t.Errorf("requestURI = %q, want %q", decoded.RequestURI, requestURI) + } + if decoded.RequestHash != requestHash { + t.Errorf("requestHash = %s, want %s", decoded.RequestHash, requestHash) + } +} + +func TestEncodeValidationResponse_RoundTrip(t *testing.T) { + requestHash := crypto.Keccak256Hash([]byte("request payload")) + responseHash := crypto.Keccak256Hash([]byte("evaluation artifact")) + + data, err := EncodeValidationResponse(requestHash, 87, "ipfs://bafy.../eval.json", responseHash, "code-review") + if err != nil { + t.Fatalf("EncodeValidationResponse: %v", err) + } + if got := hex.EncodeToString(data[:4]); got != "3d659a96" { + t.Errorf("selector = 0x%s, want 0x3d659a96", got) + } + + decoded, err := DecodeValidationResponseCalldata(data) + if err != nil { + t.Fatalf("DecodeValidationResponseCalldata: %v", err) + } + if decoded.RequestHash != requestHash { + t.Errorf("requestHash = %s, want %s", decoded.RequestHash, requestHash) + } + if decoded.Response != 87 { + t.Errorf("response = %d, want 87", decoded.Response) + } + if decoded.ResponseURI != "ipfs://bafy.../eval.json" { + t.Errorf("responseURI = %q", decoded.ResponseURI) + } + if decoded.ResponseHash != responseHash { + t.Errorf("responseHash = %s, want %s", decoded.ResponseHash, responseHash) + } + if decoded.Tag != "code-review" { + t.Errorf("tag = %q, want %q", decoded.Tag, "code-review") + } +} + +func TestEncodeValidationResponse_OptionalFieldsZero(t *testing.T) { + requestHash := crypto.Keccak256Hash([]byte("req")) + data, err := EncodeValidationResponse(requestHash, 0, "", common.Hash{}, "") + if err != nil { + t.Fatalf("EncodeValidationResponse with zero optionals: %v", err) + } + decoded, err := DecodeValidationResponseCalldata(data) + if err != nil { + t.Fatalf("decode: %v", err) + } + if decoded.Response != 0 || decoded.ResponseURI != "" || decoded.Tag != "" || decoded.ResponseHash != (common.Hash{}) { + t.Errorf("zero optionals did not round-trip: %+v", decoded) + } +} + +func TestEncodeValidationRequest_BadInput(t *testing.T) { + validator := common.HexToAddress("0x1111111111111111111111111111111111111111") + hash := crypto.Keccak256Hash([]byte("x")) + + tests := []struct { + name string + fn func() ([]byte, error) + }{ + {"zero validator", func() ([]byte, error) { + return EncodeValidationRequest(common.Address{}, big.NewInt(1), "u", hash) + }}, + {"nil agentId", func() ([]byte, error) { + return EncodeValidationRequest(validator, nil, "u", hash) + }}, + {"negative agentId", func() ([]byte, error) { + return EncodeValidationRequest(validator, big.NewInt(-1), "u", hash) + }}, + {"zero requestHash", func() ([]byte, error) { + return EncodeValidationRequest(validator, big.NewInt(1), "u", common.Hash{}) + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := tt.fn(); err == nil { + t.Error("expected error, got nil") + } + }) + } +} + +func TestEncodeValidationResponse_BadInput(t *testing.T) { + hash := crypto.Keccak256Hash([]byte("x")) + + if _, err := EncodeValidationResponse(common.Hash{}, 50, "", common.Hash{}, ""); err == nil { + t.Error("zero requestHash: expected error, got nil") + } + if _, err := EncodeValidationResponse(hash, 101, "", common.Hash{}, ""); err == nil { + t.Error("response 101: expected error, got nil") + } + if _, err := EncodeValidationResponse(hash, MaxValidationResponse, "", common.Hash{}, ""); err != nil { + t.Errorf("response 100 should be accepted: %v", err) + } +} + +func TestDecodeValidationCalldata_Errors(t *testing.T) { + t.Run("too short", func(t *testing.T) { + if _, err := DecodeValidationResponseCalldata([]byte{0x3d, 0x65}); err == nil { + t.Error("expected error for short calldata") + } + }) + + t.Run("wrong selector", func(t *testing.T) { + // validationRequest calldata fed to the validationResponse decoder. + data, err := EncodeValidationRequest( + common.HexToAddress("0x2222222222222222222222222222222222222222"), + big.NewInt(7), "u", crypto.Keccak256Hash([]byte("y"))) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeValidationResponseCalldata(data); err == nil { + t.Error("expected selector mismatch error") + } else if !strings.Contains(err.Error(), "selector mismatch") { + t.Errorf("error = %v, want selector mismatch", err) + } + }) + + t.Run("truncated args", func(t *testing.T) { + data, err := EncodeValidationResponse(crypto.Keccak256Hash([]byte("z")), 10, "uri", common.Hash{}, "tag") + if err != nil { + t.Fatal(err) + } + if _, err := DecodeValidationResponseCalldata(data[:len(data)-40]); err == nil { + t.Error("expected error for truncated calldata") + } + }) +} + +func TestValidationRegistryAddress(t *testing.T) { + tests := []struct { + network string + want string + wantErr bool + }{ + {"base-sepolia", ValidationRegistryV2BaseSepolia, false}, + {" Base-Sepolia ", ValidationRegistryV2BaseSepolia, false}, + {"base", ValidationRegistryV2Mainnet, false}, + {"base-mainnet", ValidationRegistryV2Mainnet, false}, + {"ethereum", ValidationRegistryV2Mainnet, false}, + {"mainnet", ValidationRegistryV2Mainnet, false}, + {"solana", "", true}, + {"", "", true}, + } + for _, tt := range tests { + t.Run(tt.network, func(t *testing.T) { + got, err := ValidationRegistryAddress(tt.network) + if tt.wantErr { + if err == nil { + t.Errorf("expected error for %q, got address %s", tt.network, got) + } + return + } + if err != nil { + t.Fatalf("ValidationRegistryAddress(%q): %v", tt.network, err) + } + if got != tt.want { + t.Errorf("address = %s, want %s", got, tt.want) + } + }) + } +} + +func TestNewValidationReader_BadInput(t *testing.T) { + if _, err := NewValidationReader(nil, ValidationRegistryV2BaseSepolia); err == nil { + t.Error("nil caller: expected error") + } + if _, err := NewValidationReader(&stubCaller{}, "not-an-address"); err == nil { + t.Error("bad address: expected error") + } +} + +func TestValidationReader_ValidationStatus(t *testing.T) { + parsed, err := validationABI() + if err != nil { + t.Fatal(err) + } + + validator := common.HexToAddress("0x3333333333333333333333333333333333333333") + agentID := big.NewInt(42) + respHash := crypto.Keccak256Hash([]byte("artifact")) + lastUpdate := big.NewInt(1765432100) + + ret, err := parsed.Methods["getValidationStatus"].Outputs.Pack( + validator, agentID, uint8(91), [32]byte(respHash), "code-review", lastUpdate) + if err != nil { + t.Fatalf("pack outputs: %v", err) + } + + caller := &stubCaller{ret: ret} + reader, err := NewValidationReader(caller, ValidationRegistryV2BaseSepolia) + if err != nil { + t.Fatal(err) + } + + reqHash := crypto.Keccak256Hash([]byte("request")) + status, err := reader.ValidationStatus(context.Background(), reqHash) + if err != nil { + t.Fatalf("ValidationStatus: %v", err) + } + + if status.ValidatorAddress != validator { + t.Errorf("validatorAddress = %s, want %s", status.ValidatorAddress, validator) + } + if status.AgentID.Cmp(agentID) != 0 { + t.Errorf("agentId = %s, want %s", status.AgentID, agentID) + } + if status.Response != 91 { + t.Errorf("response = %d, want 91", status.Response) + } + if status.ResponseHash != respHash { + t.Errorf("responseHash = %s, want %s", status.ResponseHash, respHash) + } + if status.Tag != "code-review" { + t.Errorf("tag = %q, want %q", status.Tag, "code-review") + } + if status.LastUpdate.Cmp(lastUpdate) != 0 { + t.Errorf("lastUpdate = %s, want %s", status.LastUpdate, lastUpdate) + } + + // The reader must have issued a getValidationStatus(requestHash) call. + wantData, err := parsed.Pack("getValidationStatus", reqHash) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(caller.lastCall.Data) != hex.EncodeToString(wantData) { + t.Errorf("call data = 0x%x, want 0x%x", caller.lastCall.Data, wantData) + } +} + +func TestValidationReader_Summary(t *testing.T) { + parsed, err := validationABI() + if err != nil { + t.Fatal(err) + } + ret, err := parsed.Methods["getSummary"].Outputs.Pack(uint64(5), uint8(78)) + if err != nil { + t.Fatal(err) + } + + reader, err := NewValidationReader(&stubCaller{ret: ret}, ValidationRegistryV2BaseSepolia) + if err != nil { + t.Fatal(err) + } + + count, avg, err := reader.Summary(context.Background(), big.NewInt(42), nil, "") + if err != nil { + t.Fatalf("Summary: %v", err) + } + if count != 5 || avg != 78 { + t.Errorf("summary = (%d, %d), want (5, 78)", count, avg) + } + + if _, _, err := reader.Summary(context.Background(), nil, nil, ""); err == nil { + t.Error("nil agentId: expected error") + } +} + +func TestValidationReader_AgentValidations(t *testing.T) { + parsed, err := validationABI() + if err != nil { + t.Fatal(err) + } + h1 := crypto.Keccak256Hash([]byte("a")) + h2 := crypto.Keccak256Hash([]byte("b")) + ret, err := parsed.Methods["getAgentValidations"].Outputs.Pack([][32]byte{h1, h2}) + if err != nil { + t.Fatal(err) + } + + reader, err := NewValidationReader(&stubCaller{ret: ret}, ValidationRegistryV2BaseSepolia) + if err != nil { + t.Fatal(err) + } + + hashes, err := reader.AgentValidations(context.Background(), big.NewInt(42)) + if err != nil { + t.Fatalf("AgentValidations: %v", err) + } + if len(hashes) != 2 || hashes[0] != h1 || hashes[1] != h2 { + t.Errorf("hashes = %v, want [%s %s]", hashes, h1, h2) + } +} diff --git a/internal/monetizeapi/types.go b/internal/monetizeapi/types.go index 2efb439b..a3ccd798 100644 --- a/internal/monetizeapi/types.go +++ b/internal/monetizeapi/types.go @@ -1,6 +1,7 @@ package monetizeapi import ( + "crypto/md5" "fmt" "strings" "time" @@ -42,6 +43,16 @@ const ( AgentPhaseProvisioning = "Provisioning" AgentPhaseReady = "Ready" AgentPhaseFailed = "Failed" + + // SkillBundleKey is the binaryData key in a type=skill offer's bundle + // ConfigMap that holds the gzipped skill bundle bytes. + SkillBundleKey = "bundle.tar.gz" + // MaxSkillBundleBytes caps the gzipped skill bundle size. The artifact + // rides a ConfigMap (1MiB object cap) and must leave room for base64 + // expansion plus object metadata, so the cap applies to the compressed + // bytes. Enforced at the CLI before the ConfigMap is written and at + // the controller before the bundle server is published. + MaxSkillBundleBytes = 900000 ) var ( @@ -98,12 +109,20 @@ type ServiceOfferList struct { Items []ServiceOffer `json:"items"` } +// The spec-level CEL rule below mirrors the per-method payment rules: a +// type=skill offer without spec.skill is rejected at admission time, +// independent of the CLI. (Kept detached from the type's doc comment so +// it does not leak into the generated schema description.) + +// +kubebuilder:validation:XValidation:rule="self.type != 'skill' || has(self.skill)",message="spec.skill is required when type=skill" type ServiceOfferSpec struct { // Service type. 'inference' enables model management; 'http' for any HTTP // service; 'agent' references an Agent CR via spec.agent.ref and the - // controller derives upstream + model + skills from the agent's status. + // controller derives upstream + model + skills from the agent's status; + // 'skill' sells a downloadable skill bundle described by spec.skill and + // served from a controller-rendered bundle server. // +kubebuilder:default="http" - // +kubebuilder:validation:Enum=inference;fine-tuning;http;agent + // +kubebuilder:validation:Enum=inference;fine-tuning;http;agent;skill Type string `json:"type,omitempty"` // Required when type='agent'. The controller resolves spec.agent.ref to @@ -111,6 +130,14 @@ type ServiceOfferSpec struct { // and surfaces the agent's pinned model + skills in the 402 response. Agent ServiceOfferAgent `json:"agent,omitempty"` + // Required when type='skill' (enforced by the spec-level XValidation + // rule). Describes the downloadable skill bundle being sold: identity + // (name@version), integrity hash, and the ConfigMap carrying the + // artifact. The controller renders a static bundle server from this + // block and refuses to publish when the ConfigMap bytes do not match + // sha256. + Skill ServiceOfferSkill `json:"skill,omitempty"` + // LLM model metadata. Required when the upstream serves an LLM. Model ServiceOfferModel `json:"model,omitempty"` @@ -164,6 +191,43 @@ type ServiceOfferAgentRef struct { Namespace string `json:"namespace"` } +// ServiceOfferSkill is populated when Spec.Type == "skill". It pins the +// exact artifact being sold: the bundle identity (name@version), the +// sha256 of the gzipped tar bytes, and the ConfigMap — in the offer's +// namespace — whose binaryData[SkillBundleKey] holds those bytes. The +// controller verifies the hash before publishing the bundle server and +// the verifier surfaces name/version/sha256 in the 402 response's +// extra.skill block so buyers can check the download offline. +type ServiceOfferSkill struct { + // Skill name (e.g. buy-x402). Combined with Version it forms the + // skill ref @ used by ERC-8004 feedback tags. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^[a-z0-9][a-z0-9-]*$` + // +kubebuilder:validation:MaxLength=64 + Name string `json:"name"` + // Skill version (e.g. 0.1.0). + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^[A-Za-z0-9][A-Za-z0-9._-]*$` + // +kubebuilder:validation:MaxLength=64 + Version string `json:"version"` + // Lowercase hex sha256 of the gzipped bundle bytes (the exact bytes + // stored in the bundle ConfigMap and served to buyers). + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=`^[a-f0-9]{64}$` + SHA256 string `json:"sha256"` + // Name of a ConfigMap in the offer's namespace whose + // binaryData["bundle.tar.gz"] is the artifact (key: SkillBundleKey). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MaxLength=253 + BundleConfigMap string `json:"bundleConfigMap"` + // Human-friendly display name for catalog surfaces. + // +kubebuilder:validation:MaxLength=128 + DisplayName string `json:"displayName,omitempty"` + // Short human-readable description for catalog surfaces. + // +kubebuilder:validation:MaxLength=1024 + Description string `json:"description,omitempty"` +} + type ServiceOfferModel struct { // Model identifier (e.g. qwen3.5:35b). // +kubebuilder:validation:Required @@ -423,6 +487,14 @@ func (o *ServiceOffer) IsAgent() bool { return o.Spec.Type == "agent" } +// IsSkill reports whether the offer sells a downloadable skill bundle. +// Type=="skill" is the only signal — Spec.Skill must also be populated +// for a usable offer, but admission validation (the spec-level CEL rule) +// enforces that. +func (o *ServiceOffer) IsSkill() bool { + return o.Spec.Type == "skill" +} + // IsDraining reports whether spec.drainAt has been set. Drained offers // transition through three phases: pre-drain (DrainAt nil), draining // (DrainAt set, now < DrainEndsAt), and drain-expired (DrainAt set, @@ -460,6 +532,42 @@ func (o *ServiceOffer) DrainExpired(now time.Time) bool { return !now.Before(end) } +// maxK8sNameLen is the maximum length for a Kubernetes resource name +// (DNS subdomain). +const maxK8sNameLen = 253 + +// maxK8sServiceNameLen is the maximum length for a Kubernetes Service +// name (RFC 1035 label) and for label VALUES (the workload name doubles +// as the children's "app" label). +const maxK8sServiceNameLen = 63 + +// SkillBundleWorkloadName returns the deterministic name of the bundle +// server children (Deployment/Service/meta ConfigMap) rendered for a +// type=skill offer: "so--bundle". It lives in monetizeapi so the +// CLI (which pins spec.upstream.service to it), the controller (which +// renders the children and rejects spoofed upstreams), and the x402 +// route source share one definition without an import cycle. Mirrors +// serviceoffercontroller.safeName, but caps at the 63-char RFC 1035 +// Service-name/label limit (not the 253-char object-name limit — the +// name is also a Service name and an "app" label value): longer offer +// names are truncated with a short hash appended to avoid collisions. +func SkillBundleWorkloadName(offerName string) string { + const ( + prefix = "so-" + suffix = "-bundle" + ) + full := prefix + offerName + suffix + if len(full) <= maxK8sServiceNameLen { + return full + } + hash := fmt.Sprintf("%x", md5.Sum([]byte(offerName)))[:8] + maxName := maxK8sServiceNameLen - len(prefix) - len(suffix) - 1 - len(hash) // 1 for the dash before hash + if maxName < 1 { + maxName = 1 + } + return prefix + offerName[:maxName] + "-" + hash + suffix +} + // ── PurchaseRequest ───────────────────────────────────────────────────────── // +kubebuilder:object:root=true diff --git a/internal/monetizeapi/zz_generated.deepcopy.go b/internal/monetizeapi/zz_generated.deepcopy.go index 3c0207f3..97d20b96 100644 --- a/internal/monetizeapi/zz_generated.deepcopy.go +++ b/internal/monetizeapi/zz_generated.deepcopy.go @@ -706,10 +706,26 @@ func (in *ServiceOfferService) DeepCopy() *ServiceOfferService { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceOfferSkill) DeepCopyInto(out *ServiceOfferSkill) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceOfferSkill. +func (in *ServiceOfferSkill) DeepCopy() *ServiceOfferSkill { + if in == nil { + return nil + } + out := new(ServiceOfferSkill) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceOfferSpec) DeepCopyInto(out *ServiceOfferSpec) { *out = *in out.Agent = in.Agent + out.Skill = in.Skill out.Model = in.Model out.Upstream = in.Upstream out.Payment = in.Payment diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index be6b7cfe..1ac39c2f 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -455,6 +455,42 @@ func (c *Controller) reconcileOffer(ctx context.Context, key string) error { } } + if offer.IsSkill() { + ok, skillErr := c.reconcileSkillBundle(ctx, &status, offer) + if skillErr != nil { + return skillErr + } + if !ok { + // reconcileSkillBundle already set UpstreamHealthy=False with a + // specific reason (BundleMissing / BundleTooLarge / + // BundleHashMismatch / InvalidSkillUpstream / ...). Mirror the + // WaitingForAgent early return: park the downstream gates, commit + // status, refresh the catalog, and poll — no informer watches the + // operator's bundle ConfigMap, so a later kubectl apply of the + // bundle would otherwise never re-enqueue this offer. + setCondition(&status, "ModelReady", "True", "Skipped", "Skill offer does not require model preparation") + if offer.DrainExpired(time.Now()) { + if err := c.deleteRouteChildren(ctx, offer); err != nil { + return err + } + setCondition(&status, "Draining", "False", "Drained", fmt.Sprintf("Drain ended at %s; route torn down", offer.DrainEndsAt().UTC().Format(time.RFC3339))) + setCondition(&status, "PaymentGateReady", "False", "Drained", "Offer drained; payment gate removed") + setCondition(&status, "RoutePublished", "False", "Drained", "Offer drained; route removed") + } else { + setCondition(&status, "PaymentGateReady", "False", "WaitingForUpstream", "Waiting for a valid skill bundle before publishing payment gate") + setCondition(&status, "RoutePublished", "False", "WaitingForPaymentGate", "Waiting for payment gate before publishing route") + } + setCondition(&status, "Ready", "False", "Reconciling", "Offer is not fully reconciled yet") + if err := c.updateOfferStatus(ctx, raw, status); err != nil { + return err + } + c.offerQueue.AddAfter(offer.Namespace+"/"+offer.Name, 5*time.Second) + freshOffer := *offer + freshOffer.Status = status + return c.reconcileSkillCatalog(ctx, &freshOffer) + } + } + if err := c.reconcileModel(&status, offer); err != nil { return err } diff --git a/internal/serviceoffercontroller/skill.go b/internal/serviceoffercontroller/skill.go new file mode 100644 index 00000000..0bb9d938 --- /dev/null +++ b/internal/serviceoffercontroller/skill.go @@ -0,0 +1,120 @@ +package serviceoffercontroller + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// reconcileSkillBundle validates a type=skill offer's bundle ConfigMap and, +// when the artifact checks out, renders the bundle-server children +// (meta ConfigMap + Deployment + Service) in the offer's namespace. +// +// Returns ok=true when the children were applied and the rest of the +// condition ladder (reconcileUpstream → PaymentGateReady → RoutePublished +// → Registered → Ready) should proceed unchanged. Returns ok=false with a +// nil error when the offer is not yet publishable; in that case status +// already carries UpstreamHealthy=False with one of the specific reasons: +// +// - InvalidSkillSpec — required spec.skill fields missing (defense +// in depth behind the CRD's CEL rule) +// - InvalidSkillUpstream — spec.upstream does not point at the +// controller-rendered bundle server. Anti-spoof: a skill offer may +// only ever advertise its own bundle server, so the sha256 surfaced +// in the 402 extra can never describe a different upstream. +// - BundleMissing — bundle ConfigMap or its binaryData key absent +// - BundleInvalid — binaryData is not decodable base64 +// - BundleTooLarge — compressed bytes exceed MaxSkillBundleBytes +// - BundleHashMismatch — sha256 of the bytes != spec.skill.sha256 +// +// Errors are only returned for transient API failures (the caller's +// rate-limited requeue handles those). +func (c *Controller) reconcileSkillBundle(ctx context.Context, status *monetizeapi.ServiceOfferStatus, offer *monetizeapi.ServiceOffer) (bool, error) { + skill := offer.Spec.Skill + if skill.Name == "" || skill.Version == "" || skill.SHA256 == "" || skill.BundleConfigMap == "" { + setCondition(status, "UpstreamHealthy", "False", "InvalidSkillSpec", + "type=skill offer requires spec.skill.name, .version, .sha256 and .bundleConfigMap") + return false, nil + } + + workload := monetizeapi.SkillBundleWorkloadName(offer.Name) + if offer.Spec.Upstream.Service != workload || + offer.EffectiveNamespace() != offer.Namespace || + offer.EffectivePort() != skillBundlePort { + setCondition(status, "UpstreamHealthy", "False", "InvalidSkillUpstream", + fmt.Sprintf("type=skill offers must use the controller-rendered bundle server %s.%s:%d as upstream", workload, offer.Namespace, skillBundlePort)) + return false, nil + } + + raw, err := c.configMaps.Namespace(offer.Namespace).Get(ctx, skill.BundleConfigMap, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + setCondition(status, "UpstreamHealthy", "False", "BundleMissing", + fmt.Sprintf("bundle ConfigMap %s/%s not found", offer.Namespace, skill.BundleConfigMap)) + return false, nil + } + if err != nil { + return false, err + } + + encoded, found, err := unstructured.NestedString(raw.Object, "binaryData", monetizeapi.SkillBundleKey) + if err != nil || !found || encoded == "" { + setCondition(status, "UpstreamHealthy", "False", "BundleMissing", + fmt.Sprintf("bundle ConfigMap %s/%s has no binaryData[%q]", offer.Namespace, skill.BundleConfigMap, monetizeapi.SkillBundleKey)) + return false, nil + } + + bundle, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + setCondition(status, "UpstreamHealthy", "False", "BundleInvalid", + fmt.Sprintf("bundle ConfigMap %s/%s binaryData[%q] is not valid base64: %v", offer.Namespace, skill.BundleConfigMap, monetizeapi.SkillBundleKey, err)) + return false, nil + } + + if len(bundle) > monetizeapi.MaxSkillBundleBytes { + setCondition(status, "UpstreamHealthy", "False", "BundleTooLarge", + fmt.Sprintf("bundle is %d bytes; the cap is %d bytes of compressed artifact", len(bundle), monetizeapi.MaxSkillBundleBytes)) + return false, nil + } + + sum := sha256.Sum256(bundle) + got := hex.EncodeToString(sum[:]) + if !strings.EqualFold(got, skill.SHA256) { + setCondition(status, "UpstreamHealthy", "False", "BundleHashMismatch", + fmt.Sprintf("bundle sha256 %s does not match spec.skill.sha256 %s", got, strings.ToLower(skill.SHA256))) + return false, nil + } + + meta, err := buildSkillBundleMetaConfigMap(offer) + if err != nil { + return false, err + } + for _, child := range []*unstructured.Unstructured{ + meta, + buildSkillBundleDeployment(offer), + buildSkillBundleService(offer), + } { + // applyAgentObject (get-or-create-or-update) rather than the SSA + // applyObject so the same code path is exercised by the fake + // dynamic client in unit tests — see the rationale on + // applyAgentObject. All three kinds are mutable (not in + // isCreateOnlyKind), so re-reconciles pick up rendered changes. + if err := c.applyAgentObject(ctx, c.resourceFor(child), child); err != nil { + setCondition(status, "UpstreamHealthy", "False", "ApplyFailed", err.Error()) + return false, err + } + } + + // Children applied. The actual UpstreamHealthy verdict is owned by the + // shared reconcileUpstream, which health-checks the bundle Service at + // spec.upstream (http://so--bundle..svc:8080/skill.json), so + // the gate only opens once the httpd pod really serves the artifact. + return true, nil +} diff --git a/internal/serviceoffercontroller/skill_render.go b/internal/serviceoffercontroller/skill_render.go new file mode 100644 index 00000000..9a4a6869 --- /dev/null +++ b/internal/serviceoffercontroller/skill_render.go @@ -0,0 +1,226 @@ +package serviceoffercontroller + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// skillBundlePort is the fixed port the controller-rendered skill bundle +// server listens on. The CLI pins spec.upstream.port to this value and +// reconcileSkillBundle rejects anything else (anti-spoof guard — see the +// InvalidSkillUpstream branch). +const skillBundlePort = int64(8080) + +// skillBundleHTTPDConf maps the two file extensions the bundle server +// serves to their MIME types (busybox httpd /etc/httpd.conf format). +const skillBundleHTTPDConf = ".tar.gz:application/gzip\n.json:application/json\n" + +// skillBundleMetaName returns the name of the controller-rendered metadata +// ConfigMap (skill.json + httpd.conf) that sits next to the operator's +// bundle ConfigMap. Equals SkillBundleWorkloadName(offerName)+"-meta" for +// every name that fits the 253-char DNS-subdomain limit; pathological +// names go through the shared safeName truncate+hash fallback instead of +// blindly appending past the limit. +func skillBundleMetaName(offerName string) string { + return safeName("so-", offerName, "-bundle-meta") +} + +// skillBundleLabels is the shared label set for the bundle server children +// (Deployment selector/template, Service selector, meta ConfigMap). Same +// shape as agentIdentityLabels / the skill catalog labels. +func skillBundleLabels(offer *monetizeapi.ServiceOffer) map[string]any { + return map[string]any{ + "app": monetizeapi.SkillBundleWorkloadName(offer.Name), + "obol.org/managed-by": "serviceoffer-controller", + } +} + +// skillBundleDocument is the machine-readable descriptor served at +// /skill.json next to the artifact. It doubles as the upstream health +// check target (the CLI pins spec.upstream.healthPath to /skill.json), so +// UpstreamHealthy only goes True once the bundle server actually serves +// the descriptor for the validated bundle. +type skillBundleDocument struct { + Name string `json:"name"` + Version string `json:"version"` + SHA256 string `json:"sha256"` + DisplayName string `json:"displayName,omitempty"` + Description string `json:"description,omitempty"` + Offer string `json:"offer"` + Namespace string `json:"namespace"` +} + +func buildSkillBundleJSON(offer *monetizeapi.ServiceOffer) (string, error) { + document := skillBundleDocument{ + Name: offer.Spec.Skill.Name, + Version: offer.Spec.Skill.Version, + SHA256: strings.ToLower(offer.Spec.Skill.SHA256), + DisplayName: offer.Spec.Skill.DisplayName, + Description: offer.Spec.Skill.Description, + Offer: offer.Name, + Namespace: offer.Namespace, + } + data, err := json.MarshalIndent(document, "", " ") + if err != nil { + return "", fmt.Errorf("marshal skill.json for %s/%s: %w", offer.Namespace, offer.Name, err) + } + return string(data), nil +} + +// buildSkillBundleMetaConfigMap renders the controller-owned metadata +// ConfigMap mounted into the bundle server: skill.json (descriptor + +// health target) and httpd.conf (MIME map). Owner-referenced to the offer +// so GC removes it when the offer is deleted. +func buildSkillBundleMetaConfigMap(offer *monetizeapi.ServiceOffer) (*unstructured.Unstructured, error) { + skillJSON, err := buildSkillBundleJSON(offer) + if err != nil { + return nil, err + } + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": skillBundleMetaName(offer.Name), + "namespace": offer.Namespace, + "ownerReferences": []any{ownerRefMap(offer)}, + "labels": skillBundleLabels(offer), + }, + "data": map[string]any{ + "skill.json": skillJSON, + "httpd.conf": skillBundleHTTPDConf, + }, + }, + }, nil +} + +// buildSkillBundleDeployment renders the static bundle server: a busybox +// httpd serving /www/bundle.tar.gz (projected from the operator's bundle +// ConfigMap) and /www/skill.json (projected from the meta ConfigMap). +// Restricted-PSS securityContext copied from the skill catalog / +// agentidentity httpd pattern — the same admission profile applies to any +// namespace that enforces Restricted PSS, and there is no reason for a +// static file server to run with more privilege. +// +// The pod template carries obol.org/content-hash = spec.skill.sha256[:8] +// so re-publishing a bundle (new hash) rolls the pod even though the +// Deployment spec is otherwise unchanged. +func buildSkillBundleDeployment(offer *monetizeapi.ServiceOffer) *unstructured.Unstructured { + name := monetizeapi.SkillBundleWorkloadName(offer.Name) + labels := skillBundleLabels(offer) + contentHash := strings.ToLower(offer.Spec.Skill.SHA256) + if len(contentHash) > 8 { + contentHash = contentHash[:8] + } + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]any{ + "name": name, + "namespace": offer.Namespace, + "ownerReferences": []any{ownerRefMap(offer)}, + "labels": labels, + }, + "spec": map[string]any{ + "replicas": int64(1), + "selector": map[string]any{ + "matchLabels": labels, + }, + "template": map[string]any{ + "metadata": map[string]any{ + "labels": labels, + "annotations": map[string]any{ + "obol.org/content-hash": contentHash, + }, + }, + "spec": map[string]any{ + "securityContext": restrictedPodSecurityContext(), + "containers": []any{ + map[string]any{ + "name": "httpd", + "image": "busybox:1.36", + "command": []any{"httpd", "-f", "-p", "8080", "-h", "/www"}, + "securityContext": restrictedContainerSecurityContext(), + "ports": []any{ + map[string]any{"containerPort": skillBundlePort, "protocol": "TCP"}, + }, + "volumeMounts": []any{ + map[string]any{"name": "content", "mountPath": "/www", "readOnly": true}, + map[string]any{"name": "httpdconf", "mountPath": "/etc/httpd.conf", "subPath": "httpd.conf", "readOnly": true}, + }, + "resources": map[string]any{ + "requests": map[string]any{"cpu": "5m", "memory": "8Mi"}, + "limits": map[string]any{"cpu": "50m", "memory": "32Mi"}, + }, + }, + }, + "volumes": []any{ + // Single projected volume so both ConfigMaps land in + // the same /www docroot (two configMap volumes cannot + // share a mountPath). + map[string]any{ + "name": "content", + "projected": map[string]any{ + "sources": []any{ + map[string]any{ + "configMap": map[string]any{ + "name": offer.Spec.Skill.BundleConfigMap, + "items": []any{map[string]any{"key": monetizeapi.SkillBundleKey, "path": monetizeapi.SkillBundleKey}}, + }, + }, + map[string]any{ + "configMap": map[string]any{ + "name": skillBundleMetaName(offer.Name), + "items": []any{map[string]any{"key": "skill.json", "path": "skill.json"}}, + }, + }, + }, + }, + }, + map[string]any{ + "name": "httpdconf", + "configMap": map[string]any{ + "name": skillBundleMetaName(offer.Name), + "items": []any{map[string]any{"key": "httpd.conf", "path": "httpd.conf"}}, + }, + }, + }, + }, + }, + }, + }, + } +} + +// buildSkillBundleService renders the ClusterIP Service in front of the +// bundle server. Its name is the deterministic upstream the CLI pins into +// spec.upstream.service, which is how the existing reconcileUpstream and +// routeRuleFromOffer paths work unchanged for type=skill offers. +func buildSkillBundleService(offer *monetizeapi.ServiceOffer) *unstructured.Unstructured { + name := monetizeapi.SkillBundleWorkloadName(offer.Name) + labels := skillBundleLabels(offer) + return &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]any{ + "name": name, + "namespace": offer.Namespace, + "ownerReferences": []any{ownerRefMap(offer)}, + "labels": labels, + }, + "spec": map[string]any{ + "type": "ClusterIP", + "selector": labels, + "ports": []any{ + map[string]any{"port": skillBundlePort, "targetPort": skillBundlePort, "protocol": "TCP"}, + }, + }, + }, + } +} diff --git a/internal/serviceoffercontroller/skill_render_test.go b/internal/serviceoffercontroller/skill_render_test.go new file mode 100644 index 00000000..a400e07a --- /dev/null +++ b/internal/serviceoffercontroller/skill_render_test.go @@ -0,0 +1,233 @@ +package serviceoffercontroller + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestBuildSkillBundleDeployment_RestrictedPSS(t *testing.T) { + offer := skillTestOffer(nil) + dep := buildSkillBundleDeployment(offer) + + podSpec, found, err := unstructured.NestedMap(dep.Object, "spec", "template", "spec") + if err != nil || !found { + t.Fatalf("pod spec missing: found=%v err=%v", found, err) + } + + // Pod-level Restricted PSS — same assertions as the skill catalog / + // agentidentity httpd renders. + sc, ok := podSpec["securityContext"].(map[string]any) + if !ok { + t.Fatal("pod securityContext missing") + } + if sc["runAsNonRoot"] != true { + t.Errorf("runAsNonRoot = %v, want true", sc["runAsNonRoot"]) + } + if sc["runAsUser"] != int64(1000) || sc["runAsGroup"] != int64(1000) || sc["fsGroup"] != int64(1000) { + t.Errorf("uid/gid/fsGroup = %v/%v/%v, want 1000", sc["runAsUser"], sc["runAsGroup"], sc["fsGroup"]) + } + seccomp, _ := sc["seccompProfile"].(map[string]any) + if seccomp == nil || seccomp["type"] != "RuntimeDefault" { + t.Errorf("seccompProfile = %v, want RuntimeDefault", sc["seccompProfile"]) + } + + containers, _ := podSpec["containers"].([]any) + if len(containers) != 1 { + t.Fatalf("containers = %d, want 1", len(containers)) + } + container := containers[0].(map[string]any) + if container["image"] != "busybox:1.36" { + t.Errorf("image = %v, want busybox:1.36", container["image"]) + } + + csc, ok := container["securityContext"].(map[string]any) + if !ok { + t.Fatal("container securityContext missing") + } + if csc["allowPrivilegeEscalation"] != false { + t.Errorf("allowPrivilegeEscalation = %v, want false", csc["allowPrivilegeEscalation"]) + } + caps, _ := csc["capabilities"].(map[string]any) + drop, _ := caps["drop"].([]any) + if len(drop) != 1 || drop[0] != "ALL" { + t.Errorf("capabilities.drop = %v, want [ALL]", drop) + } + + command, _ := container["command"].([]any) + wantCommand := []any{"httpd", "-f", "-p", "8080", "-h", "/www"} + if len(command) != len(wantCommand) { + t.Fatalf("command = %v, want %v", command, wantCommand) + } + for i := range wantCommand { + if command[i] != wantCommand[i] { + t.Errorf("command[%d] = %v, want %v", i, command[i], wantCommand[i]) + } + } + + resources, _ := container["resources"].(map[string]any) + requests, _ := resources["requests"].(map[string]any) + limits, _ := resources["limits"].(map[string]any) + if requests["cpu"] != "5m" || requests["memory"] != "8Mi" { + t.Errorf("requests = %v, want 5m/8Mi", requests) + } + if limits["cpu"] != "50m" || limits["memory"] != "32Mi" { + t.Errorf("limits = %v, want 50m/32Mi", limits) + } +} + +func TestBuildSkillBundleDeployment_VolumesWireBothConfigMaps(t *testing.T) { + offer := skillTestOffer(nil) + dep := buildSkillBundleDeployment(offer) + + volumes, found, err := unstructured.NestedSlice(dep.Object, "spec", "template", "spec", "volumes") + if err != nil || !found || len(volumes) != 2 { + t.Fatalf("volumes = %v (found=%v err=%v), want 2 entries", volumes, found, err) + } + + content := volumes[0].(map[string]any) + if content["name"] != "content" { + t.Fatalf("volumes[0] = %v, want content", content["name"]) + } + projected, _ := content["projected"].(map[string]any) + sources, _ := projected["sources"].([]any) + if len(sources) != 2 { + t.Fatalf("projected sources = %d, want 2 (bundle CM + meta CM)", len(sources)) + } + bundleCM := sources[0].(map[string]any)["configMap"].(map[string]any) + if bundleCM["name"] != offer.Spec.Skill.BundleConfigMap { + t.Errorf("bundle source CM = %v, want %s", bundleCM["name"], offer.Spec.Skill.BundleConfigMap) + } + bundleItems, _ := bundleCM["items"].([]any) + if len(bundleItems) != 1 { + t.Fatalf("bundle items = %v", bundleItems) + } + item := bundleItems[0].(map[string]any) + if item["key"] != monetizeapi.SkillBundleKey || item["path"] != monetizeapi.SkillBundleKey { + t.Errorf("bundle item = %v, want %s→%s", item, monetizeapi.SkillBundleKey, monetizeapi.SkillBundleKey) + } + metaCM := sources[1].(map[string]any)["configMap"].(map[string]any) + if metaCM["name"] != skillBundleMetaName(offer.Name) { + t.Errorf("meta source CM = %v, want %s", metaCM["name"], skillBundleMetaName(offer.Name)) + } + + httpdconf := volumes[1].(map[string]any) + if httpdconf["name"] != "httpdconf" { + t.Fatalf("volumes[1] = %v, want httpdconf", httpdconf["name"]) + } + + mounts, _, _ := unstructured.NestedSlice(dep.Object, "spec", "template", "spec", "containers") + container := mounts[0].(map[string]any) + volumeMounts, _ := container["volumeMounts"].([]any) + var sawContent, sawConf bool + for _, vm := range volumeMounts { + m := vm.(map[string]any) + switch m["name"] { + case "content": + sawContent = m["mountPath"] == "/www" && m["readOnly"] == true + case "httpdconf": + sawConf = m["mountPath"] == "/etc/httpd.conf" && m["subPath"] == "httpd.conf" + } + } + if !sawContent { + t.Error("content volume must be mounted read-only at /www") + } + if !sawConf { + t.Error("httpd.conf must be subPath-mounted at /etc/httpd.conf") + } +} + +func TestBuildSkillBundleService_SelectorMatchesDeploymentLabels(t *testing.T) { + offer := skillTestOffer(nil) + svc := buildSkillBundleService(offer) + dep := buildSkillBundleDeployment(offer) + + selector, _, _ := unstructured.NestedMap(svc.Object, "spec", "selector") + podLabels, _, _ := unstructured.NestedMap(dep.Object, "spec", "template", "metadata", "labels") + if len(selector) == 0 || len(selector) != len(podLabels) { + t.Fatalf("selector = %v, pod labels = %v", selector, podLabels) + } + for k, v := range selector { + if podLabels[k] != v { + t.Errorf("selector[%s] = %v, pod label = %v", k, v, podLabels[k]) + } + } + + if svc.GetName() != monetizeapi.SkillBundleWorkloadName(offer.Name) { + t.Errorf("service name = %q, want %q", svc.GetName(), monetizeapi.SkillBundleWorkloadName(offer.Name)) + } + ports, _, _ := unstructured.NestedSlice(svc.Object, "spec", "ports") + if len(ports) != 1 { + t.Fatalf("ports = %v", ports) + } + port := ports[0].(map[string]any) + if port["port"] != int64(8080) || port["targetPort"] != int64(8080) { + t.Errorf("port = %v, want 8080→8080", port) + } + if svcType, _, _ := unstructured.NestedString(svc.Object, "spec", "type"); svcType != "ClusterIP" { + t.Errorf("service type = %q, want ClusterIP", svcType) + } +} + +func TestBuildSkillBundleMetaConfigMap_Content(t *testing.T) { + offer := skillTestOffer(nil) + cm, err := buildSkillBundleMetaConfigMap(offer) + if err != nil { + t.Fatalf("buildSkillBundleMetaConfigMap: %v", err) + } + + if cm.GetName() != skillBundleMetaName(offer.Name) { + t.Errorf("name = %q, want %q", cm.GetName(), skillBundleMetaName(offer.Name)) + } + if cm.GetNamespace() != offer.Namespace { + t.Errorf("namespace = %q, want %q", cm.GetNamespace(), offer.Namespace) + } + owners := cm.GetOwnerReferences() + if len(owners) != 1 || owners[0].Kind != monetizeapi.ServiceOfferKind || owners[0].Name != offer.Name { + t.Errorf("ownerReferences = %+v, want single ServiceOffer/%s owner", owners, offer.Name) + } + + httpdConf, _, _ := unstructured.NestedString(cm.Object, "data", "httpd.conf") + if !strings.Contains(httpdConf, ".tar.gz:application/gzip") || !strings.Contains(httpdConf, ".json:application/json") { + t.Errorf("httpd.conf = %q, want gzip + json MIME entries", httpdConf) + } + + skillJSON, _, _ := unstructured.NestedString(cm.Object, "data", "skill.json") + var doc map[string]any + if err := json.Unmarshal([]byte(skillJSON), &doc); err != nil { + t.Fatalf("skill.json is not valid JSON: %v\n%s", err, skillJSON) + } + wants := map[string]string{ + "name": "buy-x402", + "version": "0.1.0", + "sha256": skillTestBundleHash(), + "displayName": "Buy x402", + "offer": offer.Name, + "namespace": offer.Namespace, + } + for key, want := range wants { + if doc[key] != want { + t.Errorf("skill.json[%s] = %v, want %q", key, doc[key], want) + } + } +} + +func TestSkillBundleMetaName_RespectsK8sNameLimit(t *testing.T) { + long := strings.Repeat("a", 300) + if wn := monetizeapi.SkillBundleWorkloadName(long); len(wn) > 63 { + t.Errorf("workload name length = %d, want <= 63 (Service name / app label limit)", len(wn)) + } + name := skillBundleMetaName(long) + if len(name) > 253 { + t.Errorf("meta name length = %d, want <= 253", len(name)) + } + if name != skillBundleMetaName(long) { + t.Error("meta name must be deterministic") + } + if short := skillBundleMetaName("buy-x402"); short != monetizeapi.SkillBundleWorkloadName("buy-x402")+"-meta" { + t.Errorf("short names must equal SkillBundleWorkloadName+\"-meta\", got %q", short) + } +} diff --git a/internal/serviceoffercontroller/skill_test.go b/internal/serviceoffercontroller/skill_test.go new file mode 100644 index 00000000..38a5458b --- /dev/null +++ b/internal/serviceoffercontroller/skill_test.go @@ -0,0 +1,352 @@ +package serviceoffercontroller + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic/fake" +) + +// newSkillTestController builds a Controller wired with the GVRs that +// reconcileSkillBundle touches, backed by the fake dynamic client (same +// harness style as newProvisioningTestController). +func newSkillTestController(t *testing.T, seedObjects ...*unstructured.Unstructured) *Controller { + t.Helper() + + objects := make([]runtime.Object, 0, len(seedObjects)) + for _, o := range seedObjects { + objects = append(objects, o) + } + + dynClient := fake.NewSimpleDynamicClientWithCustomListKinds( + runtime.NewScheme(), + map[schema.GroupVersionResource]string{ + monetizeapi.ConfigMapGVR: "ConfigMapList", + monetizeapi.ServiceGVR: "ServiceList", + monetizeapi.DeploymentGVR: "DeploymentList", + }, + objects..., + ) + + return &Controller{ + dynClient: dynClient, + client: dynClient, + services: dynClient.Resource(monetizeapi.ServiceGVR), + configMaps: dynClient.Resource(monetizeapi.ConfigMapGVR), + deployments: dynClient.Resource(monetizeapi.DeploymentGVR), + } +} + +// skillTestBundle is a stand-in for gzipped tar bytes; reconcileSkillBundle +// only hashes and measures them, it never unpacks. +var skillTestBundle = []byte("fake-gzipped-skill-bundle-bytes") + +func skillTestBundleHash() string { + sum := sha256.Sum256(skillTestBundle) + return hex.EncodeToString(sum[:]) +} + +// skillTestOffer returns a well-formed type=skill offer whose upstream is +// pinned to the controller-rendered bundle server, exactly as the CLI +// writes it. mutate lets each table case break one thing. +func skillTestOffer(mutate func(*monetizeapi.ServiceOffer)) *monetizeapi.ServiceOffer { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "buy-x402", + Namespace: "hermes-obol-agent", + UID: types.UID("offer-uid-1"), + }, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "skill", + Skill: monetizeapi.ServiceOfferSkill{ + Name: "buy-x402", + Version: "0.1.0", + SHA256: skillTestBundleHash(), + BundleConfigMap: "buy-x402-skill-bundle", + DisplayName: "Buy x402", + Description: "Pre-sign x402 payment authorizations", + }, + Upstream: monetizeapi.ServiceOfferUpstream{ + Service: monetizeapi.SkillBundleWorkloadName("buy-x402"), + Namespace: "hermes-obol-agent", + Port: 8080, + HealthPath: "/skill.json", + }, + Payment: monetizeapi.ServiceOfferPayment{ + PayTo: "0x1111111111111111111111111111111111111111", + Network: "base-sepolia", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.01"}, + }, + }, + } + if mutate != nil { + mutate(offer) + } + return offer +} + +// bundleConfigMapObject renders the operator-supplied bundle ConfigMap the +// way the apiserver stores it: binaryData values base64-encoded. +func bundleConfigMapObject(namespace, name string, payload []byte) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": name, + "namespace": namespace, + }, + "binaryData": map[string]any{ + monetizeapi.SkillBundleKey: base64.StdEncoding.EncodeToString(payload), + }, + }} +} + +func conditionByType(status monetizeapi.ServiceOfferStatus, conditionType string) *monetizeapi.Condition { + for i := range status.Conditions { + if status.Conditions[i].Type == conditionType { + return &status.Conditions[i] + } + } + return nil +} + +func TestReconcileSkillBundle_FailureTable(t *testing.T) { + oversize := make([]byte, monetizeapi.MaxSkillBundleBytes+1) + + cases := []struct { + name string + mutate func(*monetizeapi.ServiceOffer) + seed []*unstructured.Unstructured + wantReason string + }{ + { + name: "missing bundle ConfigMap", + seed: nil, + wantReason: "BundleMissing", + }, + { + name: "ConfigMap without binaryData key", + seed: []*unstructured.Unstructured{{Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{ + "name": "buy-x402-skill-bundle", + "namespace": "hermes-obol-agent", + }, + "data": map[string]any{"unrelated": "value"}, + }}}, + wantReason: "BundleMissing", + }, + { + name: "bundle exceeds MaxSkillBundleBytes", + seed: []*unstructured.Unstructured{ + bundleConfigMapObject("hermes-obol-agent", "buy-x402-skill-bundle", oversize), + }, + wantReason: "BundleTooLarge", + }, + { + name: "sha256 mismatch", + mutate: func(o *monetizeapi.ServiceOffer) { + o.Spec.Skill.SHA256 = strings.Repeat("ab", 32) + }, + seed: []*unstructured.Unstructured{ + bundleConfigMapObject("hermes-obol-agent", "buy-x402-skill-bundle", skillTestBundle), + }, + wantReason: "BundleHashMismatch", + }, + { + name: "spoofed upstream service", + mutate: func(o *monetizeapi.ServiceOffer) { + o.Spec.Upstream.Service = "litellm" + }, + seed: []*unstructured.Unstructured{ + bundleConfigMapObject("hermes-obol-agent", "buy-x402-skill-bundle", skillTestBundle), + }, + wantReason: "InvalidSkillUpstream", + }, + { + name: "spoofed upstream namespace", + mutate: func(o *monetizeapi.ServiceOffer) { + o.Spec.Upstream.Namespace = "llm" + }, + seed: []*unstructured.Unstructured{ + bundleConfigMapObject("hermes-obol-agent", "buy-x402-skill-bundle", skillTestBundle), + }, + wantReason: "InvalidSkillUpstream", + }, + { + name: "spoofed upstream port", + mutate: func(o *monetizeapi.ServiceOffer) { + o.Spec.Upstream.Port = 4000 + }, + seed: []*unstructured.Unstructured{ + bundleConfigMapObject("hermes-obol-agent", "buy-x402-skill-bundle", skillTestBundle), + }, + wantReason: "InvalidSkillUpstream", + }, + { + name: "missing required skill fields", + mutate: func(o *monetizeapi.ServiceOffer) { + o.Spec.Skill.SHA256 = "" + }, + wantReason: "InvalidSkillSpec", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := newSkillTestController(t, tc.seed...) + offer := skillTestOffer(tc.mutate) + status := monetizeapi.ServiceOfferStatus{} + + ok, err := c.reconcileSkillBundle(context.Background(), &status, offer) + if err != nil { + t.Fatalf("reconcileSkillBundle: %v", err) + } + if ok { + t.Fatal("ok = true, want false") + } + + cond := conditionByType(status, "UpstreamHealthy") + if cond == nil { + t.Fatalf("UpstreamHealthy condition not set: %+v", status.Conditions) + } + if cond.Status != "False" { + t.Errorf("UpstreamHealthy = %q, want False", cond.Status) + } + if cond.Reason != tc.wantReason { + t.Errorf("UpstreamHealthy reason = %q, want %q (message: %s)", cond.Reason, tc.wantReason, cond.Message) + } + + // No children may be published when validation fails. + workload := monetizeapi.SkillBundleWorkloadName(offer.Name) + if resourceExists(t, c, "deployments", offer.Namespace, workload) { + t.Error("bundle Deployment must not be created on a failed validation") + } + if resourceExists(t, c, "services", offer.Namespace, workload) { + t.Error("bundle Service must not be created on a failed validation") + } + if resourceExists(t, c, "configmaps", offer.Namespace, skillBundleMetaName(offer.Name)) { + t.Error("meta ConfigMap must not be created on a failed validation") + } + }) + } +} + +func TestReconcileSkillBundle_HappyPathAppliesChildren(t *testing.T) { + c := newSkillTestController(t, + bundleConfigMapObject("hermes-obol-agent", "buy-x402-skill-bundle", skillTestBundle), + ) + offer := skillTestOffer(nil) + status := monetizeapi.ServiceOfferStatus{} + + ok, err := c.reconcileSkillBundle(context.Background(), &status, offer) + if err != nil { + t.Fatalf("reconcileSkillBundle: %v", err) + } + if !ok { + t.Fatalf("ok = false, want true; conditions: %+v", status.Conditions) + } + + // reconcileSkillBundle must NOT claim UpstreamHealthy itself — the + // shared reconcileUpstream health check owns that verdict. + if cond := conditionByType(status, "UpstreamHealthy"); cond != nil { + t.Errorf("UpstreamHealthy should be left to reconcileUpstream, got %+v", cond) + } + + workload := monetizeapi.SkillBundleWorkloadName(offer.Name) + ctx := context.Background() + + dep, err := c.deployments.Namespace(offer.Namespace).Get(ctx, workload, metav1.GetOptions{}) + if err != nil { + t.Fatalf("bundle Deployment missing: %v", err) + } + owners := dep.GetOwnerReferences() + if len(owners) != 1 || owners[0].Kind != monetizeapi.ServiceOfferKind || owners[0].Name != offer.Name { + t.Errorf("Deployment ownerReferences = %+v, want single ServiceOffer/%s owner", owners, offer.Name) + } + hash, _, _ := unstructured.NestedString(dep.Object, "spec", "template", "metadata", "annotations", "obol.org/content-hash") + if want := skillTestBundleHash()[:8]; hash != want { + t.Errorf("content-hash annotation = %q, want %q", hash, want) + } + + if _, err := c.services.Namespace(offer.Namespace).Get(ctx, workload, metav1.GetOptions{}); err != nil { + t.Fatalf("bundle Service missing: %v", err) + } + meta, err := c.configMaps.Namespace(offer.Namespace).Get(ctx, skillBundleMetaName(offer.Name), metav1.GetOptions{}) + if err != nil { + t.Fatalf("meta ConfigMap missing: %v", err) + } + skillJSON, _, _ := unstructured.NestedString(meta.Object, "data", "skill.json") + for _, want := range []string{`"name": "buy-x402"`, `"version": "0.1.0"`, skillTestBundleHash()} { + if !strings.Contains(skillJSON, want) { + t.Errorf("skill.json missing %q:\n%s", want, skillJSON) + } + } +} + +func TestReconcileSkillBundle_HashCompareIsCaseInsensitive(t *testing.T) { + c := newSkillTestController(t, + bundleConfigMapObject("hermes-obol-agent", "buy-x402-skill-bundle", skillTestBundle), + ) + offer := skillTestOffer(func(o *monetizeapi.ServiceOffer) { + o.Spec.Skill.SHA256 = strings.ToUpper(skillTestBundleHash()) + }) + status := monetizeapi.ServiceOfferStatus{} + + ok, err := c.reconcileSkillBundle(context.Background(), &status, offer) + if err != nil { + t.Fatalf("reconcileSkillBundle: %v", err) + } + if !ok { + t.Fatalf("uppercase spec hash must still match (CRD enforces lowercase, controller stays lenient); conditions: %+v", status.Conditions) + } +} + +func TestReconcileSkillBundle_RepublishedBundleRollsContentHash(t *testing.T) { + c := newSkillTestController(t, + bundleConfigMapObject("hermes-obol-agent", "buy-x402-skill-bundle", skillTestBundle), + ) + offer := skillTestOffer(nil) + ctx := context.Background() + + status := monetizeapi.ServiceOfferStatus{} + if ok, err := c.reconcileSkillBundle(ctx, &status, offer); err != nil || !ok { + t.Fatalf("first reconcile: ok=%v err=%v", ok, err) + } + + // Operator re-publishes a new bundle: CM bytes + spec hash both move. + newBundle := []byte("v2-bundle-bytes") + newSum := sha256.Sum256(newBundle) + newHash := hex.EncodeToString(newSum[:]) + if _, err := c.configMaps.Namespace(offer.Namespace).Update(ctx, + bundleConfigMapObject(offer.Namespace, "buy-x402-skill-bundle", newBundle), metav1.UpdateOptions{}); err != nil { + t.Fatalf("update bundle CM: %v", err) + } + offer.Spec.Skill.SHA256 = newHash + offer.Spec.Skill.Version = "0.2.0" + + status = monetizeapi.ServiceOfferStatus{} + if ok, err := c.reconcileSkillBundle(ctx, &status, offer); err != nil || !ok { + t.Fatalf("second reconcile: ok=%v err=%v conditions=%+v", ok, err, status.Conditions) + } + + dep, err := c.deployments.Namespace(offer.Namespace).Get(ctx, monetizeapi.SkillBundleWorkloadName(offer.Name), metav1.GetOptions{}) + if err != nil { + t.Fatalf("bundle Deployment missing after re-publish: %v", err) + } + hash, _, _ := unstructured.NestedString(dep.Object, "spec", "template", "metadata", "annotations", "obol.org/content-hash") + if want := newHash[:8]; hash != want { + t.Errorf("content-hash after re-publish = %q, want %q (pod must roll)", hash, want) + } +} diff --git a/internal/skillpkg/bundle.go b/internal/skillpkg/bundle.go new file mode 100644 index 00000000..c7a443a0 --- /dev/null +++ b/internal/skillpkg/bundle.go @@ -0,0 +1,241 @@ +// Package skillpkg packages a skill directory (SKILL.md + scripts, the +// same shape as internal/embed/skills/*) into a byte-for-byte +// deterministic gzipped tarball so the sha256 of the artifact is a +// stable identity for the skill content. The hash is what `obol sell +// skill` pins into the ServiceOffer spec and what `obol skills calldata +// set-hash` anchors on the ERC-8004 Identity Registry, so two packs of +// the same content MUST produce identical bytes regardless of file +// mtimes, ownership, umask, or on-disk creation order. +package skillpkg + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io/fs" + "path" + "sort" + "strings" + "time" +) + +const ( + // MaxBundleBytes caps the gzipped bundle size. It mirrors + // monetizeapi.MaxSkillBundleBytes (asserted equal in tests): the + // artifact rides a ConfigMap (1MiB object cap) and must leave room + // for base64 expansion plus object metadata, so the cap applies to + // the compressed bytes. Pack enforces it so no caller can persist + // an artifact the controller would refuse to publish. + MaxBundleBytes = 900000 + + // ManifestName is the required top-level file. A skill bundle + // without SKILL.md is not a skill. + ManifestName = "SKILL.md" +) + +// entry is one path collected from the source tree, pre-sorted and +// pre-classified so the tar emission loop is trivially deterministic. +type entry struct { + path string // slash-separated, relative to root + dir bool + exec bool // any exec bit set on the source file +} + +// Pack walks root, packs every regular file and directory into a +// deterministic USTAR tar wrapped in a deterministic gzip stream, and +// returns the compressed bytes plus their lowercase hex sha256. +// +// Determinism rules: +// - entries sorted lexicographically by slash-separated path +// - file modes normalized to 0644 (0755 when any source exec bit is +// set); directory modes normalized to 0755 +// - ModTime fixed to the Unix epoch; uid/gid 0; uname/gname cleared +// - gzip header carries no name, zero mtime, and OS byte 255 +// +// Symlinks and irregular files are rejected (a bundle must be fully +// self-contained and portable); __pycache__ directories and *.pyc files +// are skipped, mirroring embed.WriteSkillSubset. The gzipped result is +// rejected when it exceeds MaxBundleBytes. +func Pack(root fs.FS) ([]byte, string, error) { + entries, err := collectEntries(root) + if err != nil { + return nil, "", err + } + + if !hasTopLevelManifest(entries) { + return nil, "", fmt.Errorf("skillpkg: bundle root must contain %s — a skill bundle without %s is not a skill", ManifestName, ManifestName) + } + + var buf bytes.Buffer + zw, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) + if err != nil { + return nil, "", fmt.Errorf("skillpkg: gzip writer: %w", err) + } + // Deterministic gzip header: no original name, zero mtime (written + // as 0), and an explicit "unknown" OS byte so the output does not + // vary across platforms or Go releases. + zw.Header.Name = "" + zw.Header.ModTime = time.Time{} + zw.Header.OS = 255 + + tw := tar.NewWriter(zw) + for _, e := range entries { + if e.dir { + if err := tw.WriteHeader(dirHeader(e.path)); err != nil { + return nil, "", fmt.Errorf("skillpkg: write dir header %s: %w", e.path, err) + } + continue + } + data, err := fs.ReadFile(root, e.path) + if err != nil { + return nil, "", fmt.Errorf("skillpkg: read %s: %w", e.path, err) + } + if err := tw.WriteHeader(fileHeader(e.path, int64(len(data)), e.exec)); err != nil { + return nil, "", fmt.Errorf("skillpkg: write file header %s: %w", e.path, err) + } + if _, err := tw.Write(data); err != nil { + return nil, "", fmt.Errorf("skillpkg: write %s: %w", e.path, err) + } + } + if err := tw.Close(); err != nil { + return nil, "", fmt.Errorf("skillpkg: close tar: %w", err) + } + if err := zw.Close(); err != nil { + return nil, "", fmt.Errorf("skillpkg: close gzip: %w", err) + } + + gz := buf.Bytes() + if len(gz) > MaxBundleBytes { + return nil, "", fmt.Errorf("skillpkg: gzipped bundle is %d bytes, which exceeds the %d-byte skill bundle cap (the artifact must fit in a ConfigMap) — trim large assets from the skill directory", len(gz), MaxBundleBytes) + } + + sum := sha256.Sum256(gz) + return gz, hex.EncodeToString(sum[:]), nil +} + +// ScanSecrets walks root with the same entry rules as Pack and returns +// one human-readable warning per entry that looks like it carries +// secret material: .env-style files, id_rsa* key files, and any file +// whose content carries a PEM "PRIVATE KEY" marker. Warn-only by +// contract — callers print the warnings and proceed; a skill author may +// legitimately ship an .env.example. +func ScanSecrets(root fs.FS) ([]string, error) { + entries, err := collectEntries(root) + if err != nil { + return nil, err + } + + var warnings []string + for _, e := range entries { + if e.dir { + continue + } + base := path.Base(e.path) + switch { + case base == ".env" || strings.HasPrefix(base, ".env."): + warnings = append(warnings, fmt.Sprintf("%s: looks like an environment file — it will be published to every buyer", e.path)) + case strings.HasPrefix(base, "id_rsa"): + warnings = append(warnings, fmt.Sprintf("%s: looks like an SSH key file — it will be published to every buyer", e.path)) + } + data, err := fs.ReadFile(root, e.path) + if err != nil { + return nil, fmt.Errorf("skillpkg: read %s: %w", e.path, err) + } + if bytes.Contains(data, []byte("PRIVATE KEY")) { + warnings = append(warnings, fmt.Sprintf("%s: contains a PEM \"PRIVATE KEY\" marker — it will be published to every buyer", e.path)) + } + } + return warnings, nil +} + +// collectEntries walks root and returns the full, lexicographically +// sorted entry list. Symlinks and other irregular files error out; +// __pycache__ dirs and *.pyc files are skipped (they are interpreter +// artifacts that vary per machine and would break hash determinism). +func collectEntries(root fs.FS) ([]entry, error) { + var entries []entry + err := fs.WalkDir(root, ".", func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if p == "." { + return nil + } + if d.IsDir() { + if d.Name() == "__pycache__" { + return fs.SkipDir + } + entries = append(entries, entry{path: p, dir: true}) + return nil + } + if !d.Type().IsRegular() { + return fmt.Errorf("skillpkg: unsupported entry %q (%s): symlinks and special files cannot be packed into a skill bundle", p, d.Type()) + } + if strings.HasSuffix(d.Name(), ".pyc") { + return nil + } + info, err := d.Info() + if err != nil { + return fmt.Errorf("skillpkg: stat %s: %w", p, err) + } + entries = append(entries, entry{path: p, exec: info.Mode().Perm()&0o111 != 0}) + return nil + }) + if err != nil { + return nil, err + } + + // One sorted order for everything. Parents naturally precede their + // children ("a" < "a/b"), so extraction order is always valid. + sort.Slice(entries, func(i, j int) bool { return entries[i].path < entries[j].path }) + return entries, nil +} + +func hasTopLevelManifest(entries []entry) bool { + for _, e := range entries { + if !e.dir && e.path == ManifestName { + return true + } + } + return false +} + +// dirHeader builds the normalized tar header for a directory entry. +func dirHeader(p string) *tar.Header { + hdr := baseHeader(p + "/") + hdr.Typeflag = tar.TypeDir + hdr.Mode = 0o755 + return hdr +} + +// fileHeader builds the normalized tar header for a regular file. +func fileHeader(p string, size int64, exec bool) *tar.Header { + hdr := baseHeader(p) + hdr.Typeflag = tar.TypeReg + hdr.Size = size + hdr.Mode = 0o644 + if exec { + hdr.Mode = 0o755 + } + return hdr +} + +// baseHeader carries every normalized field shared by files and dirs: +// USTAR format, epoch mtime, zero atime/ctime, uid/gid 0, cleared +// uname/gname, forward-slash relative name. +func baseHeader(name string) *tar.Header { + return &tar.Header{ + Name: name, + Format: tar.FormatUSTAR, + ModTime: time.Unix(0, 0), + AccessTime: time.Time{}, + ChangeTime: time.Time{}, + Uid: 0, + Gid: 0, + Uname: "", + Gname: "", + } +} diff --git a/internal/skillpkg/bundle_test.go b/internal/skillpkg/bundle_test.go new file mode 100644 index 00000000..b1fcf386 --- /dev/null +++ b/internal/skillpkg/bundle_test.go @@ -0,0 +1,396 @@ +package skillpkg + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "math/rand" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" +) + +// skillFS builds a minimal valid skill tree as a MapFS. mtime/sys +// fields are parameterized so tests can prove they don't leak into the +// hash. +func skillFS(modTime time.Time) fstest.MapFS { + return fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("# my-skill\n"), Mode: 0o644, ModTime: modTime}, + "scripts/run.py": &fstest.MapFile{Data: []byte("print('hi')\n"), Mode: 0o755, ModTime: modTime}, + "references/ref.md": &fstest.MapFile{Data: []byte("ref\n"), Mode: 0o600, ModTime: modTime}, + } +} + +func TestMaxBundleBytes_MatchesMonetizeAPI(t *testing.T) { + if MaxBundleBytes != monetizeapi.MaxSkillBundleBytes { + t.Fatalf("skillpkg.MaxBundleBytes = %d, monetizeapi.MaxSkillBundleBytes = %d — these caps must agree", + MaxBundleBytes, monetizeapi.MaxSkillBundleBytes) + } +} + +func TestPack_Deterministic(t *testing.T) { + fsys := skillFS(time.Unix(1700000000, 0)) + + gz1, hash1, err := Pack(fsys) + if err != nil { + t.Fatalf("first pack: %v", err) + } + gz2, hash2, err := Pack(fsys) + if err != nil { + t.Fatalf("second pack: %v", err) + } + + if !bytes.Equal(gz1, gz2) { + t.Error("two packs of the same FS produced different bytes") + } + if hash1 != hash2 { + t.Errorf("two packs of the same FS produced different hashes: %s vs %s", hash1, hash2) + } + if len(hash1) != 64 || strings.ToLower(hash1) != hash1 { + t.Errorf("hash %q is not 64-char lowercase hex", hash1) + } +} + +// TestPack_MetadataIndependence proves on-disk metadata (mtimes, sys +// info, source modes that normalize to the same class) does not change +// the artifact hash. +func TestPack_MetadataIndependence(t *testing.T) { + tests := []struct { + name string + a, b fstest.MapFS + }{ + { + name: "different mtimes", + a: skillFS(time.Unix(0, 0)), + b: skillFS(time.Now()), + }, + { + name: "different owner-ish sys info", + a: fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("x"), Mode: 0o644, Sys: &struct{ UID int }{1000}}, + }, + b: fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("x"), Mode: 0o644, Sys: &struct{ UID int }{0}}, + }, + }, + { + name: "modes within the same normalization class", + a: fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("x"), Mode: 0o644}, + "run.sh": &fstest.MapFile{Data: []byte("y"), Mode: 0o755}, + }, + b: fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("x"), Mode: 0o600}, + "run.sh": &fstest.MapFile{Data: []byte("y"), Mode: 0o700}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gzA, hashA, err := Pack(tt.a) + if err != nil { + t.Fatalf("pack a: %v", err) + } + gzB, hashB, err := Pack(tt.b) + if err != nil { + t.Fatalf("pack b: %v", err) + } + if hashA != hashB { + t.Errorf("hashes differ: %s vs %s", hashA, hashB) + } + if !bytes.Equal(gzA, gzB) { + t.Error("bytes differ for metadata-only variation") + } + }) + } +} + +// TestPack_CreationOrderIndependence writes the same content into two +// real directories in opposite creation order (and with different +// mtimes) and proves the hashes match. This is the on-disk analog of +// the MapFS determinism tests. +func TestPack_CreationOrderIndependence(t *testing.T) { + files := map[string]string{ + "SKILL.md": "# skill\n", + "scripts/a.py": "a\n", + "scripts/b.py": "b\n", + "references.txt": "r\n", + } + + writeAll := func(t *testing.T, order []string) string { + t.Helper() + dir := t.TempDir() + for _, rel := range order { + p := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(files[rel]), 0o644); err != nil { + t.Fatal(err) + } + // Scatter mtimes so this also covers epoch normalization on + // a real filesystem. + mt := time.Now().Add(-time.Duration(len(rel)) * time.Hour) + if err := os.Chtimes(p, mt, mt); err != nil { + t.Fatal(err) + } + } + return dir + } + + dirA := writeAll(t, []string{"SKILL.md", "scripts/a.py", "scripts/b.py", "references.txt"}) + dirB := writeAll(t, []string{"references.txt", "scripts/b.py", "scripts/a.py", "SKILL.md"}) + + _, hashA, err := Pack(os.DirFS(dirA)) + if err != nil { + t.Fatalf("pack a: %v", err) + } + _, hashB, err := Pack(os.DirFS(dirB)) + if err != nil { + t.Fatalf("pack b: %v", err) + } + if hashA != hashB { + t.Errorf("creation order changed the hash: %s vs %s", hashA, hashB) + } +} + +func TestPack_Errors(t *testing.T) { + tests := []struct { + name string + fsys fstest.MapFS + wantSub string + }{ + { + name: "symlink rejected", + fsys: fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("x"), Mode: 0o644}, + "link": &fstest.MapFile{Mode: 0o644 | os.ModeSymlink}, + }, + wantSub: "symlinks and special files", + }, + { + name: "missing SKILL.md", + fsys: fstest.MapFS{ + "scripts/run.py": &fstest.MapFile{Data: []byte("x"), Mode: 0o644}, + }, + wantSub: "must contain SKILL.md", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := Pack(tt.fsys) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantSub) { + t.Errorf("error %q does not contain %q", err, tt.wantSub) + } + }) + } +} + +func TestPack_RejectsOversizeAfterGzip(t *testing.T) { + // Incompressible (random) payload comfortably above the cap so the + // post-gzip size still exceeds MaxBundleBytes. + big := make([]byte, MaxBundleBytes+200000) + rnd := rand.New(rand.NewSource(42)) //nolint:gosec // determinism wanted, not security + rnd.Read(big) + + fsys := fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("x"), Mode: 0o644}, + "blob.bin": &fstest.MapFile{Data: big, Mode: 0o644}, + } + + _, _, err := Pack(fsys) + if err == nil { + t.Fatal("expected oversize error, got nil") + } + if !strings.Contains(err.Error(), "900000-byte") { + t.Errorf("oversize error should name the cap, got: %v", err) + } +} + +func TestPack_SkipsPythonArtifacts(t *testing.T) { + fsys := fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("x"), Mode: 0o644}, + "scripts/run.py": &fstest.MapFile{Data: []byte("y"), Mode: 0o644}, + "scripts/run.pyc": &fstest.MapFile{Data: []byte("z"), Mode: 0o644}, + "scripts/__pycache__/run.cpython-312.pyc": &fstest.MapFile{Data: []byte("z"), Mode: 0o644}, + } + + gz, _, err := Pack(fsys) + if err != nil { + t.Fatal(err) + } + + names := tarEntryNames(t, gz) + for _, n := range names { + if strings.Contains(n, "pyc") || strings.Contains(n, "__pycache__") { + t.Errorf("python artifact leaked into bundle: %s", n) + } + } +} + +// TestPack_NormalizesHeaders cracks the artifact open and verifies the +// determinism-relevant tar header fields entry by entry. +func TestPack_NormalizesHeaders(t *testing.T) { + fsys := fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("doc"), Mode: 0o600, ModTime: time.Now()}, + "scripts/run.sh": &fstest.MapFile{Data: []byte("#!/bin/sh\n"), Mode: 0o700, ModTime: time.Now()}, + } + + gz, _, err := Pack(fsys) + if err != nil { + t.Fatal(err) + } + + zr, err := gzip.NewReader(bytes.NewReader(gz)) + if err != nil { + t.Fatal(err) + } + if zr.Header.Name != "" { + t.Errorf("gzip header name = %q, want empty", zr.Header.Name) + } + if zr.Header.OS != 255 { + t.Errorf("gzip header OS = %d, want 255", zr.Header.OS) + } + + wantModes := map[string]int64{ + "SKILL.md": 0o644, + "scripts/": 0o755, + "scripts/run.sh": 0o755, // exec bit on source promotes to 0755 + } + tr := tar.NewReader(zr) + var got []string + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + got = append(got, hdr.Name) + if want, ok := wantModes[hdr.Name]; ok && hdr.Mode != want { + t.Errorf("%s mode = %o, want %o", hdr.Name, hdr.Mode, want) + } + if !hdr.ModTime.Equal(time.Unix(0, 0)) { + t.Errorf("%s mtime = %v, want epoch", hdr.Name, hdr.ModTime) + } + if hdr.Uid != 0 || hdr.Gid != 0 || hdr.Uname != "" || hdr.Gname != "" { + t.Errorf("%s ownership not cleared: uid=%d gid=%d uname=%q gname=%q", hdr.Name, hdr.Uid, hdr.Gid, hdr.Uname, hdr.Gname) + } + } + + want := []string{"SKILL.md", "scripts/", "scripts/run.sh"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("entry order = %v, want %v", got, want) + } +} + +func TestScanSecrets(t *testing.T) { + tests := []struct { + name string + fsys fstest.MapFS + wantCount int + wantSub string + }{ + { + name: "clean skill", + fsys: fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("doc"), Mode: 0o644}, + "scripts/run.py": &fstest.MapFile{Data: []byte("print(1)"), Mode: 0o644}, + }, + wantCount: 0, + }, + { + name: "dotenv file", + fsys: fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("doc"), Mode: 0o644}, + ".env": &fstest.MapFile{Data: []byte("API_KEY=x"), Mode: 0o644}, + }, + wantCount: 1, + wantSub: "environment file", + }, + { + name: "dotenv variant", + fsys: fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("doc"), Mode: 0o644}, + ".env.locals": &fstest.MapFile{Data: []byte("API_KEY=x"), Mode: 0o644}, + }, + wantCount: 1, + wantSub: "environment file", + }, + { + name: "ssh key name", + fsys: fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("doc"), Mode: 0o644}, + "keys/id_rsa": &fstest.MapFile{Data: []byte("whatever"), Mode: 0o600}, + }, + wantCount: 1, + wantSub: "SSH key", + }, + { + name: "pem marker in content", + fsys: fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("doc"), Mode: 0o644}, + "creds.txt": &fstest.MapFile{Data: []byte("-----BEGIN EC PRIVATE KEY-----\nabc\n"), Mode: 0o644}, + }, + wantCount: 1, + wantSub: "PRIVATE KEY", + }, + { + name: "key file with pem content warns for both", + fsys: fstest.MapFS{ + "SKILL.md": &fstest.MapFile{Data: []byte("doc"), Mode: 0o644}, + "id_rsa": &fstest.MapFile{Data: []byte("-----BEGIN OPENSSH PRIVATE KEY-----\n"), Mode: 0o600}, + }, + wantCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + warnings, err := ScanSecrets(tt.fsys) + if err != nil { + t.Fatal(err) + } + if len(warnings) != tt.wantCount { + t.Fatalf("got %d warnings %v, want %d", len(warnings), warnings, tt.wantCount) + } + if tt.wantSub != "" && !strings.Contains(strings.Join(warnings, "\n"), tt.wantSub) { + t.Errorf("warnings %v do not mention %q", warnings, tt.wantSub) + } + }) + } +} + +// tarEntryNames decompresses and lists tar entry names. +func tarEntryNames(t *testing.T, gz []byte) []string { + t.Helper() + zr, err := gzip.NewReader(bytes.NewReader(gz)) + if err != nil { + t.Fatal(err) + } + tr := tar.NewReader(zr) + var names []string + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + names = append(names, hdr.Name) + } + return names +} diff --git a/internal/x402/config.go b/internal/x402/config.go index a878ac48..ba728832 100644 --- a/internal/x402/config.go +++ b/internal/x402/config.go @@ -117,6 +117,20 @@ type RouteRule struct { // Surfaced as `accepts[].extra.agentRuntime`. AgentRuntime string `yaml:"agentRuntime,omitempty"` + // SkillName is the skill bundle identifier for type=skill offers. + // Surfaced as `accepts[].extra.skill.name` in the 402 response so + // buyers see which artifact they are paying to download. + SkillName string `yaml:"skillName,omitempty"` + + // SkillVersion is the skill bundle version (e.g. "0.1.0"). Surfaced + // as `accepts[].extra.skill.version`. + SkillVersion string `yaml:"skillVersion,omitempty"` + + // SkillSHA256 is the lowercase hex sha256 of the gzipped bundle bytes. + // Surfaced as `accepts[].extra.skill.sha256` so buyers can verify the + // downloaded artifact offline against what the 402 advertised. + SkillSHA256 string `yaml:"skillSha256,omitempty"` + // OfferType records the originating ServiceOffer.spec.type // (inference, http, agent, fine-tuning). The HTML 402 renderer uses // this to pick type-appropriate copy and Buy CTAs. diff --git a/internal/x402/serviceoffer_source.go b/internal/x402/serviceoffer_source.go index 2983f5ba..664354ba 100644 --- a/internal/x402/serviceoffer_source.go +++ b/internal/x402/serviceoffer_source.go @@ -191,6 +191,17 @@ func routeRuleFromOffer(offer *monetizeapi.ServiceOffer, upstreamAuth string) (R rule.Model = offer.Spec.Model.Name } + // Skill offers advertise the bundle identity + integrity hash so the + // 402 response carries extra.skill (mirrors the agent extras above). + // Upstream URL/auth need no special-casing: spec.upstream points at + // the controller-rendered bundle server and effectiveUpstreamAuth + // returns "" for non-litellm services. + if offer.IsSkill() { + rule.SkillName = offer.Spec.Skill.Name + rule.SkillVersion = offer.Spec.Skill.Version + rule.SkillSHA256 = strings.ToLower(offer.Spec.Skill.SHA256) + } + return rule, nil } diff --git a/internal/x402/serviceoffer_source_skill_test.go b/internal/x402/serviceoffer_source_skill_test.go new file mode 100644 index 00000000..8bdae8df --- /dev/null +++ b/internal/x402/serviceoffer_source_skill_test.go @@ -0,0 +1,119 @@ +package x402 + +import ( + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func skillSourceTestOffer() monetizeapi.ServiceOffer { + return monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "buy-x402", Namespace: "hermes-obol-agent"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "skill", + Skill: monetizeapi.ServiceOfferSkill{ + Name: "buy-x402", + Version: "0.1.0", + SHA256: strings.Repeat("0a", 32), + BundleConfigMap: "buy-x402-skill-bundle", + }, + Upstream: monetizeapi.ServiceOfferUpstream{ + Service: monetizeapi.SkillBundleWorkloadName("buy-x402"), + Namespace: "hermes-obol-agent", + Port: 8080, + HealthPath: "/skill.json", + }, + Payment: monetizeapi.ServiceOfferPayment{ + PayTo: "0x1111111111111111111111111111111111111111", + Network: "base-sepolia", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.01"}, + }, + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{{Type: "RoutePublished", Status: "True"}}, + }, + } +} + +func TestRouteRuleFromOffer_SkillPopulatesSkillFields(t *testing.T) { + offer := skillSourceTestOffer() + + rule, err := routeRuleFromOffer(&offer, "") + if err != nil { + t.Fatalf("routeRuleFromOffer: %v", err) + } + + if rule.SkillName != "buy-x402" { + t.Errorf("SkillName = %q, want buy-x402", rule.SkillName) + } + if rule.SkillVersion != "0.1.0" { + t.Errorf("SkillVersion = %q, want 0.1.0", rule.SkillVersion) + } + if rule.SkillSHA256 != strings.Repeat("0a", 32) { + t.Errorf("SkillSHA256 = %q", rule.SkillSHA256) + } + if rule.OfferType != "skill" { + t.Errorf("OfferType = %q, want skill", rule.OfferType) + } + + // The upstream URL must be the controller-rendered bundle server, + // derived from spec.upstream with no skill-specific synthesis. + wantURL := "http://so-buy-x402-bundle.hermes-obol-agent.svc.cluster.local:8080" + if rule.UpstreamURL != wantURL { + t.Errorf("UpstreamURL = %q, want %q", rule.UpstreamURL, wantURL) + } + if rule.Pattern != "/services/buy-x402/*" { + t.Errorf("Pattern = %q, want /services/buy-x402/*", rule.Pattern) + } +} + +func TestRouteRuleFromOffer_SkillUppercaseHashNormalizedToLower(t *testing.T) { + offer := skillSourceTestOffer() + offer.Spec.Skill.SHA256 = strings.ToUpper(strings.Repeat("0a", 32)) + + rule, err := routeRuleFromOffer(&offer, "") + if err != nil { + t.Fatalf("routeRuleFromOffer: %v", err) + } + if rule.SkillSHA256 != strings.Repeat("0a", 32) { + t.Errorf("SkillSHA256 = %q, want lowercase", rule.SkillSHA256) + } +} + +func TestRouteRuleFromOffer_SkillUpstreamAuthStaysEmpty(t *testing.T) { + // Even if a litellm master key exists for the namespace, the bundle + // server is a static file host — no Authorization header may be + // injected (effectiveUpstreamAuth only injects for litellm/agent). + offer := skillSourceTestOffer() + + rule, err := routeRuleFromOffer(&offer, "Bearer should-not-leak") + if err != nil { + t.Fatalf("routeRuleFromOffer: %v", err) + } + if rule.UpstreamAuth != "" { + t.Errorf("UpstreamAuth = %q, want empty for skill bundle upstream", rule.UpstreamAuth) + } +} + +func TestRouteRuleFromOffer_NonSkillOffersCarryNoSkillFields(t *testing.T) { + offer := monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "plain", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "http", + Upstream: monetizeapi.ServiceOfferUpstream{Service: "httpbin", Namespace: "llm", Port: 8080}, + Payment: monetizeapi.ServiceOfferPayment{ + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.01"}, + }, + }, + } + + rule, err := routeRuleFromOffer(&offer, "") + if err != nil { + t.Fatalf("routeRuleFromOffer: %v", err) + } + if rule.SkillName != "" || rule.SkillVersion != "" || rule.SkillSHA256 != "" { + t.Errorf("non-skill rule gained skill fields: %q %q %q", rule.SkillName, rule.SkillVersion, rule.SkillSHA256) + } +} diff --git a/internal/x402/skill_extras_test.go b/internal/x402/skill_extras_test.go new file mode 100644 index 00000000..b04d5525 --- /dev/null +++ b/internal/x402/skill_extras_test.go @@ -0,0 +1,138 @@ +package x402 + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + x402types "github.com/x402-foundation/x402/go/types" +) + +func TestMergeSkillExtras_Noop_NonSkillRule(t *testing.T) { + req := x402types.PaymentRequirements{Extra: map[string]any{"name": "USDC"}} + rule := &RouteRule{} + + mergeSkillExtras(&req, rule) + + if _, ok := req.Extra["skill"]; ok { + t.Error("non-skill rule must not add extra.skill") + } + if got := req.Extra["name"]; got != "USDC" { + t.Errorf("non-skill merge clobbered existing extra.name: %v", got) + } +} + +func TestMergeSkillExtras_AddsSkillBlock(t *testing.T) { + req := x402types.PaymentRequirements{Extra: map[string]any{}} + rule := &RouteRule{ + SkillName: "buy-x402", + SkillVersion: "0.1.0", + SkillSHA256: strings.Repeat("ab", 32), + } + + mergeSkillExtras(&req, rule) + + skill, ok := req.Extra["skill"].(map[string]any) + if !ok { + t.Fatalf("extra.skill wrong type: %T", req.Extra["skill"]) + } + if skill["name"] != "buy-x402" { + t.Errorf("skill.name = %v, want buy-x402", skill["name"]) + } + if skill["version"] != "0.1.0" { + t.Errorf("skill.version = %v, want 0.1.0", skill["version"]) + } + if skill["sha256"] != strings.Repeat("ab", 32) { + t.Errorf("skill.sha256 = %v", skill["sha256"]) + } +} + +func TestMergeSkillExtras_InitialisesNilExtra(t *testing.T) { + req := x402types.PaymentRequirements{} + rule := &RouteRule{SkillName: "buy-x402"} + + mergeSkillExtras(&req, rule) + + if req.Extra == nil { + t.Fatal("Extra not initialised") + } + skill, ok := req.Extra["skill"].(map[string]any) + if !ok || skill["name"] != "buy-x402" { + t.Errorf("extra.skill missing or malformed: %+v", req.Extra) + } + if _, ok := skill["version"]; ok { + t.Error("empty version must be omitted from extra.skill") + } + if _, ok := skill["sha256"]; ok { + t.Error("empty sha256 must be omitted from extra.skill") + } +} + +// TestVerifier_402_SkillExtra exercises the full 402 path for a type=skill +// route: a paymentless probe must surface accepts[].extra.skill = +// {name, version, sha256} in the JSON body (the wire contract buyers use +// to verify the artifact before paying), while a non-skill route must not +// gain the key. Modeled on the agent-extras coverage. +func TestVerifier_402_SkillExtra(t *testing.T) { + fac := newMockFacilitator(t, mockFacilitatorOpts{}) + sha := strings.Repeat("0a", 32) + v := newTestVerifier(t, fac.URL, []RouteRule{ + { + Pattern: "/services/buy-x402/*", + Price: "0.01", + OfferType: "skill", + SkillName: "buy-x402", + SkillVersion: "0.1.0", + SkillSHA256: sha, + }, + { + Pattern: "/services/plain-http/*", + Price: "0.01", + }, + }) + + probe402 := func(t *testing.T, uri string) map[string]any { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/verify", nil) + req.Header.Set("X-Forwarded-Uri", uri) + req.Header.Set("X-Forwarded-Host", "obol.stack") + w := httptest.NewRecorder() + v.HandleVerify(w, req) + if w.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want 402", w.Code) + } + body, _ := io.ReadAll(w.Body) + var parsed struct { + Accepts []map[string]any `json:"accepts"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("402 body is not JSON: %v\n%s", err, body) + } + if len(parsed.Accepts) != 1 { + t.Fatalf("accepts = %d entries, want 1", len(parsed.Accepts)) + } + extra, _ := parsed.Accepts[0]["extra"].(map[string]any) + return extra + } + + t.Run("skill route advertises extra.skill", func(t *testing.T) { + extra := probe402(t, "/services/buy-x402/bundle.tar.gz") + skill, ok := extra["skill"].(map[string]any) + if !ok { + t.Fatalf("extra.skill missing or wrong shape: %+v", extra) + } + if skill["name"] != "buy-x402" || skill["version"] != "0.1.0" || skill["sha256"] != sha { + t.Errorf("extra.skill = %+v, want name/version/sha256 populated", skill) + } + }) + + t.Run("non-skill route emits no extra.skill", func(t *testing.T) { + extra := probe402(t, "/services/plain-http/anything") + if _, ok := extra["skill"]; ok { + t.Errorf("non-skill route must not advertise extra.skill: %+v", extra) + } + }) +} diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index 29f4451d..a82e1ada 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -333,6 +333,7 @@ func (v *Verifier) matchPaidRouteFull(cfg *PricingConfig, uri string) (*RouteRul asset := ResolveAssetInfo(chain, rule) requirement := BuildV2RequirementWithAsset(chain, asset, rule.Price, wallet, rule.MaxTimeoutSeconds) mergeAgentExtras(&requirement, rule) + mergeSkillExtras(&requirement, rule) extensions := WithBazaar(BuildExtensionsForAsset(asset), rule.OfferType, rule.Model) return rule, requirement, extensions, prometheusLabels(rule), chain, asset, true } @@ -397,6 +398,28 @@ func mergeAgentExtras(req *x402types.PaymentRequirements, rule *RouteRule) { } } +// mergeSkillExtras adds the skill bundle identity from a RouteRule to the +// requirement's Extra map as extra.skill = {name, version, sha256} so +// buyers probing a 402 on a type=skill offer can verify the artifact they +// are about to pay for. No-op for non-skill rules (SkillName empty). +// Strictly additive — mirrors mergeAgentExtras above. +func mergeSkillExtras(req *x402types.PaymentRequirements, rule *RouteRule) { + if rule.SkillName == "" { + return + } + if req.Extra == nil { + req.Extra = make(map[string]interface{}) + } + skill := map[string]any{"name": rule.SkillName} + if rule.SkillVersion != "" { + skill["version"] = rule.SkillVersion + } + if rule.SkillSHA256 != "" { + skill["sha256"] = rule.SkillSHA256 + } + req.Extra["skill"] = skill +} + // buildPaymentDisplay turns the matched rule + chain + asset into pre-formatted // strings for the HTML 402 page. The atomic-amount input is the value already // computed for the wire requirement (rule.Price * 10^decimals), so passing