diff --git a/cmd/obol/accept_test.go b/cmd/obol/accept_test.go index d15b928b..2af943c7 100644 --- a/cmd/obol/accept_test.go +++ b/cmd/obol/accept_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/ObolNetwork/obol-stack/internal/schemas" + "github.com/urfave/cli/v3" ) const testPayTo = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" @@ -200,3 +201,62 @@ func TestBuildAcceptPayments(t *testing.T) { t.Fatalf("expected duplicate error, got %v", err) } } + +// TestSellAccept_CommaSeparatorDisabled is the regression guard for the +// multi-currency --accept bug: cli/v3 StringSliceFlag splits values on "," +// by default, so "--accept token=USDC,network=base,price=1" was shredded into +// three fragments and parsing failed with "network is required". The unit +// tests above never caught it because they call parseAcceptOption / +// buildAcceptPayments directly, bypassing cli/v3 argv parsing entirely. +func TestSellAccept_CommaSeparatorDisabled(t *testing.T) { + // (1) Structural guard: every sell command carrying --accept must keep the + // separator disabled, or multi-currency offers silently break again. + cfg := newTestConfig(t) + for _, name := range []string{"http", "agent", "update"} { + sub := findSubcommand(t, sellCommand(cfg), name) + if !sub.DisableSliceFlagSeparator { + t.Errorf("sell %s: DisableSliceFlagSeparator must be true so a comma-joined --accept stays one value", name) + } + } + + // (2) Behavioral: drive real cli/v3 argv parsing. With the separator + // disabled each --accept arrives whole; with the default separator the same + // argv shreds, proving the field is load-bearing (not a no-op). + run := func(disable bool) []string { + var got []string + cmd := &cli.Command{ + Name: "x", + DisableSliceFlagSeparator: disable, + Flags: []cli.Flag{&cli.StringSliceFlag{Name: "accept"}}, + Action: func(_ context.Context, c *cli.Command) error { + got = c.StringSlice("accept") + return nil + }, + } + err := cmd.Run(context.Background(), []string{ + "x", + "--accept", "token=USDC,network=base,price=1", + "--accept", "token=OBOL,network=ethereum,price=10", + }) + if err != nil { + t.Fatalf("run(disable=%v): %v", disable, err) + } + return got + } + + whole := run(true) + if len(whole) != 2 { + t.Fatalf("disabled separator: got %d values %q, want 2 whole options", len(whole), whole) + } + if _, err := buildAcceptPayments(whole, testPayTo); err != nil { + t.Fatalf("whole --accept values should build payments, got: %v", err) + } + + shredded := run(false) + if len(shredded) == 2 { + t.Fatal("default cli/v3 separator unexpectedly kept --accept whole; the fix may be a no-op") + } + if _, err := buildAcceptPayments(shredded, testPayTo); err == nil { + t.Error("shredded --accept fragments should fail to build payments (the original bug)") + } +} diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 2ea01bba..710710f5 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -667,6 +667,14 @@ func sellHTTPCommand(cfg *config.Config) *cli.Command { Name: "http", Usage: "Sell any local HTTP service with x402 payments", ArgsUsage: "", + // --accept carries a comma-separated key=value list per option + // (token=USDC,network=base,price=1). cli/v3 slice flags split on "," + // by default, which would shred one option into several fragments and + // fail with "network is required". Disable the separator so each + // --accept is one whole value; parseAcceptKV does its own "," split. + // The repeatable --register-skills/-domains/-metadata flags are + // unaffected in practice (each value is a single token / key=value). + DisableSliceFlagSeparator: true, Description: `Publishes a payment gated HTTP API to any service within the stack. By default it also registers the seller agent on ERC-8004 after the route is live. Use --no-register to skip the on-chain registration step. @@ -2732,6 +2740,9 @@ func sellUpdateCommand(cfg *config.Config) *cli.Command { Name: "update", Usage: "Update pricing or wallet on an existing ServiceOffer in place", ArgsUsage: "", + // See sell http: keep one --accept value intact (no cli/v3 "," split) + // so multi-currency updates parse. parseAcceptKV splits internally. + DisableSliceFlagSeparator: true, Description: `Patches a live ServiceOffer without deleting it. Only the fields you pass are changed; everything else is preserved. The serviceoffer-controller will reconcile the new payment config automatically. diff --git a/cmd/obol/sell_agent.go b/cmd/obol/sell_agent.go index 25f3503d..31b5d93a 100644 --- a/cmd/obol/sell_agent.go +++ b/cmd/obol/sell_agent.go @@ -25,6 +25,9 @@ func sellAgentCommand(cfg *config.Config) *cli.Command { Name: "agent", Usage: "Gate an existing Obol Stack agent with x402 payments", ArgsUsage: "", + // See sell http: keep one --accept value intact (no cli/v3 "," split) + // so multi-currency offers parse. parseAcceptKV splits internally. + DisableSliceFlagSeparator: true, Description: `Wraps an existing Agent (created with ` + "`obol agent new `" + `) with a ServiceOffer of type=agent. The serviceoffer-controller resolves spec.agent.ref into the agent's cluster endpoint, surfaces the agent's