Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
32 changes: 32 additions & 0 deletions docs/app-users.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
46 changes: 34 additions & 12 deletions spamoor/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"math/big"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -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"}
Expand All @@ -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(") {
Expand Down Expand Up @@ -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,
Expand Down
174 changes: 174 additions & 0 deletions spamoor/client_groups_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}