diff --git a/README.md b/README.md index 2e16a77..c36d41d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,11 @@ spamoor eoatx --privkey="0x..." \ # With authentication spamoor eoatx --privkey="0x..." \ --rpchost="headers(Authorization:Bearer token)http://node:8545" + +# Reserve an endpoint for one scenario: clients are in "default" unless +# removed from it, and group-less selections (funding, deploys) use "default" +spamoor eoatx --privkey="0x..." --client-group=private \ + --rpchost="group(private,-default)http://intake:8080/rpc" ``` 💡 **See the [App User Guide](./docs/app-users.md) for advanced RPC configuration options** diff --git a/docs/app-users.md b/docs/app-users.md index af7ef05..44856b4 100644 --- a/docs/app-users.md +++ b/docs/app-users.md @@ -128,6 +128,38 @@ http://host1:8545 http://host2:8545 ``` +#### RPC Host Prefixes + +An RPC host may carry prefixes that configure the client before the URL: + +| Prefix | Effect | +|--------|--------| +| `headers(k:v\|k2:v2)` | Sets HTTP headers on every request | +| `name(label)` | Overrides the display name | +| `type(builder)` | Marks the client as a builder (excluded from some selections) | +| `group(a,b)` | Adds the client to named groups | +| `group(-a)` | Removes the client from a group | + +#### Client Groups + +Every client starts in the **`default`** group. Scenarios select clients with +`--client-group`; a scenario that names no group, and the internal selections +for wallet funding, refills and contract deployments, all use `default`. + +`group(name)` is **additive**, so a client with `group(builder)` is in both +`builder` and `default` and still receives group-less traffic. To reserve a +client for one group only, remove it from `default`: + +```bash +# Reachable only by scenarios with --client-group=private +--rpchost "group(private,-default)name(Private Intake)http://localhost:8080/rpc" +``` + +This matters when the endpoint is not an ordinary node — a private transaction +intake, a builder's submission endpoint, a rate-limited provider — and stray +funding or deployment transactions must not land there. Removing every group +is refused, since such a client could never be selected at all. + ### Wallet Management Spamoor uses a sophisticated wallet management system designed for isolation, automation, and high-throughput operations: diff --git a/spamoor/client.go b/spamoor/client.go index c7bdcc3..20e1657 100644 --- a/spamoor/client.go +++ b/spamoor/client.go @@ -6,6 +6,7 @@ import ( "math/big" "net/http" "net/url" + "slices" "strings" "sync" "time" @@ -108,12 +109,18 @@ type ExternalClientOptions struct { // - headers(key:value|key2:value2) - sets custom HTTP headers // - group(name) - assigns the client to a named group (can be used multiple times) // - group(name1,name2,name3) - assigns the client to multiple groups (comma-separated) +// - group(-name) - removes the client from a group. Every client starts in +// "default", which is the group used by selections that name no group +// (wallet funding, deployments, scenarios without --client-group), so +// "group(mygroup,-default)" is how a client is reserved for scenarios +// that ask for "mygroup" and reached by nothing else. // - name(custom_name) - sets a custom display name override // - type(client_type) - sets the client type (e.g., "builder" for builder clients) // // Example: "headers(Authorization:Bearer token|User-Agent:MyApp)group(mainnet)group(primary)name(My Custom Node)http://localhost:8545" // Example: "group(mainnet,primary,backup)name(MainNet Primary)http://localhost:8545" // Example: "type(builder)group(builders)name(Builder Node)http://localhost:8545" +// Example: "group(private,-default)name(Private Intake)http://localhost:8080/rpc" func NewClient(options *ClientOptions) (*Client, error) { headers := map[string]string{} clientGroups := []string{"default"} @@ -137,21 +144,29 @@ func NewClient(options *ClientOptions) (*Client, error) { groupStr := rpchost[6:groupEnd] rpchost = rpchost[groupEnd+1:] - // Parse comma-separated groups + // Parse comma-separated groups. A "-" prefix removes a group + // instead of adding it, which is the only way to take a client + // out of the implicit "default" group. for _, group := range strings.Split(groupStr, ",") { group = strings.TrimSpace(group) - if group != "" { - // Check if group already exists to avoid duplicates - exists := false - for _, existing := range clientGroups { - if existing == group { - exists = true - break - } - } - if !exists { - clientGroups = append(clientGroups, group) + if group == "" { + continue + } + + if remove, found := strings.CutPrefix(group, "-"); found { + if remove == "" { + return nil, fmt.Errorf("invalid client group \"-\": expected a group name after the '-'") } + + clientGroups = slices.DeleteFunc(clientGroups, func(existing string) bool { + return existing == remove + }) + + continue + } + + if !slices.Contains(clientGroups, group) { + clientGroups = append(clientGroups, group) } } } else if strings.HasPrefix(rpchost, "name(") { @@ -189,6 +204,13 @@ func NewClient(options *ClientOptions) (*Client, error) { rpcClient.SetHeader(hKey, hVal) } + // An empty group set makes the client unselectable by every code path + // while GetClientGroups still reports "default", so refuse it rather than + // silently shipping a client nothing can ever reach. + if len(clientGroups) == 0 { + return nil, fmt.Errorf("client %s has no client groups left: the group(...) prefix removed every group", rpchost) + } + return &Client{ rpcClient: rpcClient, rpchost: rpchost, diff --git a/spamoor/client_groups_test.go b/spamoor/client_groups_test.go new file mode 100644 index 0000000..6553714 --- /dev/null +++ b/spamoor/client_groups_test.go @@ -0,0 +1,174 @@ +package spamoor + +import ( + "slices" + "strings" + "testing" +) + +// Client groups are additive on top of the implicit "default" group, and a +// "-name" token removes one. Removing "default" is the only way to keep a +// client out of the selections that name no group. +func TestNewClientGroupParsing(t *testing.T) { + tests := []struct { + name string + rpchost string + want []string + wantErr string + }{ + { + name: "no prefix stays in default", + rpchost: "http://localhost:8545", + want: []string{"default"}, + }, + { + name: "a named group is added to default", + rpchost: "group(builder)http://localhost:8545", + want: []string{"default", "builder"}, + }, + { + name: "several groups, comma separated", + rpchost: "group(a,b)http://localhost:8545", + want: []string{"default", "a", "b"}, + }, + { + name: "duplicates are ignored", + rpchost: "group(a)group(a)http://localhost:8545", + want: []string{"default", "a"}, + }, + { + name: "default can be removed", + rpchost: "group(builder,-default)http://localhost:8545", + want: []string{"builder"}, + }, + { + name: "removal is order independent", + rpchost: "group(-default,builder)http://localhost:8545", + want: []string{"builder"}, + }, + { + name: "removal works across prefixes", + rpchost: "group(builder)group(-default)http://localhost:8545", + want: []string{"builder"}, + }, + { + name: "removing an absent group is a no-op", + rpchost: "group(-nope)http://localhost:8545", + want: []string{"default"}, + }, + { + name: "removing every group is refused", + rpchost: "group(-default)http://localhost:8545", + wantErr: "no client groups left", + }, + { + name: "a bare dash is refused", + rpchost: "group(-)http://localhost:8545", + wantErr: "expected a group name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(&ClientOptions{RpcHost: tt.rpchost}) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected an error containing %q, got groups %v", tt.wantErr, client.GetClientGroups()) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected an error containing %q, got %q", tt.wantErr, err) + } + + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := client.GetClientGroups(); !slices.Equal(got, tt.want) { + t.Fatalf("groups = %v, want %v", got, tt.want) + } + + for _, group := range tt.want { + if !client.HasGroup(group) { + t.Errorf("HasGroup(%q) = false, want true", group) + } + } + }) + } +} + +// A client removed from "default" must be invisible to the group-less +// selections used by wallet funding, deployments and scenarios that set no +// client group. +func TestClientRemovedFromDefaultIsNotSelectedByDefault(t *testing.T) { + reserved, err := NewClient(&ClientOptions{RpcHost: "group(private,-default)http://localhost:8545"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if reserved.HasGroup("") { + t.Error(`HasGroup("") = true, want false (an empty group means default)`) + } + + if reserved.HasGroup("default") { + t.Error(`HasGroup("default") = true, want false`) + } + + if !reserved.HasGroup("private") { + t.Error(`HasGroup("private") = false, want true`) + } + + shared, err := NewClient(&ClientOptions{RpcHost: "group(private)http://localhost:8546"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !shared.HasGroup("default") { + t.Error("without the removal the client must stay in default") + } +} + +// The point of removing "default" is selection, not just membership: a +// reserved client must be unreachable through every group-less selection — +// wallet funding, refills, deployments, and scenarios without a client group. +func TestGetClient_ReservedClientIsUnreachableWithoutItsGroup(t *testing.T) { + shared, err := NewClient(&ClientOptions{RpcHost: "http://localhost:8545"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + reserved, err := NewClient(&ClientOptions{RpcHost: "group(private,-default)http://localhost:8546"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pool := &ClientPool{goodClients: []*Client{shared, reserved}} + + // Group-less selection (the default group) must never return the + // reserved client, however many times it is asked. + for range 20 { + if got := pool.GetClient(); got == reserved { + t.Fatal("group-less selection returned the reserved client") + } + } + + // Explicitly asking for its group is the only way to reach it. + if got := pool.GetClient(WithClientGroup("private")); got != reserved { + t.Fatal("selection by group did not return the reserved client") + } + + // Without the removal, the same client is reachable both ways. + additive, err := NewClient(&ClientOptions{RpcHost: "group(private)http://localhost:8547"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + onlyAdditive := &ClientPool{goodClients: []*Client{additive}} + if got := onlyAdditive.GetClient(); got != additive { + t.Fatal("an additive group client must still serve the default group") + } +}