diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml index 373ad72338..cefb86d53d 100644 --- a/.github/actions/setup-go/action.yml +++ b/.github/actions/setup-go/action.yml @@ -13,7 +13,7 @@ runs: uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: check-latest: true - go-version: "1.26" + go-version: "1.27" - name: Verify setup shell: bash diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 2c5be57241..bfc7bf8573 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -19,4 +19,4 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: - version: v2.12.2 + version: v2.14.0 diff --git a/.golangci.yml b/.golangci.yml index efac98b42a..d59cec7ad4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,6 @@ version: "2" run: - go: "1.26" + go: "1.27" linters: default: all disable: @@ -9,6 +9,7 @@ linters: - cyclop - err113 - exhaustruct + - exhaustruct_v5 - forcetypeassert - funlen - funcorder @@ -18,6 +19,7 @@ linters: - godot - godox - gomoddirectives + - gomodguard - inamedparam - interfacebloat - intrange @@ -169,6 +171,12 @@ linters: - legacy - std-error-handling rules: + # golangci-lint's nolintlint falsely reports //nolint:revive directives as + # unused and (with fix: true) deletes them, even though revive would fire + # without them. Keep such directives; do not report or auto-remove them. + - linters: + - nolintlint + text: 'is unused for linter "revive"' - linters: - bodyclose - gosec diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 10f97f3692..6796706792 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: rev: v0.0.6 hooks: - id: check-go-version - args: [-v=go1.26] # Only check minor version locally + args: [-v=go1.27] # Only check minor version locally pass_filenames: false additional_dependencies: [packaging] - id: check-licence-header diff --git a/.pre-commit/run_linter.sh b/.pre-commit/run_linter.sh index 5cf19fb235..e27c2d36ea 100755 --- a/.pre-commit/run_linter.sh +++ b/.pre-commit/run_linter.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -VERSION="2.12.2" +VERSION="2.14.0" if ! command -v golangci-lint &>/dev/null; then echo "golangci-lint could not be found" diff --git a/CLAUDE.md b/CLAUDE.md index 60c486d575..5abb111b3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,7 @@ testutil/ # Test utilities, mocks, golden files ## Code Style and Guidelines ### Go Version -Requires Go 1.26 (enforced by pre-commit hooks) +Requires Go 1.27 (enforced by pre-commit hooks) ### Core Principles (from [docs/goguidelines.md](docs/goguidelines.md)) 1. **Functions over methods**: Prefer stateless functions over stateful objects diff --git a/app/app.go b/app/app.go index 724dce736a..9034c4c4b3 100644 --- a/app/app.go +++ b/app/app.go @@ -957,7 +957,7 @@ func newETH2Client(ctx context.Context, conf Config, life *lifecycle.Manager, lo return nil, nil, err } - fb := eth2wrap.NewSimnetFallbacks(bnTimeout, [4]byte(forkVersion), beaconNodeHeaders, conf.FallbackBeaconNodeAddrs) + fb := eth2wrap.NewSimnetFallbacks(ctx, bnTimeout, [4]byte(forkVersion), beaconNodeHeaders, conf.FallbackBeaconNodeAddrs) wrap, err := eth2wrap.Instrument([]eth2wrap.Client{bmock}, fb) if err != nil { @@ -1000,7 +1000,7 @@ func newETH2Client(ctx context.Context, conf Config, life *lifecycle.Manager, lo return nil, nil, err } - fb := eth2wrap.NewSimnetFallbacks(bnTimeout, [4]byte(forkVersion), beaconNodeHeaders, conf.FallbackBeaconNodeAddrs) + fb := eth2wrap.NewSimnetFallbacks(ctx, bnTimeout, [4]byte(forkVersion), beaconNodeHeaders, conf.FallbackBeaconNodeAddrs) wrap, err := eth2wrap.Instrument([]eth2wrap.Client{bmock}, fb) if err != nil { @@ -1046,7 +1046,7 @@ func newETH2Client(ctx context.Context, conf Config, life *lifecycle.Manager, lo // configureEth2Client configures a beacon node client with the provided settings. func configureEth2Client(ctx context.Context, forkVersion []byte, fallbackAddrs []string, addrs []string, headers map[string]string, timeout time.Duration, syntheticBlockProposals bool) (eth2wrap.Client, error) { - eth2Cl, err := eth2wrap.NewMultiHTTP(timeout, [4]byte(forkVersion), headers, addrs, fallbackAddrs) + eth2Cl, err := eth2wrap.NewMultiHTTP(ctx, timeout, [4]byte(forkVersion), headers, addrs, fallbackAddrs) if err != nil { return nil, errors.Wrap(err, "new eth2 http client") } diff --git a/app/eth2wrap/eth2wrap.go b/app/eth2wrap/eth2wrap.go index 8568d9cb30..b1c8af7617 100644 --- a/app/eth2wrap/eth2wrap.go +++ b/app/eth2wrap/eth2wrap.go @@ -92,46 +92,51 @@ func WithSyntheticDuties(cl Client) Client { } // NewMultiHTTP returns a new instrumented multi eth2 http client. -func NewMultiHTTP(timeout time.Duration, forkVersion [4]byte, headers map[string]string, addrs []string, fallbackAddrs []string) (Client, error) { +// The ctx bounds the lifetime of the underlying eth2http services' background routines. +func NewMultiHTTP(ctx context.Context, timeout time.Duration, forkVersion [4]byte, headers map[string]string, addrs []string, fallbackAddrs []string) (Client, error) { return Instrument( - newClients(timeout, forkVersion, headers, addrs), - newClients(timeout, forkVersion, headers, fallbackAddrs), + newClients(ctx, timeout, forkVersion, headers, addrs), + newClients(ctx, timeout, forkVersion, headers, fallbackAddrs), ) } // NewSimnetFallbacks returns a slice of Client initialized with the provided settings. Used in Simnet setting. -func NewSimnetFallbacks(timeout time.Duration, forkVersion [4]byte, headers map[string]string, addresses []string) []Client { +func NewSimnetFallbacks(ctx context.Context, timeout time.Duration, forkVersion [4]byte, headers map[string]string, addresses []string) []Client { var clients []Client for _, address := range addresses { - clients = append(clients, newBeaconClient(timeout, forkVersion, headers, address)) + clients = append(clients, newBeaconClient(ctx, timeout, forkVersion, headers, address)) } return clients } // newClients returns a slice of Client initialized with the provided settings. -func newClients(timeout time.Duration, forkVersion [4]byte, headers map[string]string, addresses []string) []Client { +func newClients(ctx context.Context, timeout time.Duration, forkVersion [4]byte, headers map[string]string, addresses []string) []Client { var clients []Client for _, address := range addresses { - clients = append(clients, newBeaconClient(timeout, forkVersion, headers, address)) + clients = append(clients, newBeaconClient(ctx, timeout, forkVersion, headers, address)) } return clients } -// newBeaconClient returns a Client with the provided settings. -func newBeaconClient(timeout time.Duration, forkVersion [4]byte, headers map[string]string, address string) Client { - parameters := []eth2http.Parameter{ +// newBeaconClient returns a Client with the provided settings, extraParams override them. +func newBeaconClient(svcCtx context.Context, timeout time.Duration, forkVersion [4]byte, headers map[string]string, address string, + extraParams ...eth2http.Parameter, +) Client { + parameters := append([]eth2http.Parameter{ eth2http.WithLogLevel(zeroLogInfo), eth2http.WithAddress(address), eth2http.WithTimeout(timeout), eth2http.WithAllowDelayedStart(true), eth2http.WithEnforceJSON(featureset.Enabled(featureset.JSONRequests)), eth2http.WithExtraHeaders(headers), - } + }, extraParams...) cl := newLazy(func(ctx context.Context) (Client, error) { - eth2Svc, err := eth2http.New(ctx, parameters...) + // eth2http.New ties its background routines (static value cache clearing, connection + // state pings) to the ctx it is given, so use svcCtx, not the triggering request's ctx. + eth2Svc, err := eth2http.New(svcCtx, parameters...) if err != nil { return nil, wrapError(ctx, err, "new eth2 client", z.Str("address", address)) } diff --git a/app/eth2wrap/eth2wrap_gen.go b/app/eth2wrap/eth2wrap_gen.go index ef0415a4d3..1e8bdddf76 100644 --- a/app/eth2wrap/eth2wrap_gen.go +++ b/app/eth2wrap/eth2wrap_gen.go @@ -14,6 +14,7 @@ import ( apiv1 "github.com/attestantio/go-eth2-client/api/v1" "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/electra" "github.com/attestantio/go-eth2-client/spec/phase0" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" ) @@ -58,6 +59,7 @@ type Client interface { eth2client.NodeSyncingProvider eth2client.NodeVersionProvider eth2client.NodeVersionV2Provider + eth2client.PendingDepositProvider eth2client.ProposalPreparationsSubmitter eth2client.ProposalProvider eth2client.ProposalSubmitter @@ -872,6 +874,27 @@ func (m multi) SubmitVoluntaryExit(ctx context.Context, voluntaryExit *phase0.Si return err } +// PendingDeposits provides the pending deposits for a given state. +func (m multi) PendingDeposits(ctx context.Context, opts *api.PendingDepositsOpts) (*api.Response[[]*electra.PendingDeposit], error) { + const label = "pending_deposits" + defer latency(ctx, label, false)() + defer incRequest(label) + + res0, err := provide(ctx, m.clients, m.fallbacks, + func(ctx context.Context, args provideArgs) (*api.Response[[]*electra.PendingDeposit], error) { + return args.client.PendingDeposits(ctx, opts) + }, + nil, m.selector, + ) + + if err != nil { + incError(label) + err = wrapError(ctx, err, label) + } + + return res0, err +} + // Domain provides a domain for a given domain type at a given epoch. // Note this endpoint is cached in go-eth2-client. func (m multi) Domain(ctx context.Context, domainType phase0.DomainType, epoch phase0.Epoch) (phase0.Domain, error) { @@ -1296,6 +1319,16 @@ func (l *lazy) SubmitVoluntaryExit(ctx context.Context, voluntaryExit *phase0.Si return cl.SubmitVoluntaryExit(ctx, voluntaryExit) } +// PendingDeposits provides the pending deposits for a given state. +func (l *lazy) PendingDeposits(ctx context.Context, opts *api.PendingDepositsOpts) (res0 *api.Response[[]*electra.PendingDeposit], err error) { + cl, err := l.getOrCreateClient(ctx) + if err != nil { + return res0, err + } + + return cl.PendingDeposits(ctx, opts) +} + // Domain provides a domain for a given domain type at a given epoch. func (l *lazy) Domain(ctx context.Context, domainType phase0.DomainType, epoch phase0.Epoch) (res0 phase0.Domain, err error) { cl, err := l.getOrCreateClient(ctx) diff --git a/app/eth2wrap/eth2wrap_internal_test.go b/app/eth2wrap/eth2wrap_internal_test.go new file mode 100644 index 0000000000..5029f243cf --- /dev/null +++ b/app/eth2wrap/eth2wrap_internal_test.go @@ -0,0 +1,300 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package eth2wrap + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + eth2api "github.com/attestantio/go-eth2-client/api" + apiv1 "github.com/attestantio/go-eth2-client/api/v1" + eth2http "github.com/attestantio/go-eth2-client/http" + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/app/retry" +) + +// TestNodeVersionV2Refresh covers a beacon node that reports its execution client only after its +// version was first fetched, e.g. when they start together. The execution client must show up once +// the eth2http service clears its static values cache, without restarting. +func TestNodeVersionV2Refresh(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var withEE atomic.Bool + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/eth/v1/node/version": + _, _ = w.Write([]byte(`{"data":{"version":"Lighthouse/v8.0.0"}}`)) + case "/eth/v1/node/syncing": + _, _ = w.Write([]byte(`{"data":{"head_slot":"100","sync_distance":"0","is_syncing":false,"is_optimistic":false,"el_offline":false}}`)) + case "/eth/v2/node/version": + ee := "" + if withEE.Load() { + ee = `,"execution_client":{"code":"GE","name":"Geth","version":"v1.16.0","commit":"12345678"}` + } + + _, _ = fmt.Fprintf(w, `{"data":{"beacon_node":{"code":"LH","name":"Lighthouse","version":"v8.0.0","commit":"abcdef01"}%s}}`, ee) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + // Serve in-memory, since time in the bubble only advances while no goroutine waits on the network. + httpCl := &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, r) + + return rec.Result(), nil + })} + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + cl, err := Instrument([]Client{ + newBeaconClient(ctx, time.Second, [4]byte{}, nil, "http://beacon-node", eth2http.WithHTTPClient(httpCl)), + }, nil) + require.NoError(t, err) + + executionClient := func() *apiv1.ClientVersion { + resp, err := cl.NodeVersionV2(ctx, ð2api.NodeVersionV2Opts{}) + require.NoError(t, err) + + return resp.Data.ExecutionClient + } + + require.Nil(t, executionClient()) + + withEE.Store(true) + require.Nil(t, executionClient(), "expected cached version") + + // The eth2http service clears its static values every 5 minutes. + time.Sleep(5 * time.Minute) + synctest.Wait() + + ee := executionClient() + require.NotNil(t, ee, "execution client didn't refresh") + require.Equal(t, "Geth", ee.Name) + }) +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +// TestConnectionStateRecovery covers duty requests after the eth2http service's periodic connection +// state ping found a beacon node slow or syncing. The service then fails requests without querying +// the node until a request's own ping succeeds, and concurrent requests fail while that ping is in +// flight, so duties rely on retries to recover. +func TestConnectionStateRecovery(t *testing.T) { + t.Parallel() + + const ( + timeout = 500 * time.Millisecond + deadline = 2 * time.Second + requests = 20 + ) + + slowPing := func(bn *fakeBeaconNode) { bn.syncingDelay.Store(int64(2 * timeout)) } + syncing := func(bn *fakeBeaconNode) { bn.syncing.Store(true) } + healthy := func(bn *fakeBeaconNode) { + bn.syncingDelay.Store(0) + bn.syncing.Store(false) + } + unchanged := func(*fakeBeaconNode) {} + + tests := []struct { + name string + // nodes is the number of beacon nodes, the first one is pinged. + nodes int + // beforePing and afterPing modify the first node around the ping. + beforePing func(*fakeBeaconNode) + afterPing func(*fakeBeaconNode) + // expectSuccess is whether the requests succeed within the deadline. + expectSuccess bool + }{ + { + name: "ping timed out, node recovered", + nodes: 1, + beforePing: slowPing, + afterPing: healthy, + expectSuccess: true, + }, + { + name: "ping found node syncing, node synced", + nodes: 1, + beforePing: syncing, + afterPing: healthy, + expectSuccess: true, + }, + { + name: "ping keeps timing out", + nodes: 1, + beforePing: slowPing, + afterPing: unchanged, + expectSuccess: false, + }, + { + name: "ping timed out, other node healthy", + nodes: 2, + beforePing: slowPing, + afterPing: unchanged, + expectSuccess: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var ( + bns []*fakeBeaconNode + clients []Client + ) + + for range test.nodes { + bn := newFakeBeaconNode(t) + bns = append(bns, bn) + clients = append(clients, newBeaconClient(t.Context(), timeout, [4]byte{}, nil, bn.URL)) + } + + // Create the lazy clients individually, since a multi client stops at the first response. + for _, c := range clients { + _, err := c.AttestationData(t.Context(), ð2api.AttestationDataOpts{Slot: 1}) + require.NoError(t, err) + } + + cl, err := Instrument(clients, nil) + require.NoError(t, err) + + pinged := bns[0] + svc := eth2Service(t, clients[0]) + + test.beforePing(pinged) + svc.CheckConnectionState(t.Context()) + require.False(t, svc.IsSynced()) + test.afterPing(pinged) + + pinged.attDataHits.Store(0) + + retryer := retry.New(func(deadline time.Time) (time.Time, bool) { + return deadline, true + }) + + var ( + wg sync.WaitGroup + successes atomic.Int64 + ) + + for i := range requests { + wg.Go(func() { + retryer.DoAsync(t.Context(), time.Now().Add(deadline), "test", "attestation_data", func(ctx context.Context) error { + _, err := cl.AttestationData(ctx, ð2api.AttestationDataOpts{Slot: eth2p0.Slot(10 + i)}) + if err != nil { + return err + } + + successes.Add(1) + + return nil + }) + }) + } + + wg.Wait() + + if test.expectSuccess { + require.EqualValues(t, requests, successes.Load()) + } else { + require.Zero(t, successes.Load()) + require.Zero(t, pinged.attDataHits.Load(), "service queried node it considers inactive") + } + }) + } +} + +// fakeBeaconNode is a beacon node serving node version and attestation data, with a controllable +// node syncing endpoint. Like real beacon nodes, it doesn't serve attestation data while syncing. +type fakeBeaconNode struct { + URL string + syncingDelay atomic.Int64 // time.Duration + syncing atomic.Bool + attDataHits atomic.Int64 +} + +func newFakeBeaconNode(t *testing.T) *fakeBeaconNode { + t.Helper() + + bn := new(fakeBeaconNode) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/eth/v1/node/version": + _, _ = w.Write([]byte(`{"data":{"version":"Lighthouse/v8.0.0"}}`)) + case "/eth/v1/node/syncing": + select { + case <-time.After(time.Duration(bn.syncingDelay.Load())): + case <-r.Context().Done(): + return + } + + if bn.syncing.Load() { + _, _ = w.Write([]byte(`{"data":{"head_slot":"100","sync_distance":"50","is_syncing":true,"is_optimistic":false,"el_offline":false}}`)) + } else { + _, _ = w.Write([]byte(`{"data":{"head_slot":"100","sync_distance":"0","is_syncing":false,"is_optimistic":false,"el_offline":false}}`)) + } + case "/eth/v1/config/spec": + _, _ = w.Write([]byte(`{"data":{"SLOTS_PER_EPOCH":"32","ELECTRA_FORK_EPOCH":"0"}}`)) + case "/eth/v1/validator/attestation_data": + bn.attDataHits.Add(1) + + if bn.syncing.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"code":503,"message":"Beacon node is currently syncing"}`)) + + return + } + + root := "0x" + strings.Repeat("00", 32) + _, _ = fmt.Fprintf(w, `{"data":{"slot":"%s","index":"0","beacon_block_root":"%s","source":{"epoch":"1","root":"%s"},"target":{"epoch":"2","root":"%s"}}}`, + r.URL.Query().Get("slot"), root, root, root) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + + bn.URL = srv.URL + + return bn +} + +// eth2Service returns the eth2http service of a created lazy beacon client. +func eth2Service(t *testing.T, cl Client) *eth2http.Service { + t.Helper() + + l, ok := cl.(*lazy) + require.True(t, ok) + + inner, ok := l.getClient() + require.True(t, ok) + + adapter, ok := inner.(*httpAdapter) + require.True(t, ok) + + return adapter.Service +} diff --git a/app/eth2wrap/eth2wrap_test.go b/app/eth2wrap/eth2wrap_test.go index 900b13af62..76436e6be2 100644 --- a/app/eth2wrap/eth2wrap_test.go +++ b/app/eth2wrap/eth2wrap_test.go @@ -283,7 +283,7 @@ func TestErrors(t *testing.T) { ctx := context.Background() t.Run("network dial error", func(t *testing.T) { - cl, err := eth2wrap.NewMultiHTTP(time.Hour, [4]byte{}, nil, []string{"localhost:22222"}, nil) + cl, err := eth2wrap.NewMultiHTTP(t.Context(), time.Hour, [4]byte{}, nil, []string{"localhost:22222"}, nil) require.NoError(t, err) _, err = cl.NodePeerCount(ctx, ð2api.NodePeerCountOpts{}) @@ -298,7 +298,7 @@ func TestErrors(t *testing.T) { })) t.Run("http timeout", func(t *testing.T) { - cl, err := eth2wrap.NewMultiHTTP(time.Millisecond, [4]byte{}, nil, []string{srv.URL}, nil) + cl, err := eth2wrap.NewMultiHTTP(t.Context(), time.Millisecond, [4]byte{}, nil, []string{srv.URL}, nil) require.NoError(t, err) _, err = cl.NodePeerCount(ctx, ð2api.NodePeerCountOpts{}) @@ -311,7 +311,7 @@ func TestErrors(t *testing.T) { ctx, cancel := context.WithCancel(ctx) cancel() - cl, err := eth2wrap.NewMultiHTTP(time.Millisecond, [4]byte{}, nil, []string{srv.URL}, nil) + cl, err := eth2wrap.NewMultiHTTP(t.Context(), time.Millisecond, [4]byte{}, nil, []string{srv.URL}, nil) require.NoError(t, err) _, err = cl.NodePeerCount(ctx, ð2api.NodePeerCountOpts{}) @@ -367,7 +367,7 @@ func TestCtxCancel(t *testing.T) { bmock, err := beaconmock.New(t.Context()) require.NoError(t, err) - eth2Cl, err := eth2wrap.NewMultiHTTP(time.Second, [4]byte{}, nil, []string{bmock.Address()}, nil) + eth2Cl, err := eth2wrap.NewMultiHTTP(t.Context(), time.Second, [4]byte{}, nil, []string{bmock.Address()}, nil) require.NoError(t, err) cancel() // Cancel context before calling method. @@ -394,7 +394,7 @@ func TestOneError(t *testing.T) { bmock.Address(), // Valid } - eth2Cl, err := eth2wrap.NewMultiHTTP(time.Second, [4]byte{}, nil, addresses, nil) + eth2Cl, err := eth2wrap.NewMultiHTTP(t.Context(), time.Second, [4]byte{}, nil, addresses, nil) require.NoError(t, err) eth2Resp, err := eth2Cl.Spec(ctx, ð2api.SpecOpts{}) @@ -425,7 +425,7 @@ func TestOneTimeout(t *testing.T) { bmock.Address(), // Valid } - eth2Cl, err := eth2wrap.NewMultiHTTP(time.Minute, [4]byte{}, nil, addresses, nil) + eth2Cl, err := eth2wrap.NewMultiHTTP(t.Context(), time.Minute, [4]byte{}, nil, addresses, nil) require.NoError(t, err) eth2Resp, err := eth2Cl.Spec(ctx, ð2api.SpecOpts{}) @@ -448,7 +448,7 @@ func TestOnlyTimeout(t *testing.T) { defer srv.Close() defer cancel() // Cancel the context before stopping the server. - eth2Cl, err := eth2wrap.NewMultiHTTP(time.Minute, [4]byte{}, nil, []string{srv.URL}, nil) + eth2Cl, err := eth2wrap.NewMultiHTTP(t.Context(), time.Minute, [4]byte{}, nil, []string{srv.URL}, nil) require.NoError(t, err) // Start goroutine that is blocking trying to create the client. @@ -519,7 +519,7 @@ func TestLazy(t *testing.T) { httputil.NewSingleHostReverseProxy(target).ServeHTTP(w, r) })) - eth2Cl, err := eth2wrap.NewMultiHTTP(time.Second, [4]byte{}, nil, []string{srv1.URL, srv2.URL}, nil) + eth2Cl, err := eth2wrap.NewMultiHTTP(t.Context(), time.Second, [4]byte{}, nil, []string{srv1.URL, srv2.URL}, nil) require.NoError(t, err) // Both proxies are disabled, so this should fail. @@ -598,7 +598,7 @@ func TestLazyDomain(t *testing.T) { forkVersionHex, err := hex.DecodeString(test.in) require.NoError(t, err) - eth2Cl, err := eth2wrap.NewMultiHTTP(time.Second, [4]byte(forkVersionHex), nil, []string{srv.URL}, nil) + eth2Cl, err := eth2wrap.NewMultiHTTP(t.Context(), time.Second, [4]byte(forkVersionHex), nil, []string{srv.URL}, nil) require.NoError(t, err) voluntaryExitDomain := eth2p0.DomainType{0x04, 0x00, 0x00, 0x00} diff --git a/app/eth2wrap/genwrap/genwrap.go b/app/eth2wrap/genwrap/genwrap.go index 5f8e9c7e94..b3433fe649 100644 --- a/app/eth2wrap/genwrap/genwrap.go +++ b/app/eth2wrap/genwrap/genwrap.go @@ -130,6 +130,7 @@ type Client interface { "NodeSyncingProvider": {Latency: true, Log: false}, "NodeVersionProvider": {Latency: false, Log: false}, "NodeVersionV2Provider": {Latency: false, Log: false}, + "PendingDepositProvider": {Latency: true, Log: false}, "ProposerDutiesProvider": {Latency: true, Log: false}, "ProposalPreparationsSubmitter": {Latency: true, Log: true}, "SlotDurationProvider": {Latency: false, Log: false}, diff --git a/app/eth2wrap/mocks/client.go b/app/eth2wrap/mocks/client.go index 7ade54a226..3a5f52b12b 100644 --- a/app/eth2wrap/mocks/client.go +++ b/app/eth2wrap/mocks/client.go @@ -20,6 +20,8 @@ import ( spec "github.com/attestantio/go-eth2-client/spec" + electra "github.com/attestantio/go-eth2-client/spec/electra" + time "time" v1 "github.com/attestantio/go-eth2-client/api/v1" @@ -770,6 +772,36 @@ func (_m *Client) NodeVersionV2(ctx context.Context, opts *api.NodeVersionV2Opts return r0, r1 } +// PendingDeposits provides a mock function with given fields: ctx, opts +func (_m *Client) PendingDeposits(ctx context.Context, opts *api.PendingDepositsOpts) (*api.Response[[]*electra.PendingDeposit], error) { + ret := _m.Called(ctx, opts) + + if len(ret) == 0 { + panic("no return value specified for PendingDeposits") + } + + var r0 *api.Response[[]*electra.PendingDeposit] + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *api.PendingDepositsOpts) (*api.Response[[]*electra.PendingDeposit], error)); ok { + return rf(ctx, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, *api.PendingDepositsOpts) *api.Response[[]*electra.PendingDeposit]); ok { + r0 = rf(ctx, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*api.Response[[]*electra.PendingDeposit]) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *api.PendingDepositsOpts) error); ok { + r1 = rf(ctx, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Proposal provides a mock function with given fields: ctx, opts func (_m *Client) Proposal(ctx context.Context, opts *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) { ret := _m.Called(ctx, opts) diff --git a/app/log/config.go b/app/log/config.go index 2f7ddbfc71..478ae5ff20 100644 --- a/app/log/config.go +++ b/app/log/config.go @@ -501,13 +501,13 @@ func formatZapStack(zapStack string) string { if strings.HasPrefix(line, "\t") { const sep = "charon/" // Note that this only works if source built in a folder named 'charon'. - i := strings.LastIndex(line, sep) - if i < 0 { + _, after, ok := strings.CutLast(line, sep) + if !ok { // Skip non-charon lines continue } - resp = append(resp, "\t"+line[i+len(sep):]+" "+prevFunc) + resp = append(resp, "\t"+after+" "+prevFunc) prevFunc = "" continue diff --git a/app/log/redact.go b/app/log/redact.go index 12a5beabf2..05860d500d 100644 --- a/app/log/redact.go +++ b/app/log/redact.go @@ -1,5 +1,6 @@ // Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 +//nolint:revive,nolintlint // somehow the nolintlint linter catches revive as unnecessary, while it is package log import ( diff --git a/app/monitoringapi_internal_test.go b/app/monitoringapi_internal_test.go index 6e6c0ecb96..208ed47d30 100644 --- a/app/monitoringapi_internal_test.go +++ b/app/monitoringapi_internal_test.go @@ -163,10 +163,13 @@ func TestStartChecker(t *testing.T) { return true }, waitFor, tickInterval) } else { + // Don't advance the clock further: only one vapi call was sent, so the checker + // observing two more epoch rollovers would swap it out and permanently flip + // readiness to errReadyVCNotConnected. The ticks already fired above are enough + // for the checker to evaluate readiness as nil, which is then stable. require.Eventually(t, func() bool { - advanceClock(t, ctx, clock, slotDuration) return readyErrFunc() == nil - }, waitFor, tickInterval) + }, 5*time.Second, tickInterval) } }) } diff --git a/app/obolapi/api.go b/app/obolapi/api.go index 529612ab7a..e5b055d5c0 100644 --- a/app/obolapi/api.go +++ b/app/obolapi/api.go @@ -13,6 +13,7 @@ import ( "github.com/obolnetwork/charon/app/errors" "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/version" "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/cluster" ) @@ -25,6 +26,11 @@ const ( defaultTimeout = 10 * time.Second ) +// ErrLockNotFound is returned when the Obol API responds 404 Not Found to a POST request. For the +// lock hash scoped endpoints (partial exits, deposits and fee recipients) it means the cluster lock +// isn't published to that Obol API, so the data can't be stored until it is. +var ErrLockNotFound = errors.NewSentinel("cluster lock not published to Obol API") + // New returns a new Client. func New(urlStr string, options ...func(*Client)) (Client, error) { _, err := url.ParseRequestURI(urlStr) // check that urlStr is valid @@ -35,7 +41,8 @@ func New(urlStr string, options ...func(*Client)) (Client, error) { // always set a default timeout, even if no options are provided options = append([]func(*Client){WithTimeout(defaultTimeout)}, options...) cl := Client{ - baseURL: urlStr, + baseURL: urlStr, + userAgent: charonUserAgent(), } for _, opt := range options { @@ -49,6 +56,7 @@ func New(urlStr string, options ...func(*Client)) (Client, error) { type Client struct { baseURL string // Base obol-api URL reqTimeout time.Duration // Timeout to use for HTTP requests + userAgent string // User-Agent header sent with every request } // WithTimeout sets the HTTP request timeout for all Client calls to the provided value. @@ -58,6 +66,23 @@ func WithTimeout(timeout time.Duration) func(*Client) { } } +// WithUserAgent sets the User-Agent header of all Client calls, overriding the charon default. +// An empty user agent keeps the default. +func WithUserAgent(userAgent string) func(*Client) { + return func(client *Client) { + if userAgent == "" { + return + } + + client.userAgent = userAgent + } +} + +// charonUserAgent returns the default User-Agent header value, e.g. "charon/v1.12.0". +func charonUserAgent() string { + return "charon/" + version.Version.String() +} + // url returns a *url.URL from the baseURL stored in c. // Will panic if somehow c.baseURL got corrupted, and it's not a valid URL anymore. func (c Client) url() *url.URL { @@ -83,13 +108,14 @@ func launchpadURLPath(lock cluster.Lock) string { return fmt.Sprintf(launchpadReturnPathFmt, lock.LockHash) } -func httpPost(ctx context.Context, url *url.URL, body []byte, headers map[string]string) error { +func (c Client) httpPost(ctx context.Context, url *url.URL, body []byte, headers map[string]string) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), bytes.NewReader(body)) if err != nil { return errors.Wrap(err, "create POST request") } req.Header.Add("Content-Type", "application/json") + req.Header.Set("User-Agent", c.userAgent) for key, val := range headers { req.Header.Set(key, val) @@ -107,6 +133,10 @@ func httpPost(ctx context.Context, url *url.URL, body []byte, headers map[string return errors.Wrap(err, "read POST response", z.Int("status", res.StatusCode)) } + if res.StatusCode == http.StatusNotFound { + return errors.Wrap(ErrLockNotFound, "http POST failed", z.Int("status", res.StatusCode), z.Str("body", string(data))) + } + if res.StatusCode != http.StatusConflict { return errors.New("http POST failed", z.Int("status", res.StatusCode), z.Str("body", string(data))) } @@ -117,12 +147,14 @@ func httpPost(ctx context.Context, url *url.URL, body []byte, headers map[string return nil } -func httpGet(ctx context.Context, url *url.URL, headers map[string]string) (io.ReadCloser, error) { +func (c Client) httpGet(ctx context.Context, url *url.URL, headers map[string]string) (io.ReadCloser, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil) if err != nil { return nil, errors.Wrap(err, "create GET request") } + req.Header.Set("User-Agent", c.userAgent) + for key, val := range headers { req.Header.Set(key, val) } @@ -133,6 +165,8 @@ func httpGet(ctx context.Context, url *url.URL, headers map[string]string) (io.R } if res.StatusCode/100 != 2 { + defer res.Body.Close() + if res.StatusCode == http.StatusNotFound { return nil, ErrNoValue } @@ -148,12 +182,14 @@ func httpGet(ctx context.Context, url *url.URL, headers map[string]string) (io.R return res.Body, nil } -func httpDelete(ctx context.Context, url *url.URL, headers map[string]string) error { +func (c Client) httpDelete(ctx context.Context, url *url.URL, headers map[string]string) error { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url.String(), nil) if err != nil { return errors.Wrap(err, "create DELETE request") } + req.Header.Set("User-Agent", c.userAgent) + for key, val := range headers { req.Header.Set(key, val) } diff --git a/app/obolapi/api_internal_test.go b/app/obolapi/api_internal_test.go index fbb1ed0817..fe6a451107 100644 --- a/app/obolapi/api_internal_test.go +++ b/app/obolapi/api_internal_test.go @@ -11,7 +11,11 @@ import ( "testing" "time" + k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/app/version" + "github.com/obolnetwork/charon/app/z" ) func TestWithTimeout(t *testing.T) { @@ -35,6 +39,7 @@ func TestHttpPost(t *testing.T) { server *httptest.Server endpoint string expectedError string + expectedIs error }{ { name: "default scenario", @@ -89,6 +94,19 @@ func TestHttpPost(t *testing.T) { })), expectedError: "POST failed", }, + { + name: "status code 404 Not Found", + body: nil, + headers: nil, + endpoint: "/post-request", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, err := w.Write([]byte(`{"message":"cluster lock is not found"}`)) + require.NoError(t, err) + })), + expectedError: "POST failed", + expectedIs: ErrLockNotFound, + }, { name: "status code 409 Conflict (duplicate ignored)", body: nil, @@ -111,10 +129,20 @@ func TestHttpPost(t *testing.T) { testServerURL, err := url.ParseRequestURI(test.server.URL) require.NoError(t, err) - err = httpPost(context.Background(), testServerURL.JoinPath(test.endpoint), test.body, test.headers) + cl, err := New(test.server.URL) + require.NoError(t, err) + + err = cl.httpPost(context.Background(), testServerURL.JoinPath(test.endpoint), test.body, test.headers) if test.expectedError != "" { require.Error(t, err) require.ErrorContains(t, err, test.expectedError) + + if test.expectedIs != nil { + require.ErrorIs(t, err, test.expectedIs) + require.True(t, z.ContainsField(err, z.Int("status", http.StatusNotFound))) + } else { + require.NotErrorIs(t, err, ErrLockNotFound) + } } else { require.NoError(t, err) } @@ -179,7 +207,10 @@ func TestHttpGet(t *testing.T) { testServerURL, err := url.ParseRequestURI(test.server.URL) require.NoError(t, err) - respBody, err := httpGet(context.Background(), testServerURL.JoinPath(test.endpoint), test.headers) + cl, err := New(test.server.URL) + require.NoError(t, err) + + respBody, err := cl.httpGet(context.Background(), testServerURL.JoinPath(test.endpoint), test.headers) if test.expectedError != "" { require.Error(t, err) require.ErrorContains(t, err, test.expectedError) @@ -195,3 +226,77 @@ func TestHttpGet(t *testing.T) { }) } } + +func TestUserAgent(t *testing.T) { + charonUA := "charon/" + version.Version.String() + + tests := []struct { + name string + options []func(*Client) + want string + }{ + { + name: "default", + want: charonUA, + }, + { + name: "custom user agent", + options: []func(*Client){WithUserAgent("lido-dv-exit/v0.7.0")}, + want: "lido-dv-exit/v0.7.0", + }, + { + name: "empty user agent", + options: []func(*Client){WithUserAgent("")}, + want: charonUA, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var got []string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = append(got, r.Header.Get("User-Agent")) + + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cl, err := New(srv.URL, test.options...) + require.NoError(t, err) + + u, err := url.ParseRequestURI(srv.URL) + require.NoError(t, err) + + ctx := context.Background() + + require.NoError(t, cl.httpPost(ctx, u, nil, nil)) + + body, err := cl.httpGet(ctx, u, nil) + require.NoError(t, err) + require.NoError(t, body.Close()) + + require.NoError(t, cl.httpDelete(ctx, u, nil)) + + require.Equal(t, []string{test.want, test.want, test.want}, got) + }) + } +} + +func TestPostPartialExitsLockNotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"statusCode":404,"message":"cluster lock is not found"}`)) + })) + defer srv.Close() + + cl, err := New(srv.URL) + require.NoError(t, err) + + key, err := k1.GeneratePrivateKey() + require.NoError(t, err) + + err = cl.PostPartialExits(context.Background(), make([]byte, 32), 1, key) + require.ErrorIs(t, err, ErrLockNotFound) + require.NotErrorIs(t, err, ErrNoValue) +} diff --git a/app/obolapi/deposit.go b/app/obolapi/deposit.go index 8cc94dbcaa..dc6a04716a 100644 --- a/app/obolapi/deposit.go +++ b/app/obolapi/deposit.go @@ -68,7 +68,7 @@ func (c Client) PostPartialDeposits(ctx context.Context, lockHash []byte, shareI ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) defer cancel() - err = httpPost(ctx, u, data, nil) + err = c.httpPost(ctx, u, data, nil) if err != nil { return errors.Wrap(err, "http Obol API POST request") } @@ -103,7 +103,7 @@ func (c Client) GetFullDeposit(ctx context.Context, valPubkey string, lockHash [ ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) defer cancel() - respBody, err := httpGet(ctx, u, map[string]string{}) + respBody, err := c.httpGet(ctx, u, map[string]string{}) if err != nil { return []eth2p0.DepositData{}, errors.Wrap(err, "http Obol API GET request") } @@ -120,6 +120,12 @@ func (c Client) GetFullDeposit(ctx context.Context, valPubkey string, lockHash [ return []eth2p0.DepositData{}, errors.Wrap(err, "withdrawal credentials to bytes") } + // SSZ hashing zero-pads short credentials, so a malformed value would otherwise verify. + if len(withdrawalCredentialsBytes) != 32 { + return []eth2p0.DepositData{}, errors.New("withdrawal credentials must be 32 bytes", + z.Str("withdrawal_credentials", dr.WithdrawalCredentials)) + } + // Build a hex-encoded public-share -> 1-based share-index lookup so we can resolve the correct index. partialPubKeyToIdx := make(map[string]int, len(partialPubKeys)) for i, parPubKey := range partialPubKeys { diff --git a/app/obolapi/deposit_test.go b/app/obolapi/deposit_test.go index d61faa72be..d9abe76f76 100644 --- a/app/obolapi/deposit_test.go +++ b/app/obolapi/deposit_test.go @@ -243,3 +243,17 @@ func TestGetFullDeposit_NotEnoughSignatures(t *testing.T) { _, err = cl.GetFullDeposit(t.Context(), f.lock.Validators[0].PublicKeyHex(), f.lock.LockHash, f.lock.Threshold, f.lock.Validators[0].PubShares, f.network) require.ErrorContains(t, err, "not enough partial signatures to meet threshold") } + +func TestGetFullDeposit_ShortWithdrawalCredentials(t *testing.T) { + f := newDepositFixture(t) + // A bare 20 byte address, as older charon deposit sign versions submitted. + f.response.WithdrawalCredentials = "0x000000000000000000000000000000000000dead" + + srv := serveCannedDeposit(t, f.response) + + cl, err := obolapi.New(srv.URL) + require.NoError(t, err) + + _, err = cl.GetFullDeposit(t.Context(), f.lock.Validators[0].PublicKeyHex(), f.lock.LockHash, f.lock.Threshold, f.lock.Validators[0].PubShares, f.network) + require.ErrorContains(t, err, "withdrawal credentials must be 32 bytes") +} diff --git a/app/obolapi/exit.go b/app/obolapi/exit.go index af8be2e4e9..2203bc061e 100644 --- a/app/obolapi/exit.go +++ b/app/obolapi/exit.go @@ -126,7 +126,7 @@ func (c Client) PostPartialExits(ctx context.Context, lockHash []byte, shareInde ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) defer cancel() - err = httpPost(ctx, u, data, nil) + err = c.httpPost(ctx, u, data, nil) if err != nil { return errors.Wrap(err, "http Obol API POST request") } @@ -175,7 +175,7 @@ func (c Client) GetFullExit(ctx context.Context, valPubkey string, lockHash []by return ExitBlob{}, errors.Wrap(err, "k1 sign") } - respBody, err := httpGet(ctx, u, map[string]string{"Authorization": bearerString(lockHashSignature)}) + respBody, err := c.httpGet(ctx, u, map[string]string{"Authorization": bearerString(lockHashSignature)}) if err != nil { return ExitBlob{}, errors.Wrap(err, "http Obol API GET request") } @@ -320,7 +320,7 @@ func (c Client) DeletePartialExit(ctx context.Context, valPubkey string, lockHas return errors.Wrap(err, "k1 sign") } - err = httpDelete(ctx, u, map[string]string{"Authorization": bearerString(lockHashSignature)}) + err = c.httpDelete(ctx, u, map[string]string{"Authorization": bearerString(lockHashSignature)}) if err != nil { return errors.Wrap(err, "http Obol API DELETE request") } diff --git a/app/obolapi/exit_model_test.go b/app/obolapi/exit_model_test.go index e2ce32b9e6..c664ca1dda 100644 --- a/app/obolapi/exit_model_test.go +++ b/app/obolapi/exit_model_test.go @@ -16,15 +16,13 @@ import ( func TestPartialExitRequest(t *testing.T) { pr := obolapi.PartialExitRequest{ - UnsignedPartialExitRequest: obolapi.UnsignedPartialExitRequest{ - PartialExits: []obolapi.ExitBlob{ - { - PublicKey: string(testutil.RandomCorePubKey(t)), - SignedExitMessage: *testutil.RandomExit(), - }, + PartialExits: []obolapi.ExitBlob{ + { + PublicKey: string(testutil.RandomCorePubKey(t)), + SignedExitMessage: *testutil.RandomExit(), }, - ShareIdx: 0, }, + ShareIdx: 0, Signature: testutil.RandomSecp256k1Signature(), } diff --git a/app/obolapi/feerecipient.go b/app/obolapi/feerecipient.go index 3f6cc47291..f93b4ca051 100644 --- a/app/obolapi/feerecipient.go +++ b/app/obolapi/feerecipient.go @@ -22,7 +22,7 @@ const ( fetchFeeRecipientTmpl = "/fee_recipient/" + lockHashPath errNoPartialsRegistrations = "no partial registrations found" - errLockNotFound = "lock not found" + lockNotFoundBody = "lock not found" ) // submitPartialFeeRecipientURL returns the partial fee recipient Obol API URL for a given lock hash. @@ -65,7 +65,7 @@ func (c Client) PostPartialFeeRecipients(ctx context.Context, lockHash []byte, s ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) defer cancel() - err = httpPost(ctx, u, data, nil) + err = c.httpPost(ctx, u, data, nil) if err != nil { return errors.Wrap(err, "http Obol API POST request") } @@ -101,6 +101,7 @@ func (c Client) PostFeeRecipientsFetch(ctx context.Context, lockHash []byte, pub } httpReq.Header.Add("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", c.userAgent) httpResp, err := new(http.Client).Do(httpReq) if err != nil { @@ -119,8 +120,8 @@ func (c Client) PostFeeRecipientsFetch(ctx context.Context, lockHash []byte, pub return FeeRecipientFetchResponse{}, nil } - if strings.Contains(string(body), errLockNotFound) { - return FeeRecipientFetchResponse{}, errors.New("cluster is unknown to the API, publish the lock file first") + if strings.Contains(string(body), lockNotFoundBody) { + return FeeRecipientFetchResponse{}, errors.Wrap(ErrLockNotFound, "cluster is unknown to the API, publish the lock file first") } } diff --git a/app/obolapi/publish.go b/app/obolapi/publish.go index 00503a5a87..12795dbb75 100644 --- a/app/obolapi/publish.go +++ b/app/obolapi/publish.go @@ -39,7 +39,7 @@ func (c Client) PublishLock(ctx context.Context, lock cluster.Lock) error { ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) defer cancel() - err = httpPost(ctx, addr, b, nil) + err = c.httpPost(ctx, addr, b, nil) if err != nil { return err } @@ -65,7 +65,7 @@ func (c Client) PublishDefinition(ctx context.Context, def cluster.Definition, s ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) defer cancel() - return httpPost(ctx, addr, b, headers) + return c.httpPost(ctx, addr, b, headers) } type RequestSignTermsAndConditions struct { @@ -99,5 +99,5 @@ func (c Client) SignTermsAndConditions(ctx context.Context, userAddr string, for ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) defer cancel() - return httpPost(ctx, addr, r, headers) + return c.httpPost(ctx, addr, r, headers) } diff --git a/app/obolapi/test.go b/app/obolapi/test.go index 1927e08761..62c82743ea 100644 --- a/app/obolapi/test.go +++ b/app/obolapi/test.go @@ -24,7 +24,7 @@ func (c Client) PostTestResult(ctx context.Context, jsonTestResult []byte) error ctx, cancel := context.WithTimeout(ctx, c.reqTimeout) defer cancel() - err = httpPost(ctx, u, jsonTestResult, nil) + err = c.httpPost(ctx, u, jsonTestResult, nil) if err != nil { return errors.Wrap(err, "http Obol API POST request") } diff --git a/app/retry/retry.go b/app/retry/retry.go index 2788f55c94..91330a3355 100644 --- a/app/retry/retry.go +++ b/app/retry/retry.go @@ -15,6 +15,7 @@ import ( "testing" "time" + eth2client "github.com/attestantio/go-eth2-client" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" @@ -276,5 +277,11 @@ func isTemporaryBeaconErr(err error) bool { return true } + // The eth2http service fails requests while its connection state is stale or being + // refreshed, which it does on the next request. + if errors.Is(err, eth2client.ErrNotActive) || errors.Is(err, eth2client.ErrNotSynced) { + return true + } + return false } diff --git a/app/retry/retry_test.go b/app/retry/retry_test.go index f94679efaf..1ed92c2ab8 100644 --- a/app/retry/retry_test.go +++ b/app/retry/retry_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + eth2client "github.com/attestantio/go-eth2-client" "github.com/stretchr/testify/require" "github.com/obolnetwork/charon/app/errors" @@ -39,6 +40,28 @@ func TestRetryer(t *testing.T) { }, ExpectBackoffs: 1, }, + { + Name: "one retry on beacon node not active", + Func: func(ctx context.Context, attempt int) error { + if attempt == 0 { + return errors.Wrap(eth2client.ErrNotActive, "beacon api attestation_data") + } + + return nil + }, + ExpectBackoffs: 1, + }, + { + Name: "one retry on beacon node not synced", + Func: func(ctx context.Context, attempt int) error { + if attempt == 0 { + return errors.Wrap(eth2client.ErrNotSynced, "beacon api attestation_data") + } + + return nil + }, + ExpectBackoffs: 1, + }, { Name: "not retryable error", Func: func(ctx context.Context, attempt int) error { diff --git a/app/stacksnipe/stacksnipe.go b/app/stacksnipe/stacksnipe.go index b3a87cb594..7efdb2e464 100644 --- a/app/stacksnipe/stacksnipe.go +++ b/app/stacksnipe/stacksnipe.go @@ -8,6 +8,7 @@ import ( "io/fs" "os" "path/filepath" + "regexp" "strconv" "strings" "time" @@ -38,6 +39,31 @@ var maybeVCs = map[string]struct{}{ "node": {}, } +// sensitiveFlagFragments are the flag name fragments whose values must never leave the process. +// Validator client command lines routinely carry keystore passwords, keymanager bearer tokens and +// the paths of files holding them, none of which belong in a metric label or a log line. +var sensitiveFlagFragments = []string{ + "auth", + "jwt", + "key", + "mnemonic", + "passphrase", + "password", + "secret", + "token", +} + +// redactedValue replaces the value of a sensitive flag in an exported command line. +const redactedValue = "" + +// basicAuthRe matches the userinfo of a URL so a credential embedded in an otherwise innocuous +// flag value (e.g. https://user:pass@host) is redacted while the scheme, user and host stay visible. +var basicAuthRe = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://[^:@/?#\s]+):[^@/?#\s]+@`) + +// queryParamRe matches a single URL query parameter so sensitive ones (?token=..., &jwt=...) +// can be redacted by name while the rest of the value is preserved. +var queryParamRe = regexp.MustCompile(`([?&])([^=&#\s]+)=([^&#\s]*)`) + // StackComponent is a named process of the Ethereum validator stack running on the machine, // whose CLI parameters (also called cmdline) is read from a /proc-like filesystem. type StackComponent struct { @@ -79,6 +105,10 @@ func (i *Instance) Run(ctx context.Context) { return } + log.Info(ctx, "Stack component sniping enabled: command lines of detected validator clients are exported "+ + "to the monitoring endpoint and debug logs, with the values of secret-shaped flags redacted", + z.Str("proc_directory", i.procPath)) + ticker := time.NewTicker(i.interval) defer ticker.Stop() @@ -135,6 +165,157 @@ func snipe(ctx context.Context, procPath string) ([]StackComponent, error) { return ret, nil } +// isSensitiveFlag reports whether name looks like a flag whose value carries secret material. +func isSensitiveFlag(name string) bool { + name = strings.ToLower(strings.TrimLeft(name, "-")) + + for _, fragment := range sensitiveFlagFragments { + if strings.Contains(name, fragment) { + return true + } + } + + return false +} + +// redactValue scrubs secret material embedded inside a value that a flag name check misses: +// basic-auth credentials in a URL and the values of sensitive query parameters. The rest of the +// value (scheme, host, path, innocuous parameters) is preserved so telemetry stays useful. +func redactValue(value string) string { + value = basicAuthRe.ReplaceAllString(value, "${1}:"+redactedValue+"@") + + value = queryParamRe.ReplaceAllStringFunc(value, func(param string) string { + groups := queryParamRe.FindStringSubmatch(param) + if groups[3] != "" && isSensitiveFlag(groups[2]) { + return groups[1] + groups[2] + "=" + redactedValue + } + + return param + }) + + return value +} + +// splitCmdlineBlob tokenises a whole command line that arrived as a single blob, honouring single +// and double quotes so a quoted value containing spaces stays one argument (and is redacted whole) +// rather than being split into leaking fragments. +func splitCmdlineBlob(blob string) []string { + var ( + tokens []string + cur strings.Builder + quote rune + inTok bool + ) + + flush := func() { + if inTok { + tokens = append(tokens, cur.String()) + cur.Reset() + + inTok = false + } + } + + for _, r := range blob { + switch { + case quote != 0: + if r == quote { + quote = 0 + } else { + cur.WriteRune(r) + } + + inTok = true + case r == '\'' || r == '"': + quote = r + inTok = true + case r == ' ' || r == '\t': + flush() + default: + cur.WriteRune(r) + + inTok = true + } + } + + flush() + + return tokens +} + +// redactCmdline redacts the value of every sensitive flag while keeping flag names and innocuous +// values, so the exported command line stays diagnostically useful. It covers the "--flag value" +// and "--flag=value" forms, secrets embedded in a URL value of an innocuous flag (see redactValue), +// and values beginning with "-". Args are normally the NUL separated /proc elements; a whole command +// line handed over as one blob is tokenised (see splitCmdlineBlob) and its sensitive values redacted +// greedily, so redaction fails safe rather than leaking. +func redactCmdline(args []string) []string { + greedy := false + + if len(args) == 1 && strings.ContainsAny(args[0], " \t") { + args = splitCmdlineBlob(args[0]) + greedy = true + } + + redacted := make([]string, 0, len(args)) + + var ( + redactNext bool // the following token(s) are the value of a sensitive flag + valueEmitted bool // the single marker for that value has already been appended + ) + + for _, arg := range args { + if redactNext { + // A long flag means the sensitive flag took no value; stop redacting and reprocess + // this token as a flag. Anything else is (part of) the value. + if !strings.HasPrefix(arg, "--") { + if !valueEmitted { + redacted = append(redacted, redactedValue) + valueEmitted = true + } + + // A NUL separated cmdline gives one value per flag; only the ambiguous blob + // fallback keeps consuming tokens into the same value. + if !greedy { + redactNext = false + } + + continue + } + + redactNext = false + } + + if !strings.HasPrefix(arg, "-") { + redacted = append(redacted, redactValue(arg)) + continue + } + + name, value, hasValue := strings.Cut(arg, "=") + if !isSensitiveFlag(name) { + if hasValue { + redacted = append(redacted, name+"="+redactValue(value)) + } else { + redacted = append(redacted, arg) + } + + continue + } + + if hasValue { + redacted = append(redacted, name+"="+redactedValue) + continue + } + + redactNext = true + valueEmitted = false + + redacted = append(redacted, arg) + } + + return redacted +} + // walkFunc walks a /proc-like filesystem as invoked by filepath.WalkDir, and sends entries to wb. func walkFunc(ctx context.Context, wb chan<- StackComponent) fs.WalkDirFunc { cmdlineDedup := make(map[string]struct{}) @@ -216,7 +397,7 @@ func walkFunc(ctx context.Context, wb chan<- StackComponent) fs.WalkDirFunc { return nil } - cmdLineStr := strings.Join(cmdLine, " ") + cmdLineStr := strings.Join(redactCmdline(cmdLine), " ") log.Debug(ctx, "Detected stack component", z.Str("name", vcName), z.U64("host_pid", hostPID), z.Str("cmdline", cmdLineStr)) diff --git a/app/stacksnipe/stacksnipe_internal_test.go b/app/stacksnipe/stacksnipe_internal_test.go new file mode 100644 index 0000000000..2a6a456a87 --- /dev/null +++ b/app/stacksnipe/stacksnipe_internal_test.go @@ -0,0 +1,134 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package stacksnipe + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRedactCmdline(t *testing.T) { + tests := []struct { + name string + args []string + want []string + }{ + { + name: "sensitive flag space form", + args: []string{"--keystore-password", "SuperSecretPw123!"}, + want: []string{"--keystore-password", redactedValue}, + }, + { + name: "sensitive flag equals form", + args: []string{"--keymanager-auth-token=eyJhbGciOiJSUzI1NiJ9.secret"}, + want: []string{"--keymanager-auth-token=" + redactedValue}, + }, + { + name: "non-sensitive flags untouched", + args: []string{"--datadir", "/var/lib/lighthouse", "--debug-level", "info"}, + want: []string{"--datadir", "/var/lib/lighthouse", "--debug-level", "info"}, + }, + { + name: "value starting with a dash is still redacted", + args: []string{"--keystore-password", "-hunter2", "--debug-level", "info"}, + want: []string{"--keystore-password", redactedValue, "--debug-level", "info"}, + }, + { + name: "sensitive boolean flag followed by a long flag", + args: []string{"--keymanager", "--datadir", "/var/lib/teku"}, + want: []string{"--keymanager", "--datadir", "/var/lib/teku"}, + }, + { + name: "basic-auth credentials in a URL value of an innocuous flag", + args: []string{"--beacon-nodes", "https://user:s3cret@bn.internal:5052"}, + want: []string{"--beacon-nodes", "https://user:" + redactedValue + "@bn.internal:5052"}, + }, + { + name: "basic-auth credentials in the equals form", + args: []string{"--beacon-nodes=https://user:s3cret@bn.internal"}, + want: []string{"--beacon-nodes=https://user:" + redactedValue + "@bn.internal"}, + }, + { + name: "sensitive query parameter in a URL value", + args: []string{"--metrics-url", "https://push.internal/ingest?token=abc123&interval=5s"}, + want: []string{"--metrics-url", "https://push.internal/ingest?token=" + redactedValue + "&interval=5s"}, + }, + { + name: "innocuous query parameters untouched", + args: []string{"--beacon-nodes", "https://bn.internal?timeout=5s&retries=3"}, + want: []string{"--beacon-nodes", "https://bn.internal?timeout=5s&retries=3"}, + }, + { + name: "plain host:port URL without credentials untouched", + args: []string{"--validators-external-signer-url", "https://signer.internal:9000"}, + want: []string{"--validators-external-signer-url", "https://signer.internal:9000"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, redactCmdline(tt.args)) + }) + } +} + +// TestRedactCmdlineBlob covers the fail-safe fallback for a whole command line that arrives as a +// single blob rather than the NUL separated form a real /proc exposes. +func TestRedactCmdlineBlob(t *testing.T) { + tests := []struct { + name string + blob string + wantContain []string + wantNotContain []string + }{ + { + name: "unquoted multi word value redacts every token, not just the first", + blob: "lighthouse vc --keystore-password My Secret Pw --debug-level info", + wantContain: []string{"--keystore-password " + redactedValue, "--debug-level info"}, + wantNotContain: []string{"My", "Secret", "Pw"}, + }, + { + name: "quoted value with spaces stays one argument and is redacted whole", + blob: `lighthouse vc --keystore-password "alpha beta gamma" --debug-level info`, + wantContain: []string{"--keystore-password " + redactedValue, "--debug-level info"}, + wantNotContain: []string{"alpha", "beta", "gamma"}, + }, + { + name: "single quotes are honoured too", + blob: `teku vc --validators-keystore-password 'p a s s'`, + wantContain: []string{"--validators-keystore-password " + redactedValue}, + wantNotContain: []string{"p a s s", "a s s"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := strings.Join(redactCmdline([]string{tt.blob}), " ") + + for _, want := range tt.wantContain { + require.Contains(t, got, want) + } + + for _, notWant := range tt.wantNotContain { + require.NotContains(t, got, notWant) + } + }) + } +} + +func TestIsSensitiveFlag(t *testing.T) { + sensitive := []string{ + "--keystore-password", "--jwt-secret", "--api-token", "--wallet-passphrase", + "--mnemonic-file", "--auth-token", "--graffiti-key", + } + for _, name := range sensitive { + require.True(t, isSensitiveFlag(name), name) + } + + innocuous := []string{"--datadir", "--debug-level", "--suggested-fee-recipient", "--network"} + for _, name := range innocuous { + require.False(t, isSensitiveFlag(name), name) + } +} diff --git a/app/stacksnipe/stacksnipe_test.go b/app/stacksnipe/stacksnipe_test.go index cc8aecb2d7..c77b17eb24 100644 --- a/app/stacksnipe/stacksnipe_test.go +++ b/app/stacksnipe/stacksnipe_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "testing" "time" @@ -103,6 +104,106 @@ func Test_StackSnipe(t *testing.T) { require.NotContains(t, result.cmdlines, extraCmdlines[idx]) } }) + + t.Run("redacts secret flag values", func(t *testing.T) { + baseDir := t.TempDir() + + // A real /proc cmdline is NUL separated, one argument per element. + populateProc(t, baseDir, procEntry{ + pid: 42, + procName: "lighthouse", + cmdline: strings.Join([]string{ + "/usr/bin/lighthouse", "vc", + "--datadir", "/var/lib/lighthouse", + "--validators-keystore-password-file", "/run/secrets/vc-password.txt", + "--keystore-password", "SuperSecretPw123!", + "--suggested-fee-recipient", "0xdeadbeef", + "--debug-level", "info", + }, "\x00"), + }) + + populateProc(t, baseDir, procEntry{ + pid: 43, + procName: "teku", + cmdline: strings.Join([]string{ + "/usr/bin/teku", "validator-client", + "--data-path", "/var/lib/teku", + "--keymanager-auth-token=eyJhbGciOiJSUzI1NiJ9.secret-jwt", + "--validators-external-signer-url", "https://signer.internal:9000", + }, "\x00"), + }) + + var ( + ctx, cancel = context.WithCancel(context.Background()) + resultChan = make(chan snipeResult) + ) + + defer cancel() + + snipe := stacksnipe.NewWithInterval(baseDir, func(names []string, cmdlines []string) { + resultChan <- snipeResult{names: names, cmdlines: cmdlines} + + cancel() + }, 50*time.Millisecond) + + go snipe.Run(ctx) + + result := <-resultChan + require.Len(t, result.cmdlines, 2) + + exported := strings.Join(result.cmdlines, "\n") + + // No secret material survives export, in either the "--flag value" or the "--flag=value" form. + require.NotContains(t, exported, "SuperSecretPw123!") + require.NotContains(t, exported, "/run/secrets/vc-password.txt") + require.NotContains(t, exported, "eyJhbGciOiJSUzI1NiJ9.secret-jwt") + + // The flag names themselves are kept, so the telemetry stays useful. + require.Contains(t, exported, "--validators-keystore-password-file ") + require.Contains(t, exported, "--keystore-password ") + require.Contains(t, exported, "--keymanager-auth-token=") + + // Non-sensitive flags and their values are untouched. + require.Contains(t, exported, "--datadir /var/lib/lighthouse") + require.Contains(t, exported, "--suggested-fee-recipient 0xdeadbeef") + require.Contains(t, exported, "--debug-level info") + require.Contains(t, exported, "--data-path /var/lib/teku") + require.Contains(t, exported, "--validators-external-signer-url https://signer.internal:9000") + }) + + // Redaction must not silently no-op if the whole command line ever arrives as a single + // argument rather than the NUL separated form a real /proc exposes. + t.Run("redacts secret flag values in an unsplit command line", func(t *testing.T) { + baseDir := t.TempDir() + + populateProc(t, baseDir, procEntry{ + pid: 42, + procName: "lighthouse", + cmdline: "/usr/bin/lighthouse vc --keystore-password SuperSecretPw123! --debug-level info", + }) + + var ( + ctx, cancel = context.WithCancel(context.Background()) + resultChan = make(chan snipeResult) + ) + + defer cancel() + + snipe := stacksnipe.NewWithInterval(baseDir, func(names []string, cmdlines []string) { + resultChan <- snipeResult{names: names, cmdlines: cmdlines} + + cancel() + }, 50*time.Millisecond) + + go snipe.Run(ctx) + + result := <-resultChan + require.Len(t, result.cmdlines, 1) + + require.NotContains(t, result.cmdlines[0], "SuperSecretPw123!") + require.Contains(t, result.cmdlines[0], "--keystore-password ") + require.Contains(t, result.cmdlines[0], "--debug-level info") + }) } func populateProc(t *testing.T, base string, entry procEntry) { diff --git a/cmd/create.go b/cmd/create.go index bde7aafe28..7e5c83fbc6 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -2,7 +2,20 @@ package cmd -import "github.com/spf13/cobra" +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/z" + "github.com/obolnetwork/charon/eth2util" +) + +// zeroAddress is not owned by any user, is often associated with token burn & mint/genesis events and used as a generic null address. +// See https://etherscan.io/address/0x0000000000000000000000000000000000000000. +const zeroAddress = "0x0000000000000000000000000000000000000000" func newCreateCmd(cmds ...*cobra.Command) *cobra.Command { root := &cobra.Command{ @@ -15,3 +28,102 @@ func newCreateCmd(cmds ...*cobra.Command) *cobra.Command { return root } + +// validateNetworkConfig returns an error if the network configuration is invalid in the given cluster configuration. +func validateNetworkConfig(network string, testnet eth2util.Network) error { + // Check if custom testnet configuration is provided first (takes precedence over default network). + if testnet.IsNonZero() { + // Add testnet config to supported networks. + eth2util.AddTestNetwork(testnet) + + return nil + } + + if network != "" { + if eth2util.ValidNetwork(network) { + return nil + } + + return errors.New("invalid network specified", z.Str("network", network)) + } + + return errors.New("missing --network flag or testnet config flags") +} + +// isMainOrGnosis returns true if the network is either mainnet or gnosis. +func isMainOrGnosis(network string) bool { + return network == eth2util.Mainnet.Name || network == eth2util.Gnosis.Name +} + +// validateAddresses checks if we have sufficient addresses. It also fills addresses slices if only one is provided. +// +//nolint:revive // Not really confusing here as they are passed as arguments as well. +func validateAddresses(numVals int, feeRecipientAddrs []string, withdrawalAddrs []string) ([]string, []string, error) { + if len(feeRecipientAddrs) != numVals && len(feeRecipientAddrs) != 1 { + return nil, nil, errors.New("mismatching --num-validators and --fee-recipient-addresses", + z.Int("num_validators", numVals), z.Int("addresses", len(feeRecipientAddrs))) + } + + if len(withdrawalAddrs) != numVals && len(withdrawalAddrs) != 1 { + return nil, nil, errors.New("mismatching --num-validators and --withdrawal-addresses", + z.Int("num_validators", numVals), z.Int("addresses", len(withdrawalAddrs))) + } + + if len(feeRecipientAddrs) == 1 { + for i := 1; i < numVals; i++ { + feeRecipientAddrs = append(feeRecipientAddrs, feeRecipientAddrs[0]) + } + } + + if len(withdrawalAddrs) == 1 { + for i := 1; i < numVals; i++ { + withdrawalAddrs = append(withdrawalAddrs, withdrawalAddrs[0]) + } + } + + return feeRecipientAddrs, withdrawalAddrs, nil +} + +// checksumValidatorAddrs validates and checksums both fee recipient and withdrawal addresses. +// A zero fee recipient only warns; a zero withdrawal address on mainnet or gnosis is an error +// if rejectZeroWithdrawal is set, otherwise a warning. +func checksumValidatorAddrs(ctx context.Context, feeRecipientAddrs, withdrawalAddrs []string, network string, rejectZeroWithdrawal bool) (checksummedFeeRecipients []string, checksummedWithdrawals []string, err error) { + checksummedFeeRecipients, err = checksumAddrs(ctx, feeRecipientAddrs, "fee recipient", network, false) + if err != nil { + return nil, nil, err + } + + checksummedWithdrawals, err = checksumAddrs(ctx, withdrawalAddrs, "withdrawal", network, rejectZeroWithdrawal) + if err != nil { + return nil, nil, err + } + + return checksummedFeeRecipients, checksummedWithdrawals, nil +} + +// checksumAddrs validates the provided addresses (see eth2util.ValidateChecksumAddress) of the given kind +// (e.g. "withdrawal" or "fee recipient") and returns their EIP-55 checksummed forms. +// The zero address on mainnet or gnosis is an error if rejectZero is set, otherwise a warning: +// it is legitimate when the generated deposit data is never used, e.g. split existing keys of +// validators that are already active. +func checksumAddrs(ctx context.Context, addrs []string, kind string, network string, rejectZero bool) ([]string, error) { + resp := make([]string, 0, len(addrs)) + for _, addr := range addrs { + checksummed, err := eth2util.ValidateChecksumAddress(addr) + if err != nil { + return nil, errors.Wrap(err, "invalid "+kind+" address", z.Str("addr", addr)) + } + + if isMainOrGnosis(network) && checksummed == zeroAddress { + if rejectZero { + return nil, errors.New("zero "+kind+" address forbidden on this network", z.Str("network", network)) + } + + log.Warn(ctx, "Zero "+kind+" address on "+network+": only continue if the generated deposit data and registrations will never be used", nil, z.Str("network", network)) + } + + resp = append(resp, checksummed) + } + + return resp, nil +} diff --git a/cmd/create_internal_test.go b/cmd/create_internal_test.go new file mode 100644 index 0000000000..b5961a173e --- /dev/null +++ b/cmd/create_internal_test.go @@ -0,0 +1,132 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package cmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/eth2util" +) + +func TestChecksumAddrs(t *testing.T) { + // EIP-55 test vector address. + const checksummed = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" + + tests := []struct { + name string + addrs []string + network string + expected []string + expectedErr string + }{ + { + name: "checksummed", + addrs: []string{checksummed}, + network: eth2util.Mainnet.Name, + expected: []string{checksummed}, + }, + { + name: "lowercase normalised", + addrs: []string{strings.ToLower(checksummed)}, + network: eth2util.Mainnet.Name, + expected: []string{checksummed}, + }, + { + name: "uppercase normalised", + addrs: []string{"0x" + strings.ToUpper(checksummed[2:])}, + network: eth2util.Gnosis.Name, + expected: []string{checksummed}, + }, + { + name: "mixed case bad checksum", + addrs: []string{"0x5AAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"}, + network: eth2util.Hoodi.Name, + expectedErr: "does not match its EIP-55 checksum", + }, + { + name: "too short", + addrs: []string{"0xBAD000BAD000BAD"}, + network: eth2util.Hoodi.Name, + expectedErr: "invalid withdrawal address", + }, + { + name: "no 0x prefix", + addrs: []string{checksummed[2:] + "00"}, + network: eth2util.Hoodi.Name, + expectedErr: "invalid withdrawal address", + }, + { + name: "non hex", + addrs: []string{"0x" + strings.Repeat("zz", 20)}, + network: eth2util.Hoodi.Name, + expectedErr: "invalid withdrawal address", + }, + { + name: "zero address mainnet", + addrs: []string{zeroAddress}, + network: eth2util.Mainnet.Name, + expectedErr: "zero withdrawal address forbidden on this network", + }, + { + name: "zero address gnosis", + addrs: []string{checksummed, zeroAddress}, + network: eth2util.Gnosis.Name, + expectedErr: "zero withdrawal address forbidden on this network", + }, + { + name: "zero address testnet", + addrs: []string{zeroAddress}, + network: eth2util.Hoodi.Name, + expected: []string{zeroAddress}, + }, + { + name: "zero address custom testnet", + addrs: []string{zeroAddress}, + network: "", + expected: []string{zeroAddress}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := checksumAddrs(t.Context(), test.addrs, "withdrawal", test.network, true) + if test.expectedErr != "" { + require.ErrorContains(t, err, test.expectedErr) + return + } + + require.NoError(t, err) + require.Equal(t, test.expected, got) + }) + } +} + +func TestChecksumValidatorAddrs(t *testing.T) { + const checksummed = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" + + ctx := t.Context() + + fee, wd, err := checksumValidatorAddrs(ctx, []string{strings.ToLower(checksummed)}, []string{strings.ToLower(checksummed)}, eth2util.Mainnet.Name, true) + require.NoError(t, err) + require.Equal(t, []string{checksummed}, fee) + require.Equal(t, []string{checksummed}, wd) + + // A zero fee recipient only warns. + fee, _, err = checksumValidatorAddrs(ctx, []string{zeroAddress}, []string{checksummed}, eth2util.Mainnet.Name, true) + require.NoError(t, err) + require.Equal(t, []string{zeroAddress}, fee) + + // A zero withdrawal address is rejected only when asked to. + _, _, err = checksumValidatorAddrs(ctx, []string{checksummed}, []string{zeroAddress}, eth2util.Mainnet.Name, true) + require.ErrorContains(t, err, "zero withdrawal address forbidden on this network") + + _, wd, err = checksumValidatorAddrs(ctx, []string{checksummed}, []string{zeroAddress}, eth2util.Gnosis.Name, false) + require.NoError(t, err) + require.Equal(t, []string{zeroAddress}, wd) + + _, _, err = checksumValidatorAddrs(ctx, []string{checksummed}, []string{"0x5AAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"}, eth2util.Mainnet.Name, true) + require.ErrorContains(t, err, "invalid withdrawal address") +} diff --git a/cmd/createcluster.go b/cmd/createcluster.go index 856fefc95a..20eb036633 100644 --- a/cmd/createcluster.go +++ b/cmd/createcluster.go @@ -49,9 +49,6 @@ import ( ) const ( - // zeroAddress is not owned by any user, is often associated with token burn & mint/genesis events and used as a generic null address. - // See https://etherscan.io/address/0x0000000000000000000000000000000000000000. - zeroAddress = "0x0000000000000000000000000000000000000000" defaultNetwork = "mainnet" minNodes = 3 minThreshold = 2 @@ -191,6 +188,22 @@ func runCreateCluster(ctx context.Context, w io.Writer, conf clusterConfig) erro return err } + var network string + if conf.DefFile != "" { + network, err = eth2util.ForkVersionToNetwork(def.ForkVersion) + } else { + _, network, err = networkFromConfig(conf) + } + + if err != nil { + return err + } + + // Reject insecure keys as soon as the network is known, before any keys are read or generated. + if err := validateInsecureKeysFlag(ctx, conf.InsecureKeys, network); err != nil { + return err + } + var secrets []tbls.PrivateKey // If we're splitting keys, read them from SplitKeysDir and set conf.NumDVs to the amount of @@ -228,12 +241,7 @@ func runCreateCluster(ctx context.Context, w io.Writer, conf clusterConfig) erro } // Validate the provided definition. - err = validateDef(ctx, conf.InsecureKeys, conf.KeymanagerAddrs, def, eth1Cl) - if err != nil { - return err - } - - network, err := eth2util.ForkVersionToNetwork(def.ForkVersion) + err = validateDef(ctx, conf.KeymanagerAddrs, def, network, eth1Cl) if err != nil { return err } @@ -299,11 +307,6 @@ func runCreateCluster(ctx context.Context, w io.Writer, conf clusterConfig) erro } } - network, err := eth2util.ForkVersionToNetwork(def.ForkVersion) - if err != nil { - return err - } - depositDatas, err := createDepositDatas(def.WithdrawalAddresses(), network, secrets, depositAmounts, def.Compounding) if err != nil { return err @@ -913,17 +916,15 @@ func newDefFromConfig(ctx context.Context, conf clusterConfig) (cluster.Definiti return cluster.Definition{}, err } - var forkVersion string - if conf.testnetConfig.IsNonZero() { - // Custom testnet config takes precedence (already registered in validateCreateConfig via validateNetworkConfig). - forkVersion = conf.testnetConfig.GenesisForkVersionHex - } else if conf.Network != "" { - forkVersion, err = eth2util.NetworkToForkVersion(conf.Network) - if err != nil { - return cluster.Definition{}, err - } - } else { - return cluster.Definition{}, errors.New("network not specified, missing --network or --testnet-fork-version") + forkVersion, network, err := networkFromConfig(conf) + if err != nil { + return cluster.Definition{}, err + } + + // Validate and normalise addresses to their EIP-55 checksummed form before writing them into the definition. + feeRecipientAddrs, withdrawalAddrs, err = checksumValidatorAddrs(ctx, feeRecipientAddrs, withdrawalAddrs, network, false) + if err != nil { + return cluster.Definition{}, err } var ops []cluster.Operator @@ -945,6 +946,30 @@ func newDefFromConfig(ctx context.Context, conf clusterConfig) (cluster.Definiti return def, nil } +// networkFromConfig returns the fork version and network name from the network flags. +func networkFromConfig(conf clusterConfig) (forkVersion string, network string, err error) { + if conf.testnetConfig.IsNonZero() { + // Custom testnet config takes precedence (already registered in validateCreateConfig via validateNetworkConfig). + forkVersion = conf.testnetConfig.GenesisForkVersionHex + } else { + forkVersion, err = eth2util.NetworkToForkVersion(conf.Network) + if err != nil { + return "", "", err + } + } + + forkVersionBytes, err := hex.DecodeString(strings.TrimPrefix(forkVersion, "0x")) + if err != nil { + return "", "", errors.Wrap(err, "decode fork version", z.Str("fork_version", forkVersion)) + } + + // Unknown (unregistered custom testnet) fork versions resolve to an empty network name, which is + // never mainnet or gnosis, so the mainnet-only checks are still applied correctly. + network, _ = eth2util.ForkVersionToNetwork(forkVersionBytes) + + return forkVersion, network, nil +} + // newPeer returns a new peer ENR, generating a p2pkey in node directory. func newPeer(clusterDir string, peerIdx int) (enr.Record, *k1.PrivateKey, error) { dir := nodeDir(clusterDir, peerIdx) @@ -1001,7 +1026,8 @@ func nodeDir(clusterDir string, i int) string { } // validateDef returns an error if the provided cluster definition is invalid. -func validateDef(ctx context.Context, insecureKeys bool, keymanagerAddrs []string, def cluster.Definition, eth1Cl eth1wrap.EthClientRunner) error { +// The network is the one resolved from the definition's fork version. +func validateDef(ctx context.Context, keymanagerAddrs []string, def cluster.Definition, network string, eth1Cl eth1wrap.EthClientRunner) error { if def.NumValidators == 0 { return errors.New("--num-validators not set, specify at least one") } @@ -1020,26 +1046,17 @@ func validateDef(ctx context.Context, insecureKeys bool, keymanagerAddrs []strin } } - network, err := eth2util.ForkVersionToNetwork(def.ForkVersion) - if err != nil { - return err - } - - if insecureKeys && isMainOrGnosis(network) { - return errors.New("insecure keys not supported on mainnet") - } else if insecureKeys { - log.Warn(ctx, "Insecure keystores configured. ONLY DO THIS DURING TESTING", nil) - } - if def.Name == "" { return errors.New("name not provided") } - if err = def.VerifyHashes(); err != nil { + err := def.VerifyHashes() + if err != nil { return err } - if err = def.VerifySignatures(eth1Cl); err != nil { + err = def.VerifySignatures(eth1Cl) + if err != nil { return err } @@ -1051,7 +1068,27 @@ func validateDef(ctx context.Context, insecureKeys bool, keymanagerAddrs []strin return errors.New("unsupported consensus protocol", z.Str("protocol", def.ConsensusProtocol)) } - return validateWithdrawalAddrs(def.WithdrawalAddresses(), network) + // Addresses in an existing definition are covered by its hashes and signatures, so they are + // only validated here, not normalised. + _, _, err = checksumValidatorAddrs(ctx, def.FeeRecipientAddresses(), def.WithdrawalAddresses(), network, true) + + return err +} + +// validateInsecureKeysFlag returns an error if insecure keys are requested on mainnet or gnosis, +// otherwise it logs a warning when insecure keys are requested. +func validateInsecureKeysFlag(ctx context.Context, insecureKeys bool, network string) error { + if !insecureKeys { + return nil + } + + if isMainOrGnosis(network) { + return errors.New("insecure keys not supported on mainnet") + } + + log.Warn(ctx, "Insecure keystores configured. ONLY DO THIS DURING TESTING", nil) + + return nil } // aggSign returns a bls aggregate signatures of the message signed by all the shares. @@ -1188,35 +1225,6 @@ func hasDistinctAddrs(addrs []string) bool { return false } -// validateAddresses checks if we have sufficient addresses. It also fills addresses slices if only one is provided. -// -//nolint:revive // Not really confusing here as they are passed as arguments as well. -func validateAddresses(numVals int, feeRecipientAddrs []string, withdrawalAddrs []string) ([]string, []string, error) { - if len(feeRecipientAddrs) != numVals && len(feeRecipientAddrs) != 1 { - return nil, nil, errors.New("mismatching --num-validators and --fee-recipient-addresses", - z.Int("num_validators", numVals), z.Int("addresses", len(feeRecipientAddrs))) - } - - if len(withdrawalAddrs) != numVals && len(withdrawalAddrs) != 1 { - return nil, nil, errors.New("mismatching --num-validators and --withdrawal-addresses", - z.Int("num_validators", numVals), z.Int("addresses", len(withdrawalAddrs))) - } - - if len(feeRecipientAddrs) == 1 { - for i := 1; i < numVals; i++ { - feeRecipientAddrs = append(feeRecipientAddrs, feeRecipientAddrs[0]) - } - } - - if len(withdrawalAddrs) == 1 { - for i := 1; i < numVals; i++ { - withdrawalAddrs = append(withdrawalAddrs, withdrawalAddrs[0]) - } - } - - return feeRecipientAddrs, withdrawalAddrs, nil -} - func builderRegistrationFromETH2(reg core.VersionedSignedValidatorRegistration) (cluster.BuilderRegistration, error) { feeRecipient, err := reg.FeeRecipient() if err != nil { @@ -1248,24 +1256,3 @@ func builderRegistrationFromETH2(reg core.VersionedSignedValidatorRegistration) Signature: reg.Signature(), }, nil } - -// validateNetworkConfig returns an error if the network configuration is invalid in the given cluster configuration. -func validateNetworkConfig(network string, testnet eth2util.Network) error { - // Check if custom testnet configuration is provided first (takes precedence over default network). - if testnet.IsNonZero() { - // Add testnet config to supported networks. - eth2util.AddTestNetwork(testnet) - - return nil - } - - if network != "" { - if eth2util.ValidNetwork(network) { - return nil - } - - return errors.New("invalid network specified", z.Str("network", network)) - } - - return errors.New("missing --network flag or testnet config flags") -} diff --git a/cmd/createcluster_internal_test.go b/cmd/createcluster_internal_test.go index 47dc8dc54f..ebb8aded6f 100644 --- a/cmd/createcluster_internal_test.go +++ b/cmd/createcluster_internal_test.go @@ -472,13 +472,13 @@ func TestValidateDef(t *testing.T) { def.ForkVersion = gnosis - err = validateDef(ctx, false, conf.KeymanagerAddrs, def, nil) + err = validateDef(ctx, conf.KeymanagerAddrs, def, eth2util.Gnosis.Name, nil) require.Error(t, err, "zero address") }) t.Run("fork versions", func(t *testing.T) { def := definition - err = validateDef(ctx, false, conf.KeymanagerAddrs, def, nil) + err = validateDef(ctx, conf.KeymanagerAddrs, def, eth2util.Goerli.Name, nil) require.NoError(t, err) mainnet, err := hex.DecodeString(strings.TrimPrefix(eth2util.Mainnet.GenesisForkVersionHex, "0x")) @@ -486,7 +486,7 @@ func TestValidateDef(t *testing.T) { def.ForkVersion = mainnet - err = validateDef(ctx, conf.InsecureKeys, conf.KeymanagerAddrs, def, nil) + err = validateDef(ctx, conf.KeymanagerAddrs, def, eth2util.Mainnet.Name, nil) require.Error(t, err, "zero address") }) @@ -494,41 +494,35 @@ func TestValidateDef(t *testing.T) { conf := conf conf.KeymanagerAddrs = []string{"127.0.0.1:1234"} - err = validateDef(ctx, true, conf.KeymanagerAddrs, definition, nil) + err = validateDef(ctx, conf.KeymanagerAddrs, definition, eth2util.Goerli.Name, nil) require.Error(t, err) }) - t.Run("insecure keys", func(t *testing.T) { - conf := conf - err = validateDef(ctx, true, conf.KeymanagerAddrs, definition, nil) // Validate with insecure keys set to true - require.NoError(t, err) - }) - t.Run("insufficient number of nodes", func(t *testing.T) { def := definition def.Operators = nil - err = validateDef(ctx, conf.InsecureKeys, conf.KeymanagerAddrs, def, nil) + err = validateDef(ctx, conf.KeymanagerAddrs, def, eth2util.Goerli.Name, nil) require.ErrorContains(t, err, "insufficient number of nodes") }) t.Run("name not provided", func(t *testing.T) { def := definition def.Name = "" - err = validateDef(ctx, conf.InsecureKeys, conf.KeymanagerAddrs, def, nil) + err = validateDef(ctx, conf.KeymanagerAddrs, def, eth2util.Goerli.Name, nil) require.ErrorContains(t, err, "name not provided") }) t.Run("zero validators provided", func(t *testing.T) { def := definition def.NumValidators = 0 - err = validateDef(ctx, conf.InsecureKeys, conf.KeymanagerAddrs, def, nil) + err = validateDef(ctx, conf.KeymanagerAddrs, def, eth2util.Goerli.Name, nil) require.ErrorContains(t, err, "--num-validators not set, specify at least one") }) t.Run("invalid hash", func(t *testing.T) { def := remoteDef def.NumValidators = 3 - err = validateDef(ctx, conf.InsecureKeys, conf.KeymanagerAddrs, def, nil) + err = validateDef(ctx, conf.KeymanagerAddrs, def, eth2util.Sepolia.Name, nil) require.ErrorContains(t, err, "invalid config hash") }) @@ -537,18 +531,160 @@ func TestValidateDef(t *testing.T) { def.NumValidators = 3 def, err = def.SetDefinitionHashes() require.NoError(t, err) - err = validateDef(ctx, conf.InsecureKeys, conf.KeymanagerAddrs, def, nil) + err = validateDef(ctx, conf.KeymanagerAddrs, def, eth2util.Sepolia.Name, nil) require.ErrorContains(t, err, "invalid creator config signature") }) t.Run("unsupported consensus protocol", func(t *testing.T) { def := definition def.ConsensusProtocol = "unreal" - err = validateDef(ctx, false, conf.KeymanagerAddrs, def, nil) + err = validateDef(ctx, conf.KeymanagerAddrs, def, eth2util.Goerli.Name, nil) require.Error(t, err, "unsupported consensus protocol") }) } +// TestNewDefFromConfigValidation verifies that creating a cluster from flags (no --definition-file) +// applies the same address checks as a definition file. +func TestNewDefFromConfigValidation(t *testing.T) { + ctx := t.Context() + badChecksum := "0xFB6916095ca1df60bB79Ce92cE3Ea74c37c5d359" + + tests := []struct { + name string + network string + feeRecipient string + withdrawal string + expectedErr string + }{ + { + name: "mainnet valid", + network: eth2util.Mainnet.Name, + feeRecipient: validEthAddr, + withdrawal: validEthAddr, + }, + { + name: "mainnet lowercase normalised", + network: eth2util.Mainnet.Name, + feeRecipient: strings.ToLower(validEthAddr), + withdrawal: strings.ToLower(validEthAddr), + }, + { + name: "testnet zero addresses", + network: eth2util.Hoodi.Name, + feeRecipient: zeroAddress, + withdrawal: zeroAddress, + }, + { + // Zero addresses only warn: split existing keys of active validators never use deposit data. + name: "mainnet zero withdrawal", + network: eth2util.Mainnet.Name, + feeRecipient: validEthAddr, + withdrawal: zeroAddress, + }, + { + name: "mainnet zero fee recipient", + network: eth2util.Mainnet.Name, + feeRecipient: zeroAddress, + withdrawal: validEthAddr, + }, + { + name: "bad withdrawal checksum", + network: eth2util.Hoodi.Name, + feeRecipient: validEthAddr, + withdrawal: badChecksum, + expectedErr: "invalid withdrawal address", + }, + { + name: "malformed fee recipient", + network: eth2util.Hoodi.Name, + feeRecipient: "0xabc", + withdrawal: validEthAddr, + expectedErr: "invalid fee recipient address", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + conf := clusterConfig{ + Name: "test", + NumNodes: 4, + NumDVs: 2, + Threshold: 3, + TargetGasLimit: 30000000, + Network: test.network, + FeeRecipientAddrs: []string{test.feeRecipient}, + WithdrawalAddrs: []string{test.withdrawal}, + } + + def, err := newDefFromConfig(ctx, conf) + if test.expectedErr != "" { + require.ErrorContains(t, err, test.expectedErr) + return + } + + require.NoError(t, err) + + wantFee, err := eth2util.ChecksumAddress(test.feeRecipient) + require.NoError(t, err) + wantWd, err := eth2util.ChecksumAddress(test.withdrawal) + require.NoError(t, err) + + require.Equal(t, []string{wantFee, wantFee}, def.FeeRecipientAddresses()) + require.Equal(t, []string{wantWd, wantWd}, def.WithdrawalAddresses()) + }) + } +} + +// TestCreateClusterInsecureKeys verifies insecure keys are rejected on mainnet and gnosis before any keys are read. +func TestCreateClusterInsecureKeys(t *testing.T) { + mainnetForkVersion, err := eth2util.NetworkToForkVersionBytes(eth2util.Mainnet.Name) + require.NoError(t, err) + + lock, _, _ := cluster.NewForT(t, 1, 3, 4, 0, rand.New(rand.NewSource(0)), func(d *cluster.Definition) { + d.ForkVersion = mainnetForkVersion + }) + + defBytes, err := json.Marshal(lock.Definition) + require.NoError(t, err) + + mainnetDefFile := filepath.Join(t.TempDir(), "cluster-definition.json") + require.NoError(t, os.WriteFile(mainnetDefFile, defBytes, 0o644)) + + tests := []struct { + name string + network string + defFile string + }{ + {name: "mainnet flag", network: eth2util.Mainnet.Name}, + {name: "gnosis flag", network: eth2util.Gnosis.Name}, + // The definition file's network takes precedence over the network flag. + {name: "mainnet definition file", network: eth2util.Hoodi.Name, defFile: mainnetDefFile}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + conf := clusterConfig{ + Name: t.Name(), + ClusterDir: t.TempDir(), + DefFile: test.defFile, + NumNodes: 4, + Threshold: 3, + Network: test.network, + FeeRecipientAddrs: []string{validEthAddr}, + WithdrawalAddrs: []string{validEthAddr}, + TargetGasLimit: 30000000, + InsecureKeys: true, + // A missing keys dir proves the check runs before any keys are read. + SplitKeys: true, + SplitKeysDir: filepath.Join(t.TempDir(), "missing"), + } + + err := runCreateCluster(t.Context(), io.Discard, conf) + require.ErrorContains(t, err, "insecure keys not supported") + }) + } +} + func TestSplitKeys(t *testing.T) { tests := []struct { name string @@ -733,7 +869,7 @@ func TestTargetGasLimit(t *testing.T) { NumNodes: 4, Threshold: 3, NumDVs: 1, - Network: defaultNetwork, + Network: eth2util.Hoodi.Name, WithdrawalAddrs: []string{zeroAddress}, FeeRecipientAddrs: []string{zeroAddress}, InsecureKeys: true, @@ -749,7 +885,7 @@ func TestTargetGasLimit(t *testing.T) { NumNodes: 4, Threshold: 3, NumDVs: 1, - Network: defaultNetwork, + Network: eth2util.Hoodi.Name, WithdrawalAddrs: []string{zeroAddress}, FeeRecipientAddrs: []string{zeroAddress}, InsecureKeys: true, diff --git a/cmd/createdkg.go b/cmd/createdkg.go index 5d5d650a6c..eb11fb600d 100644 --- a/cmd/createdkg.go +++ b/cmd/createdkg.go @@ -142,7 +142,8 @@ func runCreateDKG(ctx context.Context, conf createDKGConfig) (err error) { return err } - if err = validateWithdrawalAddrs(conf.WithdrawalAddrs, conf.Network); err != nil { + conf.FeeRecipientAddrs, conf.WithdrawalAddrs, err = checksumValidatorAddrs(ctx, conf.FeeRecipientAddrs, conf.WithdrawalAddrs, conf.Network, true) + if err != nil { return err } @@ -193,13 +194,11 @@ func runCreateDKG(ctx context.Context, conf createDKGConfig) (err error) { if conf.testnetConfig.IsNonZero() { // Custom testnet config takes precedence (already registered in validateDKGConfig via validateNetworkConfig). forkVersion = conf.testnetConfig.GenesisForkVersionHex - } else if conf.Network != "" { + } else { forkVersion, err = eth2util.NetworkToForkVersion(conf.Network) if err != nil { return err } - } else { - return errors.New("network not specified, missing --network or --testnet-fork-version") } var privKey *k1.PrivateKey @@ -276,25 +275,6 @@ func runCreateDKG(ctx context.Context, conf createDKGConfig) (err error) { return nil } -// validateWithdrawalAddrs returns an error if any of the provided withdrawal addresses is invalid. -func validateWithdrawalAddrs(addrs []string, network string) error { - for _, addr := range addrs { - checksumAddr, err := eth2util.ChecksumAddress(addr) - if err != nil { - return errors.Wrap(err, "invalid withdrawal address", z.Str("addr", addr)) - } else if checksumAddr != addr { - return errors.New("invalid checksummed address", z.Str("addr", addr)) - } - - // We cannot allow a zero withdrawal address on mainnet or gnosis. - if isMainOrGnosis(network) && addr == zeroAddress { - return errors.New("zero address forbidden on this network", z.Str("network", network)) - } - } - - return nil -} - // validateDKGConfig returns an error if any of the provided config parameter is invalid. func validateDKGConfig(numOperators int, network string, testnet eth2util.Network, depositAmounts []int, consensusProtocol string, compounding bool) error { // Don't allow cluster size to be less than 3. @@ -321,11 +301,6 @@ func validateDKGConfig(numOperators int, network string, testnet eth2util.Networ return nil } -// isMainOrGnosis returns true if the network is either mainnet or gnosis. -func isMainOrGnosis(network string) bool { - return network == eth2util.Mainnet.Name || network == eth2util.Gnosis.Name -} - func generateLaunchpadLink(configHash []byte, network string) string { var networkLink string diff --git a/cmd/createdkg_internal_test.go b/cmd/createdkg_internal_test.go index 85675de39d..5fcc4dec13 100644 --- a/cmd/createdkg_internal_test.go +++ b/cmd/createdkg_internal_test.go @@ -4,6 +4,7 @@ package cmd import ( "context" + "encoding/json" "os" "path" "strings" @@ -11,6 +12,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/obolnetwork/charon/cluster" "github.com/obolnetwork/charon/eth2util" "github.com/obolnetwork/charon/testutil" ) @@ -121,6 +123,110 @@ func TestCreateDkgInvalid(t *testing.T) { } } +func TestCreateDkgAddresses(t *testing.T) { + enrs := []string{ + "enr:-JG4QFI0llFYxSoTAHm24OrbgoVx77dL6Ehl1Ydys39JYoWcBhiHrRhtGXDTaygWNsEWFb1cL7a1Bk0klIdaNuXplKWGAYGv0Gt7gmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQL6bcis0tFXnbqG4KuywxT5BLhtmijPFApKCDJNl3mXFYN0Y3CCDhqDdWRwgg4u", + "enr:-JG4QPnqHa7FU3PBqGxpV5L0hjJrTUqv8Wl6_UTHt-rELeICWjvCfcVfwmax8xI_eJ0ntI3ly9fgxAsmABud6-yBQiuGAYGv0iYPgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQMLLCMZ5Oqi_sdnBfdyhmysZMfFm78PgF7Y9jitTJPSroN0Y3CCPoODdWRwgj6E", + "enr:-JG4QDKNYm_JK-w6NuRcUFKvJAlq2L4CwkECelzyCVrMWji4YnVRn8AqQEL5fTQotPL2MKxiKNmn2k6XEINtq-6O3Z2GAYGvzr_LgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQKlO7fSaBa3h48CdM-qb_Xb2_hSrJOy6nNjR0mapAqMboN0Y3CCDhqDdWRwgg4u", + "enr:-JG4QKu734_MXQklKrNHe9beXIsIV5bqv58OOmsjWmp6CF5vJSHNinYReykn7-IIkc5-YsoF8Hva1Q3pl7_gUj5P9cOGAYGv0jBLgmlkgnY0gmlwhH8AAAGJc2VjcDI1NmsxoQMM3AvPhXGCUIzBl9VFOw7VQ6_m8dGifVfJ1YXrvZsaZoN0Y3CCDhqDdWRwgg4u", + } + + badChecksum := "0xFB6916095ca1df60bB79Ce92cE3Ea74c37c5d359" + + tests := []struct { + name string + network string + feeRecipient string + withdrawal string + expectedErr string + }{ + { + name: "checksummed", + network: eth2util.Mainnet.Name, + feeRecipient: validEthAddr, + withdrawal: validEthAddr, + }, + { + name: "lowercase normalised", + network: eth2util.Mainnet.Name, + feeRecipient: strings.ToLower(validEthAddr), + withdrawal: strings.ToLower(validEthAddr), + }, + { + name: "invalid fee recipient checksum", + network: eth2util.Mainnet.Name, + feeRecipient: badChecksum, + withdrawal: validEthAddr, + expectedErr: "invalid fee recipient address", + }, + { + name: "malformed fee recipient", + network: eth2util.Hoodi.Name, + feeRecipient: "0x1234", + withdrawal: validEthAddr, + expectedErr: "invalid fee recipient address", + }, + { + // A zero fee recipient only warns. + name: "zero fee recipient on mainnet", + network: eth2util.Mainnet.Name, + feeRecipient: zeroAddress, + withdrawal: validEthAddr, + }, + { + name: "zero fee recipient on testnet", + network: eth2util.Hoodi.Name, + feeRecipient: zeroAddress, + withdrawal: validEthAddr, + }, + { + name: "zero withdrawal on gnosis", + network: eth2util.Gnosis.Name, + feeRecipient: validEthAddr, + withdrawal: zeroAddress, + expectedErr: "zero withdrawal address forbidden on this network", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := t.TempDir() + conf := createDKGConfig{ + OutputDir: dir, + NumValidators: 1, + FeeRecipientAddrs: []string{test.feeRecipient}, + WithdrawalAddrs: []string{test.withdrawal}, + Network: test.network, + DKGAlgo: "default", + TargetGasLimit: 30000000, + OperatorENRs: enrs, + } + + err := runCreateDKG(context.Background(), conf) + if test.expectedErr != "" { + require.ErrorContains(t, err, test.expectedErr) + return + } + + require.NoError(t, err) + + b, err := os.ReadFile(path.Join(dir, "cluster-definition.json")) + require.NoError(t, err) + + var def cluster.Definition + require.NoError(t, json.Unmarshal(b, &def)) + + wantFee, err := eth2util.ChecksumAddress(test.feeRecipient) + require.NoError(t, err) + wantWd, err := eth2util.ChecksumAddress(test.withdrawal) + require.NoError(t, err) + + require.Equal(t, []string{wantFee}, def.FeeRecipientAddresses()) + require.Equal(t, []string{wantWd}, def.WithdrawalAddresses()) + }) + } +} + func TestRequireOperatorENRFlag(t *testing.T) { tests := []struct { name string @@ -170,31 +276,6 @@ func TestExistingClusterDefinition(t *testing.T) { require.EqualError(t, cmd.Execute(), "existing cluster-definition.json found. Try again after deleting it") } -func TestValidateWithdrawalAddr(t *testing.T) { - t.Run("ok", func(t *testing.T) { - addrs := []string{validEthAddr} - err := validateWithdrawalAddrs(addrs, eth2util.Goerli.Name) - require.NoError(t, err) - }) - - t.Run("invalid network", func(t *testing.T) { - err := validateWithdrawalAddrs([]string{zeroAddress}, eth2util.Mainnet.Name) - require.ErrorContains(t, err, "zero address forbidden on this network") - }) - - t.Run("invalid withdrawal address", func(t *testing.T) { - addrs := []string{"0xBAD000BAD000BAD"} - err := validateWithdrawalAddrs(addrs, eth2util.Gnosis.Name) - require.ErrorContains(t, err, "invalid withdrawal address") - }) - - t.Run("invalid checksum", func(t *testing.T) { - addrs := []string{"0x000BAD0000000BAD0000000BAD0000000BAD0000"} - err := validateWithdrawalAddrs(addrs, eth2util.Gnosis.Name) - require.ErrorContains(t, err, "invalid checksummed address") - }) -} - func TestValidateDKGConfig(t *testing.T) { t.Run("insufficient ENRs", func(t *testing.T) { numOperators := 2 diff --git a/cmd/depositfetch_internal_test.go b/cmd/depositfetch_internal_test.go index 66c93a5804..0d44a0bf9c 100644 --- a/cmd/depositfetch_internal_test.go +++ b/cmd/depositfetch_internal_test.go @@ -3,6 +3,7 @@ package cmd import ( + "encoding/hex" "encoding/json" "fmt" "math/rand" @@ -16,11 +17,22 @@ import ( "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/eth2util/deposit" "github.com/obolnetwork/charon/tbls" "github.com/obolnetwork/charon/testutil/obolapimock" ) func TestDepositFetchValid(t *testing.T) { + for _, compounding := range []bool{false, true} { + t.Run(fmt.Sprintf("compounding=%v", compounding), func(t *testing.T) { + testDepositFetchValid(t, compounding) + }) + } +} + +func testDepositFetchValid(t *testing.T, compounding bool) { + t.Helper() + ctx := t.Context() ctx = log.WithCtx(ctx, z.Str("test_case", t.Name())) @@ -36,8 +48,15 @@ func TestDepositFetchValid(t *testing.T) { operatorAmt, 0, random, + func(d *cluster.Definition) { d.Compounding = compounding }, ) + // Withdrawal credentials prefix, 0x02 for compounding clusters. + prefix := "01" + if compounding { + prefix = "02" + } + root := t.TempDir() operatorShares := make([][]tbls.PrivateKey, operatorAmt) @@ -74,8 +93,9 @@ func TestDepositFetchValid(t *testing.T) { } signConfig := depositSignConfig{ - depositConfig: config, - WithdrawalAddresses: []string{"0x0100000000000000000000000000000000000000000000000000000000001234", "0x0100000000000000000000000000000000000000000000000000000000001235"}, + depositConfig: config, + // One plain (checksummed) address and one full withdrawal credential. + WithdrawalAddresses: []string{"0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "0x" + prefix + "00000000000000000000000000000000000000000000000000000000001235"}, DepositAmounts: []uint{32, 1}, } @@ -100,6 +120,23 @@ func TestDepositFetchValid(t *testing.T) { err = runDepositFetch(ctx, fetchConfig) require.NoError(t, err) + + depositDatas, err := deposit.ReadDepositDataFiles(fetchConfig.DepositDataDir) + require.NoError(t, err) + require.Len(t, depositDatas, 2) + + expectedCreds := map[string]string{ + lock.Validators[0].PublicKeyHex(): prefix + "00000000000000000000005aaeb6053f3e94c9b9a09f33669435e7ef1beaed", + lock.Validators[1].PublicKeyHex(): prefix + "00000000000000000000000000000000000000000000000000000000001235", + } + + for _, dds := range depositDatas { + require.Len(t, dds, 2) + + for _, dd := range dds { + require.Equal(t, expectedCreds[dd.PublicKey.String()], hex.EncodeToString(dd.WithdrawalCredentials)) + } + } } func TestDepositFetchCLI(t *testing.T) { diff --git a/cmd/depositsign.go b/cmd/depositsign.go index e1a9a7916e..e3c7867ed6 100644 --- a/cmd/depositsign.go +++ b/cmd/depositsign.go @@ -59,7 +59,7 @@ func newDepositSignCmd(runFunc func(context.Context, depositSignConfig) error) * } func bindDepositSignFlags(cmd *cobra.Command, config *depositSignConfig) { - cmd.Flags().StringSliceVar(&config.WithdrawalAddresses, "withdrawal-addresses", []string{}, "[REQUIRED] Withdrawal addresses for which the new deposits will be signed. Either a single address for all specified validator-public-keys or one address per key should be specified.") + cmd.Flags().StringSliceVar(&config.WithdrawalAddresses, "withdrawal-addresses", []string{}, "[REQUIRED] Withdrawal addresses for which the new deposits will be signed, converted to 0x01 or 0x02 (compounding clusters) withdrawal credentials. Full 32 byte withdrawal credentials are also accepted. Either a single address for all specified validator-public-keys or one address per key should be specified.") cmd.Flags().UintSliceVar(&config.DepositAmounts, "deposit-amounts", []uint{32}, "Comma separated list of partial deposit amounts (integers) in ETH.") cmd.Flags().BoolVar(&config.AllowIncompleteKeystores, "allow-incomplete-keystores", false, "Allow the validator keys directory to contain key shares for only a subset of the cluster's validators. The command operates on the validators whose key shares are present.") } @@ -111,24 +111,29 @@ func runDepositSign(ctx context.Context, config depositSignConfig) error { pubkeys = append(pubkeys, blsPK) } + network, err := eth2util.ForkVersionToNetwork(cl.ForkVersion) + if err != nil { + return err + } + withdrawalAddrs := [][]byte{} for _, wAddr := range config.WithdrawalAddresses { - withdrawalAddr, err := hex.DecodeString(strings.TrimPrefix(wAddr, "0x")) + creds, err := deposit.WithdrawalCredsFromAddrOrCreds(wAddr, cl.Compounding) if err != nil { - return errors.Wrap(err, "decode withdrawal address", z.Str("withdrawal_address", wAddr)) + return err + } + + // We cannot allow a zero withdrawal address on mainnet or gnosis. + if isMainOrGnosis(network) && [20]byte(creds[12:]) == [20]byte{} { + return errors.New("zero withdrawal address forbidden on this network", z.Str("network", network)) } - withdrawalAddrs = append(withdrawalAddrs, withdrawalAddr) + withdrawalAddrs = append(withdrawalAddrs, creds[:]) } depositDatas := []eth2p0.DepositData{} - network, err := eth2util.ForkVersionToNetwork(cl.ForkVersion) - if err != nil { - return err - } - for i, pubkey := range pubkeys { for _, amount := range config.DepositAmounts { if !cl.Compounding && (amount < 1 || amount > 32) { diff --git a/cmd/depositsign_internal_test.go b/cmd/depositsign_internal_test.go index fc821b1640..c23230fc21 100644 --- a/cmd/depositsign_internal_test.go +++ b/cmd/depositsign_internal_test.go @@ -8,6 +8,7 @@ import ( "math/rand" "net/http/httptest" "path/filepath" + "strings" "testing" "time" @@ -16,6 +17,7 @@ import ( "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/eth2util" "github.com/obolnetwork/charon/tbls" "github.com/obolnetwork/charon/testutil/obolapimock" ) @@ -75,13 +77,90 @@ func TestDepositSignValid(t *testing.T) { signConfig := depositSignConfig{ depositConfig: config, - WithdrawalAddresses: []string{"0x0100000000000000000000000000000000000000000000000000000000001234"}, + WithdrawalAddresses: []string{"0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"}, DepositAmounts: []uint{32}, } require.NoError(t, runDepositSign(ctx, signConfig), "operator index submit deposit sign: %v", idx) } +func TestDepositSignWithdrawalAddresses(t *testing.T) { + const zeroAddr = "0x0000000000000000000000000000000000000000" + + tests := []struct { + name string + mainnet bool + compounding bool + addr string + expectedErr string + }{ + {name: "mainnet zero address", mainnet: true, addr: zeroAddr, expectedErr: "zero withdrawal address forbidden"}, + {name: "mainnet zero credentials", mainnet: true, addr: "0x01" + strings.Repeat("00", 31), expectedErr: "zero withdrawal address forbidden"}, + {name: "mainnet address", mainnet: true, addr: "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"}, + {name: "testnet zero address", addr: zeroAddr}, + {name: "compounding address", compounding: true, addr: "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"}, + {name: "compounding 0x02 credentials", compounding: true, addr: "0x02" + strings.Repeat("00", 11) + "5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"}, + {name: "compounding 0x01 credentials", compounding: true, addr: "0x01" + strings.Repeat("00", 11) + "5aaeb6053f3e94c9b9a09f33669435e7ef1beaed", expectedErr: "prefix does not match"}, + {name: "bad checksum", addr: "0x5AAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", expectedErr: "does not match its EIP-55 checksum"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := log.WithCtx(t.Context(), z.Str("test_case", t.Name())) + + const operatorAmt = 4 + + lock, enrs, keyShares := cluster.NewForT(t, 1, operatorAmt, operatorAmt, 0, rand.New(rand.NewSource(0)), func(d *cluster.Definition) { + d.Compounding = test.compounding + if test.mainnet { + forkVersion, err := eth2util.NetworkToForkVersionBytes(eth2util.Mainnet.Name) + require.NoError(t, err) + + d.ForkVersion = forkVersion + } + }) + + operatorShares := make([][]tbls.PrivateKey, operatorAmt) + for opIdx := range operatorAmt { + operatorShares[opIdx] = append(operatorShares[opIdx], keyShares[0][opIdx]) + } + + lockJSON, err := json.Marshal(lock) + require.NoError(t, err) + + root := t.TempDir() + writeAllLockData(t, root, enrs, operatorShares, lockJSON) + + handler, addLockFiles := obolapimock.MockServer(false, nil) + + srv := httptest.NewServer(handler) + defer srv.Close() + + addLockFiles(lock) + + baseDir := filepath.Join(root, "op0") + signConfig := depositSignConfig{ + ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + WithdrawalAddresses: []string{test.addr}, + DepositAmounts: []uint{32}, + } + + err = runDepositSign(ctx, signConfig) + if test.expectedErr != "" { + require.ErrorContains(t, err, test.expectedErr) + return + } + + require.NoError(t, err) + }) + } +} + func TestDepositSignCLI(t *testing.T) { tests := []struct { name string diff --git a/cmd/edit_addvalidators.go b/cmd/edit_addvalidators.go index bb52667045..145dd49045 100644 --- a/cmd/edit_addvalidators.go +++ b/cmd/edit_addvalidators.go @@ -17,6 +17,7 @@ import ( "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/cluster" "github.com/obolnetwork/charon/dkg" + "github.com/obolnetwork/charon/eth2util" "github.com/obolnetwork/charon/eth2util/deposit" "github.com/obolnetwork/charon/eth2util/keystore" "github.com/obolnetwork/charon/tbls" @@ -103,6 +104,16 @@ func runAddValidators(ctx context.Context, conf addValidatorsConfig) error { return err } + // Unknown (custom testnet) fork versions resolve to an empty network, which is never mainnet or gnosis. + network, _ := eth2util.ForkVersionToNetwork(lock.ForkVersion) + + // Validate and normalise new addresses to their EIP-55 checksummed form, so that all operators + // produce the same cluster lock regardless of the address case they provided. + conf.FeeRecipientAddrs, conf.WithdrawalAddrs, err = checksumValidatorAddrs(ctx, conf.FeeRecipientAddrs, conf.WithdrawalAddrs, network, false) + if err != nil { + return err + } + // Loading the existing cluster keystore. var secrets []tbls.PrivateKey diff --git a/cmd/edit_addvalidators_internal_test.go b/cmd/edit_addvalidators_internal_test.go index 1bf11ee426..96ac377629 100644 --- a/cmd/edit_addvalidators_internal_test.go +++ b/cmd/edit_addvalidators_internal_test.go @@ -437,3 +437,66 @@ func mustLoadTestLockFile(t *testing.T, path string) cluster.Lock { return lock } + +func TestRunAddValidatorsAddressValidation(t *testing.T) { + conf := clusterConfig{ + ClusterDir: t.TempDir(), + Name: "test_cluster", + NumNodes: 4, + Threshold: 3, + NumDVs: 1, + Network: eth2util.Mainnet.Name, + TargetGasLimit: 36000000, + FeeRecipientAddrs: []string{validEthAddr}, + WithdrawalAddrs: []string{validEthAddr}, + } + + var buf bytes.Buffer + + require.NoError(t, runCreateCluster(t.Context(), &buf, conf)) + + ddir := nodeDir(conf.ClusterDir, 0) + badChecksum := "0xFB6916095ca1df60bB79Ce92cE3Ea74c37c5d359" + + tests := []struct { + name string + feeRecipient string + withdrawal string + expectedErr string + }{ + // Zero addresses only warn here (see TestChecksumValidatorAddrs), so they would proceed to a DKG. + { + name: "bad withdrawal checksum", + feeRecipient: validEthAddr, + withdrawal: badChecksum, + expectedErr: "invalid withdrawal address", + }, + { + name: "malformed fee recipient", + feeRecipient: "0x1234", + withdrawal: validEthAddr, + expectedErr: "invalid fee recipient address", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + addConf := addValidatorsConfig{ + PrivateKeyPath: path.Join(ddir, enrPrivateKeyFile), + LockFilePath: path.Join(ddir, clusterLockFile), + ValidatorKeysDir: path.Join(ddir, validatorKeysSubDir), + OutputDir: nodeDir(t.TempDir(), 0), + NumValidators: 1, + WithdrawalAddrs: []string{test.withdrawal}, + FeeRecipientAddrs: []string{test.feeRecipient}, + DKG: dkg.Config{ + Log: log.DefaultConfig(), + Timeout: time.Second, + }, + } + + err := runAddValidators(t.Context(), addConf) + require.ErrorContains(t, err, test.expectedErr) + }) + } +} diff --git a/cmd/exit.go b/cmd/exit.go index e0e7bf4b81..b40a846d72 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -167,7 +167,7 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) case validatorKeysDir: cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", maybeRequired("Path to the directory containing the validator private key share files and passwords.")) case validatorPubkey: - cmd.Flags().StringVar(&config.ValidatorPubkey, validatorPubkey.String(), "", maybeRequired("Public key of the validator to exit, must be present in the cluster lock manifest. If --validator-index is also provided, validator liveliness won't be checked on the beacon chain.")) + cmd.Flags().StringVar(&config.ValidatorPubkey, validatorPubkey.String(), "", maybeRequired("Public key of the validator to exit, must be present in the cluster lock manifest. If --validator-index is also provided, validator liveliness won't be checked on the beacon chain, but the index is cross-checked against the public key if the beacon node knows it.")) case exitEpoch: cmd.Flags().Uint64Var(&config.ExitEpoch, exitEpoch.String(), 194048, maybeRequired("Exit epoch at which the validator will exit, must be the same across all the partial exits.")) case exitFromFile: @@ -181,7 +181,7 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) case publishTimeout: cmd.Flags().DurationVar(&config.PublishTimeout, publishTimeout.String(), 5*time.Minute, "Timeout for publishing a signed exit to the publish-address API.") case validatorIndex: - cmd.Flags().Uint64Var(&config.ValidatorIndex, validatorIndex.String(), 0, "Validator index of the validator to exit, the associated public key must be present in the cluster lock manifest. If --validator-public-key is also provided, validator existence won't be checked on the beacon chain.") + cmd.Flags().Uint64Var(&config.ValidatorIndex, validatorIndex.String(), 0, "Validator index of the validator to exit, the associated public key must be present in the cluster lock manifest. If --validator-public-key is also provided, validator existence won't be checked on the beacon chain, but the index is cross-checked against the public key if the beacon node knows it.") case all: cmd.Flags().BoolVar(&config.All, all.String(), false, "Exit all currently active validators in the cluster.") case testnetName: @@ -211,7 +211,7 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag) } func eth2Client(ctx context.Context, fallbackAddresses []string, headers map[string]string, u []string, timeout time.Duration, forkVersion [4]byte) (eth2wrap.Client, error) { - cl, err := eth2wrap.NewMultiHTTP(timeout, forkVersion, headers, u, fallbackAddresses) + cl, err := eth2wrap.NewMultiHTTP(ctx, timeout, forkVersion, headers, u, fallbackAddresses) if err != nil { return nil, err } diff --git a/cmd/exit_sign.go b/cmd/exit_sign.go index 582696be02..99fc2ee868 100644 --- a/cmd/exit_sign.go +++ b/cmd/exit_sign.go @@ -3,11 +3,14 @@ package cmd import ( + "bytes" "context" "fmt" + "slices" eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/electra" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/spf13/cobra" @@ -148,7 +151,7 @@ func runSignPartialExit(ctx context.Context, config exitConfig) error { } if config.SkipBeaconNodeCheck { - log.Info(ctx, "Both public key and index are specified, beacon node won't be checked for validator existence/liveness") + log.Info(ctx, "Both public key and index are specified, beacon node won't be checked for validator liveness, only that the index matches the public key") } var exitBlobs []obolapi.ExitBlob @@ -189,6 +192,12 @@ func signSingleValidatorExit(ctx context.Context, config exitConfig, eth2Cl eth2 return nil, errors.Wrap(err, "fetch validator index") } + if config.SkipBeaconNodeCheck { + if err := crossCheckValidatorIndex(ctx, eth2Cl, valEth2, valIndex); err != nil { + return nil, err + } + } + log.Info(ctx, "Signing partial exit message for validator", z.Str("validator_public_key", valEth2.String()), z.U64("validator_index", uint64(valIndex))) exitMsg, err := signExit(ctx, eth2Cl, valIndex, ourShare.Share, eth2p0.Epoch(config.ExitEpoch)) @@ -216,7 +225,7 @@ func signAllValidatorsExits(ctx context.Context, config exitConfig, eth2Cl eth2w valsEth2 = append(valsEth2, eth2PK) } - rawValData, err := queryBeaconForValidator(ctx, eth2Cl, valsEth2, nil, []eth2v1.ValidatorState{eth2v1.ValidatorStatePendingQueued, eth2v1.ValidatorStateActiveOngoing}) + rawValData, err := queryBeaconForValidator(ctx, eth2Cl, valsEth2, nil, []eth2v1.ValidatorState{eth2v1.ValidatorStatePendingInitialized, eth2v1.ValidatorStatePendingQueued, eth2v1.ValidatorStateActiveOngoing}) if err != nil { return nil, errors.Wrap(err, "fetch all validators indices from beacon") } @@ -233,6 +242,8 @@ func signAllValidatorsExits(ctx context.Context, config exitConfig, eth2Cl eth2w activeShares[core.PubKeyFrom48Bytes(val.Validator.PublicKey)] = share } + warnOnPendingDeposits(ctx, eth2Cl, valsEth2, rawValData.Data) + log.Info(ctx, "Signing partial exit message for all active validators", z.Int("active_validators", len(activeShares)), z.Int("inactive_validators", len(shares)-len(activeShares))) var exitBlobs []obolapi.ExitBlob @@ -260,6 +271,70 @@ func signAllValidatorsExits(ctx context.Context, config exitConfig, eth2Cl eth2w return exitBlobs, nil } +// pendingDepositsWithoutIndex returns the cluster validator public keys that have a registered +// deposit in the beacon pending-deposits queue but no validator index yet, so no exit can be +// signed for them. Top-up deposits for already-indexed validators and deposits for validators +// outside the cluster are ignored. The result is deduplicated and sorted deterministically. +func pendingDepositsWithoutIndex(clusterPubkeys, indexedPubkeys map[eth2p0.BLSPubKey]bool, pendingDeposits []*electra.PendingDeposit) []eth2p0.BLSPubKey { + seen := make(map[eth2p0.BLSPubKey]bool) + + var out []eth2p0.BLSPubKey + + for _, pd := range pendingDeposits { + pk := pd.Pubkey + if !clusterPubkeys[pk] || indexedPubkeys[pk] || seen[pk] { + continue + } + + seen[pk] = true + out = append(out, pk) + } + + slices.SortFunc(out, func(a, b eth2p0.BLSPubKey) int { + return bytes.Compare(a[:], b[:]) + }) + + return out +} + +// warnOnPendingDeposits queries the beacon pending-deposits queue and warns about the cluster +// validators that have a registered deposit but no validator index yet, so no exit could be +// signed for them. It is best-effort: any error querying the queue is logged and swallowed so it +// never blocks signing exits for the exitable validators. The affected public keys are returned. +func warnOnPendingDeposits(ctx context.Context, eth2Cl eth2wrap.Client, clusterPubkeys []eth2p0.BLSPubKey, indexed map[eth2p0.ValidatorIndex]*eth2v1.Validator) []eth2p0.BLSPubKey { + resp, err := eth2Cl.PendingDeposits(ctx, ð2api.PendingDepositsOpts{State: "head"}) + if err != nil { + log.Warn(ctx, "Could not check beacon pending-deposits queue for validators without an index", err) + return nil + } + + clusterSet := make(map[eth2p0.BLSPubKey]bool, len(clusterPubkeys)) + for _, pk := range clusterPubkeys { + clusterSet[pk] = true + } + + indexedSet := make(map[eth2p0.BLSPubKey]bool, len(indexed)) + for _, val := range indexed { + indexedSet[val.Validator.PublicKey] = true + } + + warnPubkeys := pendingDepositsWithoutIndex(clusterSet, indexedSet, resp.Data) + if len(warnPubkeys) == 0 { + return nil + } + + pubkeyStrs := make([]string, 0, len(warnPubkeys)) + for _, pk := range warnPubkeys { + pubkeyStrs = append(pubkeyStrs, pk.String()) + } + + log.Warn(ctx, "Some validators have a registered deposit but no validator index yet, so no exit was signed for them; "+ + "they will activate later and cannot be exited now. Re-run 'charon exit sign --all' once they have a validator index.", nil, + z.Int("count", len(warnPubkeys)), z.Any("validators", pubkeyStrs)) + + return warnPubkeys +} + func fetchValidatorBLSPubKey(ctx context.Context, config exitConfig, eth2Cl eth2wrap.Client) (eth2p0.BLSPubKey, error) { if config.ValidatorPubkey != "" { valEth2, err := core.PubKey(config.ValidatorPubkey).ToETH2() @@ -284,6 +359,33 @@ func fetchValidatorBLSPubKey(ctx context.Context, config exitConfig, eth2Cl eth2 return eth2p0.BLSPubKey{}, errors.New("validator index not found in beacon node response", z.Str("beacon_address", eth2Cl.Address()), z.U64("validator_index", config.ValidatorIndex), z.Any("raw_response", rawValData)) } +// crossCheckValidatorIndex verifies, when both --validator-public-key and --validator-index are provided, +// that the beacon node reports the given index as belonging to the given public key. A mismatch is an +// error. If the beacon node cannot be queried or does not know the index yet, a warning is logged and +// signing proceeds, preserving the existing behaviour of not requiring the validator to be on chain. +func crossCheckValidatorIndex(ctx context.Context, eth2Cl eth2wrap.Client, pubkey eth2p0.BLSPubKey, index eth2p0.ValidatorIndex) error { + rawValData, err := queryBeaconForValidator(ctx, eth2Cl, nil, []eth2p0.ValidatorIndex{index}, nil) + if err != nil { + log.Warn(ctx, "Could not cross-check validator index against public key on beacon node, proceeding", err) + return nil + } + + val, ok := rawValData.Data[index] + if !ok { + log.Warn(ctx, "Validator index not found on beacon node, could not cross-check it against the public key, proceeding", nil) + return nil + } + + if val.Validator.PublicKey != pubkey { + return errors.New("validator index belongs to a different public key on the beacon node", + z.U64("validator_index", uint64(index)), + z.Str("validator_public_key", pubkey.String()), + z.Str("beacon_public_key", val.Validator.PublicKey.String())) + } + + return nil +} + func fetchValidatorIndex(ctx context.Context, config exitConfig, eth2Cl eth2wrap.Client) (eth2p0.ValidatorIndex, error) { if config.ValidatorIndexPresent { return eth2p0.ValidatorIndex(config.ValidatorIndex), nil diff --git a/cmd/exit_sign_internal_test.go b/cmd/exit_sign_internal_test.go index b84eefdd6c..79528eb2c8 100644 --- a/cmd/exit_sign_internal_test.go +++ b/cmd/exit_sign_internal_test.go @@ -3,24 +3,30 @@ package cmd import ( + "context" "encoding/json" "fmt" "math/rand" "net/http/httptest" "os" "path/filepath" + "slices" "testing" "time" + eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" + "github.com/attestantio/go-eth2-client/spec/electra" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/stretchr/testify/require" + "github.com/obolnetwork/charon/app/errors" "github.com/obolnetwork/charon/app/k1util" "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/app/z" "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/core" "github.com/obolnetwork/charon/eth2util/keystore" "github.com/obolnetwork/charon/tbls" "github.com/obolnetwork/charon/testutil" @@ -114,6 +120,22 @@ func Test_runSubmitPartialExit(t *testing.T) { ) }) + t.Run("main flow with skipBeaconNodeCheck mode with index of another validator", func(t *testing.T) { + runSubmitPartialExitFlowTest( + t, + true, + true, + "", + 1, // Index 1 belongs to lock.Validators[1], not the default lock.Validators[0] pubkey. + "validator index belongs to a different public key on the beacon node", + false, + ) + }) + + t.Run("main flow with skipBeaconNodeCheck mode with index unknown to beacon node", func(t *testing.T) { + runSubmitPartialExitFlowTest(t, true, true, "", 9999, "", false) + }) + t.Run("main flow with pubkey", func(t *testing.T) { runSubmitPartialExitFlowTest(t, false, false, "", 0, "", false) }) @@ -490,3 +512,257 @@ func TestExitSignCLI(t *testing.T) { }) } } + +func Test_pendingDepositsWithoutIndex(t *testing.T) { + pk := func(b byte) eth2p0.BLSPubKey { + var out eth2p0.BLSPubKey + + out[0] = b + + return out + } + + clusterA, clusterB, clusterC := pk(1), pk(2), pk(3) + nonCluster := pk(9) + + deposit := func(p eth2p0.BLSPubKey) *electra.PendingDeposit { + return &electra.PendingDeposit{Pubkey: p} + } + + tests := []struct { + name string + cluster []eth2p0.BLSPubKey + indexed []eth2p0.BLSPubKey + pendingDeposits []*electra.PendingDeposit + want []eth2p0.BLSPubKey + }{ + { + name: "deposit registered but no index yet", + cluster: []eth2p0.BLSPubKey{clusterA, clusterB}, + indexed: nil, + pendingDeposits: []*electra.PendingDeposit{deposit(clusterA)}, + want: []eth2p0.BLSPubKey{clusterA}, + }, + { + name: "top-up deposit for indexed validator is ignored", + cluster: []eth2p0.BLSPubKey{clusterA}, + indexed: []eth2p0.BLSPubKey{clusterA}, + pendingDeposits: []*electra.PendingDeposit{deposit(clusterA)}, + want: nil, + }, + { + name: "deposit for non-cluster validator is ignored", + cluster: []eth2p0.BLSPubKey{clusterA}, + indexed: nil, + pendingDeposits: []*electra.PendingDeposit{deposit(nonCluster)}, + want: nil, + }, + { + name: "empty queue yields nothing", + cluster: []eth2p0.BLSPubKey{clusterA}, + indexed: nil, + pendingDeposits: nil, + want: nil, + }, + { + name: "multiple deposits for same validator deduplicated", + cluster: []eth2p0.BLSPubKey{clusterA}, + indexed: nil, + pendingDeposits: []*electra.PendingDeposit{deposit(clusterA), deposit(clusterA)}, + want: []eth2p0.BLSPubKey{clusterA}, + }, + { + name: "results sorted deterministically", + cluster: []eth2p0.BLSPubKey{clusterA, clusterB, clusterC}, + indexed: nil, + pendingDeposits: []*electra.PendingDeposit{deposit(clusterC), deposit(clusterA), deposit(clusterB)}, + want: []eth2p0.BLSPubKey{clusterA, clusterB, clusterC}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + clusterSet := make(map[eth2p0.BLSPubKey]bool) + for _, p := range test.cluster { + clusterSet[p] = true + } + + indexedSet := make(map[eth2p0.BLSPubKey]bool) + for _, p := range test.indexed { + indexedSet[p] = true + } + + got := pendingDepositsWithoutIndex(clusterSet, indexedSet, test.pendingDeposits) + require.Equal(t, test.want, got) + }) + } +} + +// mixedValidatorSet builds a cluster and a beacon validator set where the validators +// are split across states: active_ongoing, pending_initialized (has an index) and +// entirely absent from the beacon (no index). The absent validators' public keys are +// returned so callers can place them in the pending-deposits queue. +func mixedValidatorSet(t *testing.T) (keystore.ValidatorShares, beaconmock.ValidatorSet, []eth2p0.BLSPubKey) { + t.Helper() + + const ( + valAmt = 3 + operatorAmt = 4 + ) + + random := rand.New(rand.NewSource(int64(0))) + + lock, _, keyShares := cluster.NewForT(t, valAmt, operatorAmt, operatorAmt, 0, random) + + shares := make(keystore.ValidatorShares) + + for i, v := range lock.Validators { + pk, err := core.PubKeyFromBytes(v.PubKey) + require.NoError(t, err) + + shares[pk] = keystore.IndexedKeyShare{Share: keyShares[i][0], Index: i} + } + + validatorSet := beaconmock.ValidatorSet{} + // index 0: active_ongoing, index 1: pending_initialized, index 2: absent (no index). + states := []eth2v1.ValidatorState{eth2v1.ValidatorStateActiveOngoing, eth2v1.ValidatorStatePendingInitialized} + + var noIndex []eth2p0.BLSPubKey + + for idx, v := range lock.Validators { + if idx >= len(states) { + noIndex = append(noIndex, eth2p0.BLSPubKey(v.PubKey)) + continue + } + + validatorSet[eth2p0.ValidatorIndex(idx)] = ð2v1.Validator{ + Index: eth2p0.ValidatorIndex(idx), + Balance: 42, + Status: states[idx], + Validator: ð2p0.Validator{ + PublicKey: eth2p0.BLSPubKey(v.PubKey), + WithdrawalCredentials: testutil.RandomBytes32(), + }, + } + } + + return shares, validatorSet, noIndex +} + +// stateFilteringValidators overrides a beacon mock's ValidatorsFunc to emulate a real beacon +// node: it returns only validators whose status is among the requested states (and, when set, +// whose public key is requested). This lets tests exercise the state filter that the plain +// WithValidatorSet mock ignores. +func stateFilteringValidators(set beaconmock.ValidatorSet) func(context.Context, *eth2api.ValidatorsOpts) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error) { + return func(_ context.Context, opts *eth2api.ValidatorsOpts) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error) { + resp := make(map[eth2p0.ValidatorIndex]*eth2v1.Validator) + + for idx, val := range set { + if len(opts.ValidatorStates) > 0 && !slices.Contains(opts.ValidatorStates, val.Status) { + continue + } + + if len(opts.PubKeys) > 0 && !slices.Contains(opts.PubKeys, val.Validator.PublicKey) { + continue + } + + resp[idx] = val + } + + return resp, nil + } +} + +func Test_signAllValidatorsExits_signsPendingInitialized(t *testing.T) { + ctx := t.Context() + + shares, validatorSet, noIndex := mixedValidatorSet(t) + + beaconMock, err := beaconmock.New(ctx, beaconmock.WithValidatorSet(validatorSet)) + require.NoError(t, err) + + t.Cleanup(func() { require.NoError(t, beaconMock.Close()) }) + + beaconMock.ValidatorsFunc = stateFilteringValidators(validatorSet) + + // The no-index validator sits in the pending-deposits queue: it triggers the warning but + // must not affect signing of the exitable validators. + beaconMock.PendingDepositsFunc = func(context.Context, *eth2api.PendingDepositsOpts) ([]*electra.PendingDeposit, error) { + return []*electra.PendingDeposit{{Pubkey: noIndex[0]}}, nil + } + + config := exitConfig{ExitEpoch: 194048} + + exitBlobs, err := signAllValidatorsExits(ctx, config, beaconMock, shares) + require.NoError(t, err) + + // active_ongoing and pending_initialized are signed; the no-index validator is not. + require.Len(t, exitBlobs, 2) + + signed := make(map[string]bool) + for _, b := range exitBlobs { + signed[b.PublicKey] = true + } + + require.False(t, signed[noIndex[0].String()], "no-index validator must not be signed") +} + +func Test_pendingDepositWarnings(t *testing.T) { + ctx := t.Context() + + shares, validatorSet, noIndex := mixedValidatorSet(t) + + beaconMock, err := beaconmock.New(ctx, beaconmock.WithValidatorSet(validatorSet)) + require.NoError(t, err) + + t.Cleanup(func() { require.NoError(t, beaconMock.Close()) }) + + activePubkey := validatorSet[0].Validator.PublicKey + + beaconMock.PendingDepositsFunc = func(context.Context, *eth2api.PendingDepositsOpts) ([]*electra.PendingDeposit, error) { + return []*electra.PendingDeposit{ + {Pubkey: noIndex[0]}, // cluster validator with a registered deposit but no index -> warn. + {Pubkey: activePubkey}, // top-up for an already-indexed validator -> ignored. + }, nil + } + + var clusterPubkeys []eth2p0.BLSPubKey + + for pk := range shares { + eth2PK, err := pk.ToETH2() + require.NoError(t, err) + + clusterPubkeys = append(clusterPubkeys, eth2PK) + } + + // index 0 is active_ongoing: its top-up deposit must be excluded from the warning. + indexed := map[eth2p0.ValidatorIndex]*eth2v1.Validator{0: validatorSet[0]} + + got := warnOnPendingDeposits(ctx, beaconMock, clusterPubkeys, indexed) + require.Equal(t, []eth2p0.BLSPubKey{noIndex[0]}, got) +} + +func Test_signAllValidatorsExits_pendingDepositsCheckIsBestEffort(t *testing.T) { + ctx := t.Context() + + shares, validatorSet, _ := mixedValidatorSet(t) + + beaconMock, err := beaconmock.New(ctx, beaconmock.WithValidatorSet(validatorSet)) + require.NoError(t, err) + + t.Cleanup(func() { require.NoError(t, beaconMock.Close()) }) + + beaconMock.ValidatorsFunc = stateFilteringValidators(validatorSet) + + // A beacon node that does not support the pending-deposits queue (e.g. pre-Electra) must not + // block signing exits for the exitable validators. + beaconMock.PendingDepositsFunc = func(context.Context, *eth2api.PendingDepositsOpts) ([]*electra.PendingDeposit, error) { + return nil, errors.New("pending deposits not supported") + } + + config := exitConfig{ExitEpoch: 194048} + + exitBlobs, err := signAllValidatorsExits(ctx, config, beaconMock, shares) + require.NoError(t, err) + require.Len(t, exitBlobs, 2) +} diff --git a/cmd/feerecipientfetch_internal_test.go b/cmd/feerecipientfetch_internal_test.go index 5d81d331fc..f8340ecd44 100644 --- a/cmd/feerecipientfetch_internal_test.go +++ b/cmd/feerecipientfetch_internal_test.go @@ -74,16 +74,14 @@ func signConfigForOperator(c feeRecipientTestCluster, opIdx int, validatorPubkey baseDir := filepath.Join(c.root, fmt.Sprintf("op%d", opIdx)) return feerecipientSignConfig{ - feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: []string{validatorPubkey}, - PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), - LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), - PublishAddress: c.srvURL, - PublishTimeout: 10 * time.Second, - }, - ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), - FeeRecipient: feeRecipient, - Timestamp: timestamp, + ValidatorPublicKeys: []string{validatorPubkey}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: c.srvURL, + PublishTimeout: 10 * time.Second, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: feeRecipient, + Timestamp: timestamp, } } @@ -102,13 +100,11 @@ func fetchFeeRecipient(ctx context.Context, t *testing.T, c feeRecipientTestClus t.Helper() fetchConfig := feerecipientFetchConfig{ - feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: validatorPubkeys, - LockFilePath: filepath.Join(c.root, "op0", "cluster-lock.json"), - OverridesFilePath: overridesFile, - PublishAddress: c.srvURL, - PublishTimeout: 10 * time.Second, - }, + ValidatorPublicKeys: validatorPubkeys, + LockFilePath: filepath.Join(c.root, "op0", "cluster-lock.json"), + OverridesFilePath: overridesFile, + PublishAddress: c.srvURL, + PublishTimeout: 10 * time.Second, } require.NoError(t, runFeeRecipientFetch(ctx, fetchConfig)) @@ -287,11 +283,9 @@ func TestFeeRecipientFetchSkipsInvalidSignature(t *testing.T) { func TestFeeRecipientFetchInvalidLockFile(t *testing.T) { config := feerecipientFetchConfig{ - feerecipientConfig: feerecipientConfig{ - LockFilePath: "nonexistent-lock.json", - PublishAddress: "http://localhost:0", - PublishTimeout: time.Second, - }, + LockFilePath: "nonexistent-lock.json", + PublishAddress: "http://localhost:0", + PublishTimeout: time.Second, } err := runFeeRecipientFetch(t.Context(), config) @@ -308,11 +302,9 @@ func TestFeeRecipientFetchAPIUnreachable(t *testing.T) { srv.Close() config := feerecipientFetchConfig{ - feerecipientConfig: feerecipientConfig{ - LockFilePath: filepath.Join(c.root, "op0", "cluster-lock.json"), - PublishAddress: srv.URL, - PublishTimeout: time.Second, - }, + LockFilePath: filepath.Join(c.root, "op0", "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: time.Second, } err := runFeeRecipientFetch(ctx, config) @@ -357,15 +349,13 @@ func TestFeeRecipientFetchNoQuorum(t *testing.T) { baseDir := filepath.Join(root, "op0") signConfig := feerecipientSignConfig{ - feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: []string{validatorPubkey}, - PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), - LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, - ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), - FeeRecipient: newFeeRecipient, + ValidatorPublicKeys: []string{validatorPubkey}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: newFeeRecipient, } require.NoError(t, runFeeRecipientSign(ctx, signConfig)) @@ -374,12 +364,10 @@ func TestFeeRecipientFetchNoQuorum(t *testing.T) { overridesFile := filepath.Join(root, "output", "builder_registrations_overrides.json") fetchConfig := feerecipientFetchConfig{ - feerecipientConfig: feerecipientConfig{ - LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), - OverridesFilePath: overridesFile, - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, + LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), + OverridesFilePath: overridesFile, + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, } require.NoError(t, runFeeRecipientFetch(ctx, fetchConfig)) diff --git a/cmd/feerecipientlist_internal_test.go b/cmd/feerecipientlist_internal_test.go index 9d91ab8912..81b736504c 100644 --- a/cmd/feerecipientlist_internal_test.go +++ b/cmd/feerecipientlist_internal_test.go @@ -49,12 +49,10 @@ func TestFeeRecipientListValid(t *testing.T) { require.NoError(t, os.WriteFile(lockFile, lockJSON, 0o644)) config := feerecipientListConfig{ - feerecipientConfig: feerecipientConfig{ - LockFilePath: lockFile, - OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), - PublishAddress: "http://127.0.0.1:0", - PublishTimeout: 500 * time.Millisecond, - }, + LockFilePath: lockFile, + OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), + PublishAddress: "http://127.0.0.1:0", + PublishTimeout: 500 * time.Millisecond, } require.NoError(t, runFeeRecipientList(t.Context(), config)) @@ -75,13 +73,11 @@ func TestFeeRecipientListWithFilter(t *testing.T) { require.NoError(t, os.WriteFile(lockFile, lockJSON, 0o644)) config := feerecipientListConfig{ - feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, - LockFilePath: lockFile, - OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), - PublishAddress: "http://127.0.0.1:0", - PublishTimeout: 500 * time.Millisecond, - }, + ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, + LockFilePath: lockFile, + OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), + PublishAddress: "http://127.0.0.1:0", + PublishTimeout: 500 * time.Millisecond, } require.NoError(t, runFeeRecipientList(t.Context(), config)) @@ -102,11 +98,9 @@ func TestFeeRecipientListInvalidPubkey(t *testing.T) { require.NoError(t, os.WriteFile(lockFile, lockJSON, 0o644)) config := feerecipientListConfig{ - feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: []string{"0x" + strings.Repeat("ab", 48)}, - LockFilePath: lockFile, - OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), - }, + ValidatorPublicKeys: []string{"0x" + strings.Repeat("ab", 48)}, + LockFilePath: lockFile, + OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), } err = runFeeRecipientList(t.Context(), config) @@ -115,10 +109,8 @@ func TestFeeRecipientListInvalidPubkey(t *testing.T) { func TestFeeRecipientListInvalidLockFile(t *testing.T) { config := feerecipientListConfig{ - feerecipientConfig: feerecipientConfig{ - LockFilePath: "nonexistent-lock.json", - OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), - }, + LockFilePath: "nonexistent-lock.json", + OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), } err := runFeeRecipientList(t.Context(), config) @@ -153,12 +145,10 @@ func TestFeeRecipientListWithOverrides(t *testing.T) { require.NoError(t, os.WriteFile(overridesFile, overridesJSON, 0o644)) config := feerecipientListConfig{ - feerecipientConfig: feerecipientConfig{ - LockFilePath: lockFile, - OverridesFilePath: overridesFile, - PublishAddress: "http://127.0.0.1:0", - PublishTimeout: 500 * time.Millisecond, - }, + LockFilePath: lockFile, + OverridesFilePath: overridesFile, + PublishAddress: "http://127.0.0.1:0", + PublishTimeout: 500 * time.Millisecond, } require.NoError(t, runFeeRecipientList(t.Context(), config)) @@ -463,15 +453,13 @@ func TestFeeRecipientListFetchesRemote(t *testing.T) { baseDir := filepath.Join(root, fmt.Sprintf("op%d", opIdx)) signConfig := feerecipientSignConfig{ - feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: []string{validatorPubkey}, - PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), - LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, - ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), - FeeRecipient: newFeeRecipient, + ValidatorPublicKeys: []string{validatorPubkey}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: newFeeRecipient, } require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator %d submit feerecipient sign", opIdx) @@ -480,12 +468,10 @@ func TestFeeRecipientListFetchesRemote(t *testing.T) { overridesFile := filepath.Join(root, "output", "builder_registrations_overrides.json") listConfig := feerecipientListConfig{ - feerecipientConfig: feerecipientConfig{ - LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), - OverridesFilePath: overridesFile, - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, + LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), + OverridesFilePath: overridesFile, + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, } require.NoError(t, runFeeRecipientList(ctx, listConfig)) @@ -532,26 +518,22 @@ func TestFeeRecipientListRemoteOnlyTriggersSuggestion(t *testing.T) { for opIdx := range lock.Threshold { baseDir := filepath.Join(root, fmt.Sprintf("op%d", opIdx)) signConfig := feerecipientSignConfig{ - feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: []string{validatorPubkey}, - PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), - LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, - ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), - FeeRecipient: newFeeRecipient, + ValidatorPublicKeys: []string{validatorPubkey}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: newFeeRecipient, } require.NoError(t, runFeeRecipientSign(ctx, signConfig)) } listConfig := feerecipientListConfig{ - feerecipientConfig: feerecipientConfig{ - LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), - OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, + LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), + OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, } require.NoError(t, runFeeRecipientList(ctx, listConfig)) @@ -602,15 +584,13 @@ func TestFeeRecipientListOverridesMatchRemote(t *testing.T) { for opIdx := range lock.Threshold { baseDir := filepath.Join(root, fmt.Sprintf("op%d", opIdx)) signConfig := feerecipientSignConfig{ - feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: []string{validatorPubkey}, - PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), - LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, - ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), - FeeRecipient: newFeeRecipient, + ValidatorPublicKeys: []string{validatorPubkey}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: newFeeRecipient, } require.NoError(t, runFeeRecipientSign(ctx, signConfig)) } @@ -619,22 +599,18 @@ func TestFeeRecipientListOverridesMatchRemote(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Dir(overridesFile), 0o755)) fetchConfig := feerecipientFetchConfig{ - feerecipientConfig: feerecipientConfig{ - LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), - OverridesFilePath: overridesFile, - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, + LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), + OverridesFilePath: overridesFile, + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, } require.NoError(t, runFeeRecipientFetch(ctx, fetchConfig)) listConfig := feerecipientListConfig{ - feerecipientConfig: feerecipientConfig{ - LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), - OverridesFilePath: overridesFile, - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, + LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), + OverridesFilePath: overridesFile, + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, } require.NoError(t, runFeeRecipientList(ctx, listConfig)) @@ -668,12 +644,10 @@ func TestFeeRecipientListRemoteUnreachable(t *testing.T) { srv.Close() listConfig := feerecipientListConfig{ - feerecipientConfig: feerecipientConfig{ - LockFilePath: lockFile, - OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), - PublishAddress: srv.URL, - PublishTimeout: 500 * time.Millisecond, - }, + LockFilePath: lockFile, + OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), + PublishAddress: srv.URL, + PublishTimeout: 500 * time.Millisecond, } require.NoError(t, runFeeRecipientList(ctx, listConfig)) @@ -726,26 +700,22 @@ func TestFeeRecipientListIncompleteNoQuorum(t *testing.T) { for opIdx := range lock.Threshold - 1 { baseDir := filepath.Join(root, fmt.Sprintf("op%d", opIdx)) signConfig := feerecipientSignConfig{ - feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: []string{validatorPubkey}, - PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), - LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, - ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), - FeeRecipient: newFeeRecipient, + ValidatorPublicKeys: []string{validatorPubkey}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: newFeeRecipient, } require.NoError(t, runFeeRecipientSign(ctx, signConfig)) } listConfig := feerecipientListConfig{ - feerecipientConfig: feerecipientConfig{ - LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), - OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, + LockFilePath: filepath.Join(root, "op0", "cluster-lock.json"), + OverridesFilePath: filepath.Join(t.TempDir(), "nonexistent-overrides.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, } require.NoError(t, runFeeRecipientList(ctx, listConfig)) diff --git a/cmd/feerecipientsign.go b/cmd/feerecipientsign.go index e769e48bac..5d6f478661 100644 --- a/cmd/feerecipientsign.go +++ b/cmd/feerecipientsign.go @@ -366,26 +366,18 @@ func filterPubkeysByStatus( return pubkeysToSign, nil } -// validateFeeRecipient checks that the fee recipient address is well-formed, is not the zero -// (burn) address, and, when supplied in mixed case, matches its EIP-55 checksum. +// validateFeeRecipient checks that the fee recipient address is well-formed (see eth2util.ValidateChecksumAddress) +// and is not the zero (burn) address. func validateFeeRecipient(address string) error { - checksummed, err := eth2util.ChecksumAddress(address) + checksummed, err := eth2util.ValidateChecksumAddress(address) if err != nil { return errors.Wrap(err, "invalid fee recipient address", z.Str("fee_recipient", address)) } - if checksummed == "0x0000000000000000000000000000000000000000" { + if checksummed == zeroAddress { return errors.New("fee recipient address must not be the zero address", z.Str("fee_recipient", address)) } - hexPart := strings.TrimPrefix(address, "0x") - if hexPart != strings.ToLower(hexPart) && address != checksummed { - return errors.New("fee recipient address does not match its EIP-55 checksum; use all-lowercase or the checksummed form", - z.Str("fee_recipient", address), - z.Str("checksummed", checksummed), - ) - } - return nil } diff --git a/cmd/feerecipientsign_internal_test.go b/cmd/feerecipientsign_internal_test.go index cd100fbdd1..2a36135e77 100644 --- a/cmd/feerecipientsign_internal_test.go +++ b/cmd/feerecipientsign_internal_test.go @@ -67,15 +67,13 @@ func TestFeeRecipientSignValid(t *testing.T) { baseDir := filepath.Join(root, fmt.Sprintf("op%d", idx)) signConfig := feerecipientSignConfig{ - feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, - PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), - LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, - ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), - FeeRecipient: "0x0000000000000000000000000000000000001234", + ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: "0x0000000000000000000000000000000000001234", } require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator index submit feerecipient sign: %v", idx) @@ -122,16 +120,14 @@ func TestFeeRecipientSignWithTimestamp(t *testing.T) { baseDir := filepath.Join(root, fmt.Sprintf("op%d", opIdx)) signConfig := feerecipientSignConfig{ - feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: []string{validatorPubkey}, - PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), - LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, - ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), - FeeRecipient: newFeeRecipient, - Timestamp: fixedTimestamp, + ValidatorPublicKeys: []string{validatorPubkey}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: newFeeRecipient, + Timestamp: fixedTimestamp, } require.NoError(t, runFeeRecipientSign(ctx, signConfig), "operator %d sign with timestamp", opIdx) @@ -140,13 +136,11 @@ func TestFeeRecipientSignWithTimestamp(t *testing.T) { func TestFeeRecipientSignInvalidFeeRecipient(t *testing.T) { config := feerecipientSignConfig{ - feerecipientConfig: feerecipientConfig{ - PrivateKeyPath: "nonexistent", - LockFilePath: "nonexistent", - PublishAddress: "http://localhost:0", - PublishTimeout: time.Second, - }, - FeeRecipient: "not-an-address", + PrivateKeyPath: "nonexistent", + LockFilePath: "nonexistent", + PublishAddress: "http://localhost:0", + PublishTimeout: time.Second, + FeeRecipient: "not-an-address", } err := runFeeRecipientSign(t.Context(), config) @@ -170,6 +164,10 @@ func TestValidateFeeRecipient(t *testing.T) { name: "valid checksummed", address: checksummed, }, + { + name: "valid uppercase", + address: "0x" + strings.ToUpper(checksummed[2:]), + }, { name: "invalid checksum", address: "0x5AAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", @@ -255,13 +253,11 @@ func TestFeeRecipientSignAdoptsInProgressTimestamp(t *testing.T) { func TestFeeRecipientSignInvalidLockFile(t *testing.T) { config := feerecipientSignConfig{ - feerecipientConfig: feerecipientConfig{ - PrivateKeyPath: "nonexistent", - LockFilePath: "nonexistent-lock.json", - PublishAddress: "http://localhost:0", - PublishTimeout: time.Second, - }, - FeeRecipient: "0x0000000000000000000000000000000000001234", + PrivateKeyPath: "nonexistent", + LockFilePath: "nonexistent-lock.json", + PublishAddress: "http://localhost:0", + PublishTimeout: time.Second, + FeeRecipient: "0x0000000000000000000000000000000000001234", } err := runFeeRecipientSign(t.Context(), config) @@ -299,15 +295,13 @@ func TestFeeRecipientSignAPIUnreachable(t *testing.T) { baseDir := filepath.Join(root, "op0") config := feerecipientSignConfig{ - feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, - PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), - LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), - PublishAddress: srv.URL, - PublishTimeout: time.Second, - }, - ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), - FeeRecipient: "0x0000000000000000000000000000000000001234", + ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: time.Second, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: "0x0000000000000000000000000000000000001234", } err = runFeeRecipientSign(ctx, config) @@ -348,15 +342,13 @@ func TestFeeRecipientSignPubkeyNotInCluster(t *testing.T) { baseDir := filepath.Join(root, "op0") config := feerecipientSignConfig{ - feerecipientConfig: feerecipientConfig{ - ValidatorPublicKeys: []string{"0x" + strings.Repeat("ab", 48)}, - PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), - LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), - PublishAddress: srv.URL, - PublishTimeout: 10 * time.Second, - }, - ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), - FeeRecipient: "0x0000000000000000000000000000000000001234", + ValidatorPublicKeys: []string{"0x" + strings.Repeat("ab", 48)}, + PrivateKeyPath: filepath.Join(baseDir, "charon-enr-private-key"), + LockFilePath: filepath.Join(baseDir, "cluster-lock.json"), + PublishAddress: srv.URL, + PublishTimeout: 10 * time.Second, + ValidatorKeysDir: filepath.Join(baseDir, "validator_keys"), + FeeRecipient: "0x0000000000000000000000000000000000001234", } err = runFeeRecipientSign(ctx, config) diff --git a/cmd/run.go b/cmd/run.go index b98d0a24d5..6dea05dbb2 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -97,7 +97,7 @@ func bindRunFlags(cmd *cobra.Command, config *app.Config) { cmd.Flags().Uint64Var(&config.TestnetConfig.ChainID, "testnet-chain-id", 0, "Chain ID of the custom test network.") cmd.Flags().Int64Var(&config.TestnetConfig.GenesisTimestamp, "testnet-genesis-timestamp", 0, "Genesis timestamp of the custom test network.") cmd.Flags().StringVar(&config.TestnetConfig.CapellaHardFork, "testnet-capella-hard-fork", "", "Capella hard fork version of the custom test network.") - cmd.Flags().StringVar(&config.ProcDirectory, "proc-directory", "", "Directory to look into in order to detect other stack components running on the host.") + cmd.Flags().StringVar(&config.ProcDirectory, "proc-directory", "", "Directory to look into in order to detect other stack components running on the host. Enabling this exports the command lines of detected validator clients to the monitoring endpoint and debug logs, with the values of secret-shaped flags redacted.") cmd.Flags().StringVar(&config.ConsensusProtocol, "consensus-protocol", "", "Preferred consensus protocol name for the node. Selected automatically when not specified.") cmd.Flags().StringVar(&config.Nickname, "nickname", "", "Human friendly peer nickname. Maximum 32 characters.") cmd.Flags().StringSliceVar(&config.BeaconNodeHeaders, "beacon-node-headers", nil, "Comma separated list of headers formatted as header=value") diff --git a/cmd/testbeacon_internal_test.go b/cmd/testbeacon_internal_test.go index ed448e8c24..f90095f7f2 100644 --- a/cmd/testbeacon_internal_test.go +++ b/cmd/testbeacon_internal_test.go @@ -277,9 +277,7 @@ func TestBeaconTestTimeoutAlwaysProducesResults(t *testing.T) { var buf bytes.Buffer res, err := runTestBeacon(context.Background(), &buf, testBeaconConfig{ - testConfig: testConfig{ - Timeout: time.Nanosecond, - }, + Timeout: time.Nanosecond, Endpoints: endpoints, }) require.NoError(t, err) diff --git a/cmd/testinfra_internal_test.go b/cmd/testinfra_internal_test.go index 30a5ca7bf0..1cecdf1515 100644 --- a/cmd/testinfra_internal_test.go +++ b/cmd/testinfra_internal_test.go @@ -247,9 +247,7 @@ func TestInfraTestTimeoutAlwaysProducesResults(t *testing.T) { var buf bytes.Buffer res, err := runTestInfra(context.Background(), &buf, testInfraConfig{ - testConfig: testConfig{ - Timeout: time.Nanosecond, - }, + Timeout: time.Nanosecond, DiskTestTool: DiskTestToolMock{}, }) require.NoError(t, err) diff --git a/cmd/testmev_internal_test.go b/cmd/testmev_internal_test.go index f4576e4067..cdfcb1f3f1 100644 --- a/cmd/testmev_internal_test.go +++ b/cmd/testmev_internal_test.go @@ -277,9 +277,7 @@ func TestMEVTestTimeoutAlwaysProducesResults(t *testing.T) { var buf bytes.Buffer res, err := runTestMEV(context.Background(), &buf, testMEVConfig{ - testConfig: testConfig{ - Timeout: time.Nanosecond, - }, + Timeout: time.Nanosecond, Endpoints: endpoints, }) require.NoError(t, err) diff --git a/cmd/testvalidator_internal_test.go b/cmd/testvalidator_internal_test.go index 8377217156..d7c249106c 100644 --- a/cmd/testvalidator_internal_test.go +++ b/cmd/testvalidator_internal_test.go @@ -211,9 +211,7 @@ func TestValidatorTestTimeoutAlwaysProducesResults(t *testing.T) { var buf bytes.Buffer res, err := runTestValidator(context.Background(), &buf, testValidatorConfig{ - testConfig: testConfig{ - Timeout: time.Nanosecond, - }, + Timeout: time.Nanosecond, APIAddress: fmt.Sprintf("localhost:%v", testutil.GetFreePort(t)), }) require.NoError(t, err) diff --git a/core/aggsigdb/memory.go b/core/aggsigdb/memory.go index c3ae95f833..cee0be6298 100644 --- a/core/aggsigdb/memory.go +++ b/core/aggsigdb/memory.go @@ -95,7 +95,7 @@ func (db *MemDB) Await(ctx context.Context, duty core.Duty, pubKey core.PubKey, response := make(chan core.SignedData, 1) query := readQuery{ - memDBKey: memDBKey{duty: duty, pubKey: pubKey, subcommIdx: subcommIdx}, + duty: duty, pubKey: pubKey, subcommIdx: subcommIdx, response: response, cancel: cancel, } diff --git a/core/dutydb/memory_test.go b/core/dutydb/memory_test.go index d3c5a29977..07a0bca361 100644 --- a/core/dutydb/memory_test.go +++ b/core/dutydb/memory_test.go @@ -270,14 +270,12 @@ func TestMemDBAggregatorElectraMultiCommittee(t *testing.T) { bits.SetBitAt(uint64(commIdx), true) return core.VersionedAggregatedAttestation{ - VersionedAttestation: eth2spec.VersionedAttestation{ - Version: eth2spec.DataVersionElectra, - Electra: &electra.Attestation{ - AggregationBits: testutil.RandomBitList(64), - Data: attData, - Signature: testutil.RandomEth2Signature(), - CommitteeBits: bits, - }, + Version: eth2spec.DataVersionElectra, + Electra: &electra.Attestation{ + AggregationBits: testutil.RandomBitList(64), + Data: attData, + Signature: testutil.RandomEth2Signature(), + CommitteeBits: bits, }, } } diff --git a/core/scheduler/scheduler_test.go b/core/scheduler/scheduler_test.go index 6bb4d92d91..57249921b0 100644 --- a/core/scheduler/scheduler_test.go +++ b/core/scheduler/scheduler_test.go @@ -55,7 +55,7 @@ func TestIntegration(t *testing.T) { t.Fatal("BEACON_URL env var not set") } - eth2Cl, err := eth2wrap.NewMultiHTTP(time.Second*2, [4]byte{}, map[string]string{}, []string{beaconURL}, []string{}) + eth2Cl, err := eth2wrap.NewMultiHTTP(t.Context(), time.Second*2, [4]byte{}, map[string]string{}, []string{beaconURL}, []string{}) require.NoError(t, err) // Builder registrations for mainnet validators @@ -727,6 +727,13 @@ func (c *testClock) After(d time.Duration) <-chan time.Time { } func (c *testClock) Sleep(d time.Duration) { + // A non-positive duration fires immediately on a real clock and must never move time + // backwards. The slot ticker legitimately calls After with a non-positive duration for the + // current slot, which would otherwise rewind the fake clock and race with the test. + if d <= 0 { + return + } + for c.paused.Load() { runtime.Gosched() } diff --git a/core/ssz.go b/core/ssz.go index 4087cb8275..090997a63a 100644 --- a/core/ssz.go +++ b/core/ssz.go @@ -835,7 +835,7 @@ func sizeSSZValIdxVersioned(value sszType) int { } // VersionedBlindedSSZValueForT exposes the value method of a type for testing purposes. -func VersionedBlindedSSZValueForT(t *testing.T, value any, version eth2util.DataVersion, blinded bool) sszType { +func VersionedBlindedSSZValueForT(t *testing.T, value any, version eth2util.DataVersion, blinded bool) any { t.Helper() resp, err := value.(interface { @@ -847,7 +847,7 @@ func VersionedBlindedSSZValueForT(t *testing.T, value any, version eth2util.Data } // VersionedSSZValueForT exposes the value method of a type for testing purposes. -func VersionedSSZValueForT(t *testing.T, value any, version eth2util.DataVersion) sszType { +func VersionedSSZValueForT(t *testing.T, value any, version eth2util.DataVersion) any { t.Helper() resp, err := value.(interface { diff --git a/core/tracker/inclusion.go b/core/tracker/inclusion.go index 70c9f19609..8d4c77b324 100644 --- a/core/tracker/inclusion.go +++ b/core/tracker/inclusion.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "maps" + "math" "net/http" "slices" "strconv" @@ -589,7 +590,9 @@ func (a *InclusionChecker) Run(ctx context.Context) { } var ( - checkedSlot uint64 + // MaxUint64 sentinel so the first computed slot (0 at chain start) + // is not skipped as already-checked. + checkedSlot = uint64(math.MaxUint64) attesterDuties []*eth2v1.AttesterDuty ) @@ -598,7 +601,15 @@ func (a *InclusionChecker) Run(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - slot := uint64(time.Since(a.genesis)/a.slotDuration) - InclCheckLag + // Skip until a slot is old enough to check: the unsigned + // arithmetic below underflows before genesis (negative elapsed + // time) and during the first InclCheckLag slots. + sinceGenesis := time.Since(a.genesis) + if sinceGenesis < a.slotDuration*InclCheckLag { + continue + } + + slot := uint64(sinceGenesis/a.slotDuration) - InclCheckLag if checkedSlot == slot { continue } @@ -656,7 +667,13 @@ func (a *InclusionChecker) Run(ctx context.Context) { } checkedSlot = slot - a.core.Trim(ctx, slot-InclMissedLag) + + // Only trim once a slot is old enough to be declared missed: + // slot-InclMissedLag underflows otherwise, and Trim would then + // report every pending submission as not included on-chain. + if slot >= InclMissedLag { + a.core.Trim(ctx, slot-InclMissedLag) + } } } } diff --git a/core/tracker/inclusion_internal_test.go b/core/tracker/inclusion_internal_test.go index 84911f7e30..df1c6ae591 100644 --- a/core/tracker/inclusion_internal_test.go +++ b/core/tracker/inclusion_internal_test.go @@ -6,7 +6,9 @@ import ( "context" "math/rand" "slices" + "sync" "testing" + "time" "github.com/OffchainLabs/go-bitfield" eth2api "github.com/attestantio/go-eth2-client/api" @@ -579,3 +581,81 @@ func TestInclusion404Handling(t *testing.T) { require.Error(t, err, "checkBlockAndAtts should return an error for non-404 errors") }) } + +// TestRunNoFalseMissesAtChainStart is a regression test for the first slots +// after genesis: the trim-slot arithmetic underflowed, so Trim deleted every +// pending submission and reported fresh duties as "duty not included +// on-chain", and the zero-valued checkedSlot skipped the genesis slot's +// inclusion check entirely. +func TestRunNoFalseMissesAtChainStart(t *testing.T) { + // Run must take the checkBlockFunc path below, not checkBlockAndAttsFunc. + featureset.DisableForT(t, featureset.AttestationInclusion) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + bmock, err := beaconmock.New(ctx) + require.NoError(t, err) + + var ( + mu sync.Mutex + missed []core.Duty + ) + + incl := &inclusionCore{ + missedFunc: func(ctx context.Context, sub submission) { + mu.Lock() + defer mu.Unlock() + + missed = append(missed, sub.Duty) + }, + trackerInclFunc: func(duty core.Duty, key core.PubKey, data core.SignedData, err error) {}, + submissions: make(map[subkey]submission), + beaconCommittees: make(map[eth2p0.Slot][]*eth2v1.BeaconCommittee), + } + + // Pin the checked slot to 0 for the whole test: genesis InclCheckLag + // slots ago with a slot duration far longer than the test. + checked := make(chan uint64, 1) + checker := &InclusionChecker{ + core: incl, + eth2Cl: bmock, + genesis: time.Now().Add(-InclCheckLag * time.Hour), + slotDuration: time.Hour, + checkBlockFunc: func(ctx context.Context, slot uint64, found bool) { + incl.CheckBlock(ctx, slot, found) + + select { + case checked <- slot: + default: + } + }, + } + + // A pending proposal from slot 3: too recent to be declared missed. + block := testutil.RandomDenebVersionedSignedProposal() + coreBlock, err := core.NewVersionedSignedProposal(block) + require.NoError(t, err) + require.NoError(t, incl.Submitted(core.NewProposerDuty(3), "", coreBlock, 0)) + + var wg sync.WaitGroup + + wg.Go(func() { + checker.Run(ctx) + }) + + select { + case slot := <-checked: + require.Zero(t, slot, "the genesis slot must be checked, not skipped as already-checked") + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for the genesis slot inclusion check") + } + + cancel() + wg.Wait() // Run exits only after the tick completes, including any trim. + + mu.Lock() + defer mu.Unlock() + + require.Empty(t, missed, "fresh submissions must not be reported missed right after genesis") +} diff --git a/core/tracker/tracker.go b/core/tracker/tracker.go index b4058e97f9..7b4140dcd5 100644 --- a/core/tracker/tracker.go +++ b/core/tracker/tracker.go @@ -934,9 +934,9 @@ func reportParSigs(ctx context.Context, duty core.Duty, parsigMsgs parsigsByMsg) entriesJSON = json.RawMessage(fmt.Sprintf("%q", err.Error())) } - // DutySyncMessage is handled earlier via reportSyncMessageCohorts. Sync contribution - // inconsistency is expected (per-validator, per-subcommittee) so log at debug. - if duty.Type == core.DutySyncContribution { + // DutySyncMessage is handled earlier via reportSyncMessageCohorts. Sync contribution and + // selection proof inconsistency is expected (per-validator, per-subcommittee) so log at debug. + if duty.Type == core.DutySyncContribution || duty.Type == core.DutyPrepareSyncContribution { log.Debug(ctx, "Inconsistent sync committee partial signed data", z.Any("pubkey", pubkey), z.Any("duty", duty), diff --git a/core/tracker/tracker_internal_test.go b/core/tracker/tracker_internal_test.go index 6601b38a5d..05f68cbd35 100644 --- a/core/tracker/tracker_internal_test.go +++ b/core/tracker/tracker_internal_test.go @@ -18,8 +18,10 @@ import ( eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" promtestutil "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/core" "github.com/obolnetwork/charon/p2p" "github.com/obolnetwork/charon/testutil" @@ -1394,18 +1396,14 @@ func TestSubmittedProposals(t *testing.T) { require.NotPanics(t, func() { // Not blinded err = ic.Submitted(core.NewProposerDuty(42), testutil.RandomCorePubKey(t), core.VersionedSignedProposal{ - VersionedSignedProposal: eth2api.VersionedSignedProposal{ - Version: eth2spec.DataVersionDeneb, - }, + Version: eth2spec.DataVersionDeneb, }, 1*time.Millisecond) require.ErrorContains(t, err, "could not determine if proposal was synthetic or not") // Blinded err = ic.Submitted(core.NewProposerDuty(42), testutil.RandomCorePubKey(t), core.VersionedSignedProposal{ - VersionedSignedProposal: eth2api.VersionedSignedProposal{ - Blinded: true, - Version: eth2spec.DataVersionDeneb, - }, + Blinded: true, + Version: eth2spec.DataVersionDeneb, }, 1*time.Millisecond) require.ErrorContains(t, err, "could not determine if proposal was synthetic or not") }) @@ -1618,3 +1616,19 @@ func TestReportSyncMessageCohorts(t *testing.T) { require.InDelta(t, before, promtestutil.ToFloat64(cohortRank.WithLabelValues(attLabel, "0", "0")), 0) }) } + +// TestReportParSigsSyncSubcommittees verifies that a validator's sync contributions and selection proofs +// for different subcommittees aren't warned about as inconsistent, since their message roots differ. +func TestReportParSigsSyncSubcommittees(t *testing.T) { + for _, duty := range []core.Duty{core.NewSyncContributionDuty(123), core.NewPrepareSyncContributionDuty(123)} { + t.Run(duty.Type.String(), func(t *testing.T) { + var buf zaptest.Buffer + log.InitConsoleForT(t, &buf) + + reportParSigs(context.Background(), duty, parsigsByMsg{"pk": {rootFrom(0x0a): syncSigs(1, 2), rootFrom(0x0b): syncSigs(3)}}) + + require.Contains(t, buf.String(), "Inconsistent sync committee partial signed data") + require.NotContains(t, buf.String(), "Inconsistent partial signed data") + }) + } +} diff --git a/core/validatorapi/router_internal_test.go b/core/validatorapi/router_internal_test.go index 6ba8babfaa..b3e349205d 100644 --- a/core/validatorapi/router_internal_test.go +++ b/core/validatorapi/router_internal_test.go @@ -2461,31 +2461,29 @@ func (h testHandler) newBeaconHandler(t *testing.T) http.Handler { func TestUnmarshalSSZ(t *testing.T) { phase0Proposal := core.VersionedSignedProposal{ - VersionedSignedProposal: eth2api.VersionedSignedProposal{ - Version: eth2spec.DataVersionPhase0, - Phase0: ð2p0.SignedBeaconBlock{ - Message: ð2p0.BeaconBlock{ - Slot: 1, - ProposerIndex: 2, - ParentRoot: eth2p0.Root{0x01}, - StateRoot: eth2p0.Root{0x02}, - Body: ð2p0.BeaconBlockBody{ - RANDAOReveal: eth2p0.BLSSignature{0xAA}, - ETH1Data: ð2p0.ETH1Data{ - DepositRoot: eth2p0.Root{0x03}, - DepositCount: 5, - BlockHash: append([]byte{0x04}, make([]byte, 31)...), - }, - Graffiti: [32]byte{0x05}, - ProposerSlashings: []*eth2p0.ProposerSlashing{}, - AttesterSlashings: []*eth2p0.AttesterSlashing{}, - Attestations: []*eth2p0.Attestation{}, - Deposits: []*eth2p0.Deposit{}, - VoluntaryExits: []*eth2p0.SignedVoluntaryExit{}, + Version: eth2spec.DataVersionPhase0, + Phase0: ð2p0.SignedBeaconBlock{ + Message: ð2p0.BeaconBlock{ + Slot: 1, + ProposerIndex: 2, + ParentRoot: eth2p0.Root{0x01}, + StateRoot: eth2p0.Root{0x02}, + Body: ð2p0.BeaconBlockBody{ + RANDAOReveal: eth2p0.BLSSignature{0xAA}, + ETH1Data: ð2p0.ETH1Data{ + DepositRoot: eth2p0.Root{0x03}, + DepositCount: 5, + BlockHash: append([]byte{0x04}, make([]byte, 31)...), }, + Graffiti: [32]byte{0x05}, + ProposerSlashings: []*eth2p0.ProposerSlashing{}, + AttesterSlashings: []*eth2p0.AttesterSlashing{}, + Attestations: []*eth2p0.Attestation{}, + Deposits: []*eth2p0.Deposit{}, + VoluntaryExits: []*eth2p0.SignedVoluntaryExit{}, }, - Signature: eth2p0.BLSSignature{0xBB}, }, + Signature: eth2p0.BLSSignature{0xBB}, }, } diff --git a/core/validatorapi/validatorapi.go b/core/validatorapi/validatorapi.go index 5c4cb52005..b1595ab9ff 100644 --- a/core/validatorapi/validatorapi.go +++ b/core/validatorapi/validatorapi.go @@ -644,7 +644,7 @@ func (c Component) SubmitBlindedProposal(ctx context.Context, opts *eth2api.Subm return errors.Wrap(err, "could not fetch block definition from dutydb") } - if err := propDataMatchesDuty(ð2api.SubmitProposalOpts{ + blindedOpts := ð2api.SubmitProposalOpts{ Common: opts.Common, Proposal: ð2api.VersionedSignedProposal{ Version: opts.Proposal.Version, @@ -656,7 +656,10 @@ func (c Component) SubmitBlindedProposal(ctx context.Context, opts *eth2api.Subm FuluBlinded: opts.Proposal.Fulu, }, BroadcastValidation: opts.BroadcastValidation, - }, prop); err != nil { + } + + err = propDataMatchesDuty(blindedOpts, prop) + if err != nil { return errors.Wrap(err, "consensus proposal and VC-submitted one do not match") } diff --git a/dkg/bcast/client.go b/dkg/bcast/client.go index 25c410faf8..5f6c9d5048 100644 --- a/dkg/bcast/client.go +++ b/dkg/bcast/client.go @@ -65,7 +65,7 @@ func (c *client) Broadcast(ctx context.Context, msgID string, msg proto.Message) fork, join, cancel := forkjoin.New(ctx, func(ctx context.Context, pID peer.ID) (*pb.BCastSigResponse, error) { sigResp := new(pb.BCastSigResponse) - err := c.sendRecvFunc(ctx, c.p2pNode, pID, sigReq, sigResp, protocolIDSig) + err := c.sendRecvFunc(ctx, c.p2pNode, pID, sigReq, sigResp, protocolIDSig, p2p.WithSendTimeout(sendTimeout), p2p.WithRetries(sendRetries)) return sigResp, err }) @@ -133,7 +133,7 @@ func (c *client) Broadcast(ctx context.Context, msgID string, msg proto.Message) continue // Skip self. } - err := c.sendFunc(ctx, c.p2pNode, protocolIDMsg, pID, bcastMsg, p2p.WithSendTimeout(sendTimeout)) + err := c.sendFunc(ctx, c.p2pNode, protocolIDMsg, pID, bcastMsg, p2p.WithSendTimeout(sendTimeout), p2p.WithRetries(sendRetries)) if err != nil { return errors.Wrap(err, "send message") } diff --git a/dkg/bcast/helpers.go b/dkg/bcast/helpers.go index ac6f4a1228..cdb5a4af82 100644 --- a/dkg/bcast/helpers.go +++ b/dkg/bcast/helpers.go @@ -20,6 +20,11 @@ const ( protocolIDMsg = protocolIDPrefix + "/msg" receiveTimeout = time.Minute // Allow for peers to be out of sync, with some sending messages much earlier and having to wait. sendTimeout = receiveTimeout + 2*time.Second // Allow for server to timeout first. + + // sendRetries is the number of additional p2p send attempts. A DKG ceremony aborts on the + // first failed exchange, so transient failures (e.g. a stalled relay connection) are retried + // instead of failing the whole ceremony. Receivers deduplicate re-delivered messages. + sendRetries = 5 ) // hashFunc is a function that hashes a message ID and a any-wrapped protobuf message. diff --git a/dkg/bcast/impl_test.go b/dkg/bcast/impl_test.go index f50b9d3531..b39016453f 100644 --- a/dkg/bcast/impl_test.go +++ b/dkg/bcast/impl_test.go @@ -5,11 +5,13 @@ package bcast_test import ( "context" "testing" + "time" k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/libp2p/go-libp2p/core/host" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/peerstore" + "github.com/libp2p/go-libp2p/core/protocol" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" @@ -150,6 +152,157 @@ func TestBCast(t *testing.T) { assertResults(t, p0Result, peers[0]) } +// TestBCastRetriesTransientSendFailures ensures a reliable-broadcast survives transient +// p2p send failures in each phase instead of aborting the whole ceremony on the first one. +func TestBCastRetriesTransientSendFailures(t *testing.T) { + const ( + n = 3 + msgID = "msgID" + ) + + // The bcast protocol IDs are unexported; keep in sync with helpers.go. + tests := []struct { + name string + flakyProtocol protocol.ID + }{ + {name: "signature request phase", flakyProtocol: "/charon/dkg/bcast/2.0.0/sig"}, + {name: "message send phase", flakyProtocol: "/charon/dkg/bcast/2.0.0/msg"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var ( + ctx = context.Background() + secrets []*k1.PrivateKey + tcpNodes []host.Host + peers []peer.ID + ) + + for range n { + secret, err := k1.GeneratePrivateKey() + require.NoError(t, err) + + secrets = append(secrets, secret) + + tcpNode := testutil.CreateHostWithIdentity(t, testutil.AvailableAddr(t), secret) + tcpNodes = append(tcpNodes, tcpNode) + + peers = append(peers, tcpNode.ID()) + } + + for i := range n { + for j := range n { + tcpNodes[i].Peerstore().AddAddrs(tcpNodes[j].ID(), tcpNodes[j].Addrs(), peerstore.PermanentAddrTTL) + } + } + + // Node 0's first send to each peer in this phase fails transiently. + tcpNodes[0] = testutil.NewFlakyHost(tcpNodes[0], n-1, test.flakyProtocol) + + received := make(chan proto.Message, n) + callback := func(_ context.Context, _ peer.ID, _ string, msg proto.Message) error { + received <- msg + return nil + } + checkMessage := func(_ context.Context, _ peer.ID, msgAny *anypb.Any) error { + var ts timestamppb.Timestamp + if err := msgAny.UnmarshalTo(&ts); err != nil { + return errors.Wrap(err, "anypb error") + } + + return nil + } + + var bcasts []bcast.BroadcastFunc + + for i := range n { + bcastFunc := bcast.New(tcpNodes[i], peers, secrets[i], []byte("session hash")) + bcastFunc.RegisterMessageIDFuncs(msgID, callback, checkMessage) + bcasts = append(bcasts, bcastFunc.Broadcast) + } + + msg := timestamppb.Now() + err := bcasts[0](ctx, msgID, msg) + require.NoError(t, err) + + for range n - 1 { + require.True(t, proto.Equal(msg, <-received)) + } + }) + } +} + +// TestBCastSigRequestAllowsSlowPeer ensures a signature request tolerates a peer that +// responds slower than the default p2p send timeout (7s), since ceremony peers may +// lawfully lag by up to the bcast receive timeout (1min) while catching up. +func TestBCastSigRequestAllowsSlowPeer(t *testing.T) { + if testing.Short() { + t.Skip("skipping slow-peer timeout test in short mode") + } + + const ( + n = 2 + msgID = "msgID" + // slowDelay must exceed the 7s default p2p send timeout to prove the + // bcast send timeout is applied to signature requests. + slowDelay = 8 * time.Second + ) + + var ( + ctx = context.Background() + secrets []*k1.PrivateKey + tcpNodes []host.Host + peers []peer.ID + bcasts []bcast.BroadcastFunc + ) + + for range n { + secret, err := k1.GeneratePrivateKey() + require.NoError(t, err) + + secrets = append(secrets, secret) + + tcpNode := testutil.CreateHostWithIdentity(t, testutil.AvailableAddr(t), secret) + tcpNodes = append(tcpNodes, tcpNode) + + peers = append(peers, tcpNode.ID()) + } + + for i := range n { + for j := range n { + tcpNodes[i].Peerstore().AddAddrs(tcpNodes[j].ID(), tcpNodes[j].Addrs(), peerstore.PermanentAddrTTL) + } + } + + callback := func(context.Context, peer.ID, string, proto.Message) error { + return nil + } + + for i := range n { + delay := time.Duration(0) + if i == 1 { + delay = slowDelay // Node 1 is slow to check (and so sign) node 0's message. + } + + checkMessage := func(_ context.Context, _ peer.ID, msgAny *anypb.Any) error { + time.Sleep(delay) + + var ts timestamppb.Timestamp + if err := msgAny.UnmarshalTo(&ts); err != nil { + return errors.Wrap(err, "anypb error") + } + + return nil + } + + bcastFunc := bcast.New(tcpNodes[i], peers, secrets[i], []byte("session hash")) + bcastFunc.RegisterMessageIDFuncs(msgID, callback, checkMessage) + bcasts = append(bcasts, bcastFunc.Broadcast) + } + + require.NoError(t, bcasts[0](ctx, msgID, timestamppb.Now())) +} + // TestBCastSessionHashMismatch ensures that messages signed in one session // cannot be verified in another, binding broadcasts to the cluster session. func TestBCastSessionHashMismatch(t *testing.T) { diff --git a/dkg/dkg.go b/dkg/dkg.go index e1c9fa9d88..b391d56769 100644 --- a/dkg/dkg.go +++ b/dkg/dkg.go @@ -526,7 +526,7 @@ func startSyncProtocol(ctx context.Context, p2pNode host.Host, key *k1.PrivateKe ) (stepSyncFunc func(context.Context) error, shutdownFunc func(context.Context) error, fatalFunc func() error, err error) { // Sign definition hash with charon-enr-private-key // Note: libp2p signing does another hash of the defHash. - hashSig, err := ((*libp2pcrypto.Secp256k1PrivateKey)(key)).Sign(defHash) + hashSig, err := (*libp2pcrypto.Secp256k1PrivateKey)(key).Sign(defHash) if err != nil { return nil, nil, nil, errors.Wrap(err, "sign definition hash") } diff --git a/dkg/exchanger.go b/dkg/exchanger.go index 94b85bb984..48a44276cb 100644 --- a/dkg/exchanger.go +++ b/dkg/exchanger.go @@ -11,6 +11,8 @@ import ( "github.com/libp2p/go-libp2p/core/host" "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/protocol" + "google.golang.org/protobuf/proto" "github.com/obolnetwork/charon/app/errors" "github.com/obolnetwork/charon/app/z" @@ -38,6 +40,32 @@ const ( // Do not add new values greater than sigDepositData. ) +const ( + // sendRetries is the number of additional p2p send attempts during DKG ceremonies. + // A DKG aborts on the first failed exchange, so transient failures (e.g. a stalled + // relay connection) are retried instead of failing the whole ceremony. All DKG + // receive paths deduplicate messages, so a retry of an already-delivered message + // is harmless. + sendRetries = 5 + + // sendTimeout is the per-attempt p2p send timeout during DKG ceremonies. Ceremony peers + // may lag behind (e.g. slower operators or relay-routed connections), so sends get much + // more headroom than the 7s p2p default. + sendTimeout = time.Minute +) + +// newRetryingSendFunc returns a p2p.SendFunc wrapping p2p.Send with the given send +// timeout and sendRetries retries. +func newRetryingSendFunc(timeout time.Duration) p2p.SendFunc { + return func(ctx context.Context, p2pNode host.Host, protoID protocol.ID, peerID peer.ID, + msg proto.Message, opts ...p2p.SendRecvOption, + ) error { + opts = append(opts, p2p.WithSendTimeout(timeout), p2p.WithRetries(sendRetries)) + + return p2p.Send(ctx, p2pNode, protoID, peerID, msg, opts...) + } +} + // sigTypeStore is a shorthand for a map of sigType to map of core.PubKey to slice of core.ParSignedData. type sigTypeStore map[sigType]map[core.PubKey][]core.ParSignedData @@ -111,7 +139,7 @@ func newExchanger(p2pNode host.Host, peerIdx int, peers []peer.ID, peerMap map[p ex := &exchanger{ // threshold is len(peers) to wait until we get all the partial sigs from all the peers per DV sigdb: parsigdb.NewMemDB(len(peers), noopDeadliner{}, parsigdb.NewMemDBMetadata(0, time.Now())), // metadata timestamps are used for metrics, irrelevant for DKG - sigex: parsigex.NewParSigEx(p2pNode, p2p.Send, peerIdx, peers, verifyShareIdx, dutyGaterFunc, p2p.WithSendTimeout(timeout), p2p.WithReceiveTimeout(timeout)), + sigex: parsigex.NewParSigEx(p2pNode, newRetryingSendFunc(timeout), peerIdx, peers, verifyShareIdx, dutyGaterFunc, p2p.WithSendTimeout(timeout), p2p.WithReceiveTimeout(timeout)), sigTypes: st, sigData: dataByPubkey{ store: sigTypeStore{}, diff --git a/dkg/exchanger_internal_test.go b/dkg/exchanger_internal_test.go index 86656c5816..09771acc77 100644 --- a/dkg/exchanger_internal_test.go +++ b/dkg/exchanger_internal_test.go @@ -194,6 +194,68 @@ func TestExchanger(t *testing.T) { require.Len(t, actual, len(expectedSigTypes)) } +// TestExchangerRetriesTransientSendFailures ensures the DKG signature exchange survives +// transient p2p send failures instead of aborting the whole ceremony on the first one. +func TestExchangerRetriesTransientSendFailures(t *testing.T) { + ctx := context.Background() + + const nodes = 3 + + pubkey := testutil.RandomCorePubKey(t) + + var ( + peers []peer.ID + hosts []host.Host + hostsInfo []peer.AddrInfo + ) + + for range nodes { + h := testutil.CreateHost(t, testutil.AvailableAddr(t)) + hostsInfo = append(hostsInfo, peer.AddrInfo{ID: h.ID(), Addrs: h.Addrs()}) + peers = append(peers, h.ID()) + hosts = append(hosts, h) + } + + for i := range nodes { + for j := range nodes { + if i == j { + continue + } + + hosts[i].Peerstore().AddAddrs(hostsInfo[j].ID, hostsInfo[j].Addrs, peerstore.PermanentAddrTTL) + } + } + + // Node 0's first send to each peer fails transiently. + hosts[0] = testutil.NewFlakyHost(hosts[0], nodes-1) + + var exchangers []*exchanger + + for i := range nodes { + ex, err := newExchanger(hosts[i], i, peers, positionalPeerMap(peers), []sigType{sigLock}, 8*time.Second) + require.NoError(t, err) + + exchangers = append(exchangers, ex) + } + + errChan := make(chan error, nodes) + + for i := range nodes { + go func(node int) { + set := core.ParSignedDataSet{ + pubkey: core.NewPartialSignature(testutil.RandomCoreSignature(), node+1), + } + + _, err := exchangers[node].exchange(ctx, sigLock, set) + errChan <- err + }(i) + } + + for range nodes { + require.NoError(t, <-errChan) + } +} + // TestExchangerPushPsigsNeverBlocks fires pushPsigs repeatedly with no exchange call draining // results, which must not block. A previous implementation used a shared size-1 channel that // deadlocked once full, especially when pushPsigs ran synchronously on the exchange goroutine. diff --git a/dkg/frostp2p.go b/dkg/frostp2p.go index 896332cede..24bca7de6d 100644 --- a/dkg/frostp2p.go +++ b/dkg/frostp2p.go @@ -305,7 +305,7 @@ func (f *frostP2P) Round1(ctx context.Context, castR1 map[msgKey]frost.Round1Bca return nil, nil, errors.New("bug: unexpected p2p message to self") } - err := p2p.Send(ctx, f.p2pNode, round1P2PID, pID, p2pMsg) + err := p2p.Send(ctx, f.p2pNode, round1P2PID, pID, p2pMsg, p2p.WithSendTimeout(sendTimeout), p2p.WithRetries(sendRetries)) if err != nil { return nil, nil, err } diff --git a/dkg/frostp2p_internal_test.go b/dkg/frostp2p_internal_test.go index 2e72dae8d9..21389f752c 100644 --- a/dkg/frostp2p_internal_test.go +++ b/dkg/frostp2p_internal_test.go @@ -5,17 +5,103 @@ package dkg import ( "context" "testing" + "time" k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/libp2p/go-libp2p/core/host" "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/peerstore" "github.com/stretchr/testify/require" "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/dkg/bcast" pb "github.com/obolnetwork/charon/dkg/dkgpb/v1" "github.com/obolnetwork/charon/testutil" ) +// TestFrostRound1RetriesTransientSendFailures ensures the frost round-1 p2p transport +// survives transient send failures instead of aborting the whole ceremony on the first one. +func TestFrostRound1RetriesTransientSendFailures(t *testing.T) { + const ( + nodes = 3 + threshold = 2 + numVals = 2 + dkgCtx = "test round 1 retries" + ) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + var ( + secrets []*k1.PrivateKey + hosts []host.Host + peers []peer.ID + peerMap = make(map[peer.ID]cluster.NodeIdx) + ) + + for i := range nodes { + secret, err := k1.GeneratePrivateKey() + require.NoError(t, err) + + secrets = append(secrets, secret) + + h := testutil.CreateHostWithIdentity(t, testutil.AvailableAddr(t), secret) + hosts = append(hosts, h) + peers = append(peers, h.ID()) + peerMap[h.ID()] = cluster.NodeIdx{PeerIdx: i, ShareIdx: i + 1} + } + + for i := range nodes { + for j := range nodes { + if i == j { + continue + } + + hosts[i].Peerstore().AddAddrs(peers[j], hosts[j].Addrs(), peerstore.PermanentAddrTTL) + } + } + + // Node 0's first round-1 p2p send to each peer fails transiently, + // leaving the reliable-broadcast phase untouched. + hosts[0] = testutil.NewFlakyHost(hosts[0], nodes-1, round1P2PID) + + var transports []*frostP2P + + for i := range nodes { + bcastComp := bcast.New(hosts[i], peers, secrets[i], []byte("session hash")) + + tp, err := newFrostP2P(hosts[i], peerMap, bcastComp, threshold, numVals) + require.NoError(t, err) + + transports = append(transports, tp) + } + + errChan := make(chan error, nodes) + + for i := range nodes { + go func(node int) { + validators, err := newFrostParticipants(numVals, nodes, threshold, uint32(node+1), dkgCtx) + if err != nil { + errChan <- err + return + } + + castR1, p2pR1, err := round1(validators) + if err != nil { + errChan <- err + return + } + + _, _, err = transports[node].Round1(ctx, castR1, p2pR1) + errChan <- err + }(i) + } + + for range nodes { + require.NoError(t, <-errChan) + } +} + func TestBcastCallback(t *testing.T) { const ( n = 4 diff --git a/dkg/pedersen/board.go b/dkg/pedersen/board.go index 0e4ead7e0c..815ec560da 100644 --- a/dkg/pedersen/board.go +++ b/dkg/pedersen/board.go @@ -8,6 +8,7 @@ import ( "crypto/sha256" "path" "sync" + "time" kdkg "github.com/drand/kyber/share/dkg" "github.com/libp2p/go-libp2p/core/host" @@ -56,6 +57,16 @@ type ValidatorPubKeyShare struct { const ( protocolID = "/charon/dkg/pedersen/1.0.0" + + // sendRetries is the number of additional p2p send attempts. A DKG ceremony cannot + // complete if a bundle is dropped, so transient failures (e.g. a stalled relay + // connection) are retried instead. Receivers deduplicate re-delivered messages. + sendRetries = 5 + + // sendTimeout is the per-attempt p2p send timeout. Ceremony peers may lag behind + // (e.g. slower operators or relay-routed connections), so sends get much more + // headroom than the 7s p2p default. + sendTimeout = time.Minute ) var ( @@ -72,15 +83,18 @@ var ( // In the future Kyber fork we will address this and fix all logging as well. func NewBoard(ctx context.Context, host host.Host, config *Config, bcastComp *bcast.Component) *Board { board := &Board{ - logCtx: log.WithTopic(ctx, "pedersen"), - host: host, - sender: new(p2p.Sender), - config: config, - bcastComp: bcastComp, - dedup: newBundleDedup(), - dealCh: make(chan kdkg.DealBundle), - responseCh: make(chan kdkg.ResponseBundle), - justificationCh: make(chan kdkg.JustificationBundle), + logCtx: log.WithTopic(ctx, "pedersen"), + host: host, + sender: new(p2p.Sender), + config: config, + bcastComp: bcastComp, + dedup: newBundleDedup(), + // Buffer bundle channels to cluster size: peers ahead in the ceremony send + // bundles before this node's kyber protocol reads them, and each node sends + // at most one bundle of each type per protocol run. + dealCh: make(chan kdkg.DealBundle, config.Nodes()), + responseCh: make(chan kdkg.ResponseBundle, config.Nodes()), + justificationCh: make(chan kdkg.JustificationBundle, config.Nodes()), dealOutCh: make(chan kdkg.DealBundle), responseOutCh: make(chan kdkg.ResponseBundle), justificationOutCh: make(chan kdkg.JustificationBundle), @@ -254,7 +268,7 @@ func (b *Board) broadcastP2P(ctx context.Context, msgID string, msg proto.Messag continue } - if err := b.sender.SendAsync(ctx, b.host, protocol.ID(msgID), peerID, msg); err != nil { + if err := b.sender.SendAsync(ctx, b.host, protocol.ID(msgID), peerID, msg, p2p.WithSendTimeout(sendTimeout), p2p.WithRetries(sendRetries)); err != nil { return errors.Wrap(err, "p2p send", z.Str("msg", msgID), z.Str("to", peerID.String())) } } @@ -334,6 +348,8 @@ func (b *Board) handleDealBundleMessage(ctx context.Context, peerID peer.ID, msg select { case b.dealCh <- bundle: case <-ctx.Done(): + // Forget the dropped bundle so a redelivery is not refused as a duplicate. + b.dedup.forget(dealBundleMsg, protoBundle.GetSignature()) log.Error(b.logCtx, "Dropping deal bundle, context done", nil, z.Str("from", peerID.String())) } @@ -362,6 +378,8 @@ func (b *Board) handleResponseBundleMessage(ctx context.Context, peerID peer.ID, select { case b.responseCh <- bundle: case <-ctx.Done(): + // Forget the dropped bundle so a redelivery is not refused as a duplicate. + b.dedup.forget(respBundleMsg, protoBundle.GetSignature()) log.Error(b.logCtx, "Dropping response bundle, context done", nil, z.Str("from", peerID.String())) } @@ -388,6 +406,8 @@ func (b *Board) handleJustificationBundleMessage(ctx context.Context, peerID pee select { case b.justificationCh <- bundle: case <-ctx.Done(): + // Forget the dropped bundle so a redelivery is not refused as a duplicate. + b.dedup.forget(justBundleMsg, protoBundle.GetSignature()) log.Error(b.logCtx, "Dropping justification bundle, context done", nil, z.Str("from", peerID.String())) } @@ -444,6 +464,16 @@ func (d *bundleDedup) isDuplicate(msgType string, signature []byte) bool { return false } +// forget removes a bundle from the seen set, allowing it to be delivered again. +func (d *bundleDedup) forget(msgType string, signature []byte) { + h := sha256.Sum256(append([]byte(msgType), signature...)) + + d.mu.Lock() + defer d.mu.Unlock() + + delete(d.seen, h) +} + // forwardBundles forwards bundles from in to out until ctx is canceled, then closes out. // Kyber FastSync protocol goroutines exit when a board channel closes, so this stops // them without modifying the kyber library. diff --git a/dkg/pedersen/board_internal_test.go b/dkg/pedersen/board_internal_test.go new file mode 100644 index 0000000000..83f80c4269 --- /dev/null +++ b/dkg/pedersen/board_internal_test.go @@ -0,0 +1,238 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package pedersen + +import ( + "context" + "testing" + "time" + + "github.com/drand/kyber" + kdkg "github.com/drand/kyber/share/dkg" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/cluster" + "github.com/obolnetwork/charon/testutil" +) + +// bundleKind abstracts delivering a bundle of one type via its p2p handler and +// receiving it from the corresponding incoming board channel. +type bundleKind struct { + name string + deliver func(ctx context.Context, t *testing.T, board *Board, from peer.ID, idx uint32, session, sig []byte) + receive func(t *testing.T, board *Board) (uint32, bool) +} + +func bundleKinds() []bundleKind { + return []bundleKind{ + { + name: "deal", + deliver: func(ctx context.Context, t *testing.T, board *Board, from peer.ID, idx uint32, session, sig []byte) { + t.Helper() + + protoBundle, err := DealBundleToProto(kdkg.DealBundle{ + DealerIndex: idx, + Deals: []kdkg.Deal{ + { + ShareIndex: 1, + EncryptedShare: []byte{1, 2, 3}, + }, + }, + Public: []kyber.Point{RandomPoint(t)}, + SessionID: session, + Signature: sig, + }) + require.NoError(t, err) + + _, _, err = board.handleDealBundleMessage(ctx, from, protoBundle) + require.NoError(t, err) + }, + receive: func(t *testing.T, board *Board) (uint32, bool) { + t.Helper() + + select { + case bundle := <-board.IncomingDeal(): + return bundle.DealerIndex, true + case <-time.After(time.Second): + return 0, false + } + }, + }, + { + name: "response", + deliver: func(ctx context.Context, t *testing.T, board *Board, from peer.ID, idx uint32, session, sig []byte) { + t.Helper() + + protoBundle, err := ResponseBundleToProto(kdkg.ResponseBundle{ + ShareIndex: idx, + Responses: []kdkg.Response{ + { + DealerIndex: 1, + Status: true, + }, + }, + SessionID: session, + Signature: sig, + }) + require.NoError(t, err) + + _, _, err = board.handleResponseBundleMessage(ctx, from, protoBundle) + require.NoError(t, err) + }, + receive: func(t *testing.T, board *Board) (uint32, bool) { + t.Helper() + + select { + case bundle := <-board.IncomingResponse(): + return bundle.ShareIndex, true + case <-time.After(time.Second): + return 0, false + } + }, + }, + { + name: "justification", + deliver: func(ctx context.Context, t *testing.T, board *Board, from peer.ID, idx uint32, session, sig []byte) { + t.Helper() + + protoBundle, err := JustificationBundleToProto(kdkg.JustificationBundle{ + DealerIndex: idx, + Justifications: []kdkg.Justification{ + { + ShareIndex: 1, + Share: RandomScalar(t), + }, + }, + SessionID: session, + Signature: sig, + }) + require.NoError(t, err) + + _, _, err = board.handleJustificationBundleMessage(ctx, from, protoBundle) + require.NoError(t, err) + }, + receive: func(t *testing.T, board *Board) (uint32, bool) { + t.Helper() + + select { + case bundle := <-board.IncomingJustification(): + return bundle.DealerIndex, true + case <-time.After(time.Second): + return 0, false + } + }, + }, + } +} + +// TestBoardBuffersEarlyBundles verifies that bundles arriving before the kyber +// protocol starts reading the incoming board channels are buffered and delivered +// later, instead of being dropped when the p2p request context expires. This +// happens when peers start the DKG a few seconds earlier than this node. +func TestBoardBuffersEarlyBundles(t *testing.T) { + const ( + numNodes = 4 + threshold = 3 + ) + + for _, kind := range bundleKinds() { + t.Run(kind.name, func(t *testing.T) { + board, nodes, session := newTestBoard(t, numNodes, threshold) + + // Deliver bundles from all other peers before anything reads the + // incoming channel. Each handler call gets a short-lived context, + // like an expiring p2p request context. + for i := 1; i < numNodes; i++ { + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + kind.deliver(ctx, t, board, nodes[i].NodeHost.ID(), uint32(i), session, []byte{byte(i)}) + cancel() + } + + // All early bundles must be delivered once the protocol starts reading. + received := make(map[uint32]bool) + + for i := 1; i < numNodes; i++ { + idx, ok := kind.receive(t, board) + if !ok { + require.Failf(t, "early bundle dropped", "received %d of %d bundles", len(received), numNodes-1) + } + + received[idx] = true + } + + require.Len(t, received, numNodes-1) + }) + } +} + +// TestBoardAcceptsRedeliveryAfterDrop verifies that a bundle dropped because the +// request context expired is not recorded as a duplicate, so a redelivery of the +// same bundle is accepted. +func TestBoardAcceptsRedeliveryAfterDrop(t *testing.T) { + const ( + numNodes = 4 + threshold = 3 + ) + + for _, kind := range bundleKinds() { + t.Run(kind.name, func(t *testing.T) { + board, nodes, session := newTestBoard(t, numNodes, threshold) + from := nodes[1].NodeHost.ID() + + // Fill the channel buffer plus the forwarder's in-flight slot so the + // next delivery blocks and gets dropped. + total := numNodes + 1 + for i := range total { + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + kind.deliver(ctx, t, board, from, uint32(i), session, []byte{byte(i)}) + cancel() + } + + // This bundle is dropped since nothing is reading yet and the buffer is full. + droppedIdx := uint32(total) + droppedSig := []byte{byte(total)} + + ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond) + kind.deliver(ctx, t, board, from, droppedIdx, session, droppedSig) + cancel() + + // Drain the buffered bundles. + for range total { + _, ok := kind.receive(t, board) + require.True(t, ok, "buffered bundle not delivered") + } + + // Redeliver the dropped bundle. It must not be refused as a duplicate. + ctx, cancel = context.WithTimeout(t.Context(), time.Second) + defer cancel() + + kind.deliver(ctx, t, board, from, droppedIdx, session, droppedSig) + + idx, ok := kind.receive(t, board) + require.True(t, ok, "redelivered bundle refused as duplicate") + require.Equal(t, droppedIdx, idx) + }) + } +} + +func newTestBoard(t *testing.T, numNodes, threshold int) (*Board, []*TestNode, []byte) { + t.Helper() + + var ( + peers []peer.ID + peerMap = make(map[peer.ID]cluster.NodeIdx) + session = testutil.RandomArray32() + ) + + nodes := make([]*TestNode, numNodes) + for i := range numNodes { + nodes[i] = NewTestNode(t, i) + peerMap[nodes[i].NodeHost.ID()] = nodes[i].NodeIdx + peers = append(peers, nodes[i].NodeHost.ID()) + } + + nodes[0].InitBoard(t, threshold, peers, peerMap, session[:]) + + return nodes[0].Board, nodes, session[:] +} diff --git a/dkg/pedersen/board_test.go b/dkg/pedersen/board_test.go index 97a8c07fbb..f012d4da59 100644 --- a/dkg/pedersen/board_test.go +++ b/dkg/pedersen/board_test.go @@ -4,6 +4,7 @@ package pedersen_test import ( "testing" + "time" "github.com/drand/kyber" kdkg "github.com/drand/kyber/share/dkg" @@ -15,6 +16,63 @@ import ( "github.com/obolnetwork/charon/testutil" ) +// TestBoardRetriesTransientSendFailures ensures board bundle broadcasts survive transient +// p2p send failures instead of silently dropping the bundle for a peer. +func TestBoardRetriesTransientSendFailures(t *testing.T) { + const ( + numNodes = 3 + threshold = 2 + ) + + var ( + peers []peer.ID + peerMap = make(map[peer.ID]cluster.NodeIdx) + session = testutil.RandomArray32() + ) + + nodes := make([]*pedersen.TestNode, numNodes) + for i := range numNodes { + nodes[i] = pedersen.NewTestNode(t, i) + peerMap[nodes[i].NodeHost.ID()] = nodes[i].NodeIdx + peers = append(peers, nodes[i].NodeHost.ID()) + } + + pedersen.ConnectTestNodes(t, nodes) + + // Node 0's first send to each peer fails transiently. + nodes[0].NodeHost = testutil.NewFlakyHost(nodes[0].NodeHost, numNodes-1) + + for i := range nodes { + nodes[i].InitBoard(t, threshold, peers, peerMap, session[:]) + } + + dealBundle := kdkg.DealBundle{ + DealerIndex: 0, + Deals: []kdkg.Deal{ + { + ShareIndex: 1, + EncryptedShare: []byte{1, 2, 3}, + }, + }, + Public: []kyber.Point{ + pedersen.RandomPoint(t), + }, + SessionID: []byte("sessionID"), + Signature: []byte{13, 14, 15}, + } + + nodes[0].Board.PushDeals(&dealBundle) + + for i := range nodes { + select { + case received := <-nodes[i].Board.IncomingDeal(): + require.Equal(t, []byte{13, 14, 15}, received.Signature) + case <-time.After(10 * time.Second): + require.Fail(t, "timed out waiting for deal bundle", "node %d", i) + } + } +} + func TestBoard(t *testing.T) { const ( numNodes = 4 diff --git a/docs/configuration.md b/docs/configuration.md index 09cb850501..296bb7d916 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -199,7 +199,7 @@ Flags: --p2p-udp-address strings Comma-separated list of listening UDP addresses (ip and port) for libP2P traffic. Empty default doesn't bind to local port therefore only supports outgoing connections. --private-key-file string The path to the charon enr private key file. (default ".charon/charon-enr-private-key") --private-key-file-lock Enables private key locking to prevent multiple instances using the same key. - --proc-directory string Directory to look into in order to detect other stack components running on the host. + --proc-directory string Directory to look into in order to detect other stack components running on the host. Enabling this exports the command lines of detected validator clients to the monitoring endpoint and debug logs, with the values of secret-shaped flags redacted. --publish-address string The URL of the remote API for background fee recipient fetching. (default "https://api.obol.tech/v1") --publish-timeout duration Timeout for accessing the remote API. (default 5m0s) --simnet-beacon-mock Enables an internal mock beacon node for running a simnet. diff --git a/docs/metrics.md b/docs/metrics.md index b20cfe7c03..61b4d60d89 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -100,6 +100,11 @@ when storing metrics from multiple nodes or clusters in one Prometheus instance. | `core_validatorapi_request_latency_seconds` | Histogram | The validatorapi request latencies in seconds by endpoint | `endpoint` | | `core_validatorapi_request_total` | Counter | The total number of requests per content-type and endpoint | `endpoint, content_type` | | `core_validatorapi_vc_user_agent` | Gauge | Gauge with label set to user agent string of requests made by VC | `user_agent` | +| `p2p_concurrent_requests` | Histogram | Number of concurrently handled inbound messages, observed at each message arrival, by protocol and sending peer. Unlike the sampled inflight_requests gauge, this captures bursts between scrapes. | `protocol, peer` | +| `p2p_handler_duration_seconds` | Histogram | Duration of inbound libp2p message handling from stream accept to handler completion by protocol. Explains inflight_requests: inflight equals message rate times this duration. | `protocol` | +| `p2p_inflight_requests` | Gauge | Current number of inbound libp2p messages being handled (stream accept to handler completion) by protocol and sending peer. | `protocol, peer` | +| `p2p_message_handler_panics_total` | Counter | Total number of panics recovered while handling an inbound libp2p message by protocol and sending peer. A non-zero value indicates a handler bug or malformed peer message. | `protocol, peer` | +| `p2p_message_read_errors_total` | Counter | Total number of failures reading a libp2p message by protocol and sending peer. Includes messages exceeding the protocol read limit. | `protocol, peer` | | `p2p_peer_connection_total` | Counter | Total number of libp2p connections per peer. | `peer` | | `p2p_peer_connection_types` | Gauge | Current number of libp2p connections by peer, type (`direct` or `relay`), and protocol (`tcp`, `quic`). Note that peers may have multiple connections. | `peer, type, protocol` | | `p2p_peer_network_receive_bytes_total` | Counter | Total number of network bytes received from the peer by protocol and transport. Transport is based on first active connection (accurate in steady state). | `peer, protocol, transport` | @@ -109,9 +114,11 @@ when storing metrics from multiple nodes or clusters in one Prometheus instance. | `p2p_ping_latency_secs` | Histogram | Ping latencies in seconds per peer | `peer` | | `p2p_ping_success` | Gauge | Whether the last ping was successful (1) or not (0). Can be used as proxy for connected peers | `peer` | | `p2p_reachability_status` | Gauge | Current libp2p reachability status of this node as detected by autonat: unknown(0), public(1) or private(2). | | +| `p2p_received_message_size_bytes` | Histogram | Size in bytes of received libp2p protobuf messages by protocol and sending peer. | `protocol, peer` | | `p2p_relay_connection_types` | Gauge | Current number of libp2p connections by relay, type (`direct` or `relay`), and protocol (`tcp`, `quic`). Note that peers may have multiple connections. | `peer, type, protocol` | | `p2p_relay_connections` | Gauge | Connected relays by name | `peer` | -| `p2p_send_duration_seconds` | Histogram | Wall-clock duration of synchronous libp2p Send (one-way) and SendReceive (round-trip) calls, by peer, protocol, and topic. Topic is a sub-protocol label (e.g. qbft_pre_prepare, parsigex_proposer); empty when not set by the caller. | `peer, protocol, topic` | +| `p2p_send_duration_seconds` | Histogram | Wall-clock duration of synchronous libp2p Send (one-way) and SendReceive (round-trip) attempts, by peer, protocol, and topic. Sends with retries enabled observe each attempt separately, excluding backoff. Topic is a sub-protocol label (e.g. qbft_pre_prepare, parsigex_proposer); empty when not set by the caller. | `peer, protocol, topic` | +| `p2p_sent_message_size_bytes` | Histogram | Size in bytes of sent libp2p protobuf messages by protocol. Not labelled by peer since duty messages are broadcast identically to all peers. | `protocol` | | `relay_p2p_active_connections` | Gauge | Current number of active connections by peer and cluster | `peer, peer_cluster` | | `relay_p2p_connection_total` | Counter | Total number of new connections by peer and cluster | `peer, peer_cluster` | | `relay_p2p_network_receive_bytes_total` | Counter | Total number of network bytes received from the peer and cluster | `peer, peer_cluster` | diff --git a/eth2util/deposit/deposit.go b/eth2util/deposit/deposit.go index 0745541130..340551e65a 100644 --- a/eth2util/deposit/deposit.go +++ b/eth2util/deposit/deposit.go @@ -210,6 +210,49 @@ func GetMessageSigningRoot(msg eth2p0.DepositMessage, network string) ([32]byte, return resp, nil } +// WithdrawalCredsFromAddrOrCreds returns withdrawal credentials for either a 0x-prefixed 20 byte +// execution address or 0x-prefixed 32 byte 0x01/0x02 withdrawal credentials. +// Mixed case addresses must match their EIP-55 checksum, all lower or all upper case addresses are accepted. +// The credentials prefix must match the compounding flag. +func WithdrawalCredsFromAddrOrCreds(input string, compounding bool) ([32]byte, error) { + hexPart, ok := strings.CutPrefix(input, "0x") + if !ok { + return [32]byte{}, errors.New("withdrawal address or credentials must be 0x-prefixed", z.Str("input", input)) + } + + switch len(hexPart) { + case 20 * 2: + if _, err := eth2util.ValidateChecksumAddress(input); err != nil { + return [32]byte{}, errors.Wrap(err, "invalid withdrawal address") + } + + return withdrawalCredsFromAddr("0x"+strings.ToLower(hexPart), compounding) + case 32 * 2: + b, err := hex.DecodeString(hexPart) + if err != nil { + return [32]byte{}, errors.Wrap(err, "decode withdrawal credentials", z.Str("creds", input)) + } + + if slices.ContainsFunc(b[1:12], func(b byte) bool { return b != 0 }) { + return [32]byte{}, errors.New("withdrawal credentials bytes 1 to 11 must be zero", z.Str("creds", input)) + } + + creds, err := withdrawalCredsFromAddr("0x"+hex.EncodeToString(b[12:]), compounding) + if err != nil { + return [32]byte{}, err + } + + if b[0] != creds[0] { + return [32]byte{}, errors.New("withdrawal credentials prefix does not match cluster compounding setting", + z.Str("creds", input), z.Bool("compounding", compounding)) + } + + return creds, nil + default: + return [32]byte{}, errors.New("withdrawal address must be 20 bytes or withdrawal credentials 32 bytes", z.Str("input", input)) + } +} + // withdrawalCredsFromAddr returns the Withdrawal Credentials. func withdrawalCredsFromAddr(addr string, compounding bool) ([32]byte, error) { // Check for validity of address. diff --git a/eth2util/deposit/deposit_test.go b/eth2util/deposit/deposit_test.go index 567637435c..b54ef1a203 100644 --- a/eth2util/deposit/deposit_test.go +++ b/eth2util/deposit/deposit_test.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path" + "strings" "testing" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" @@ -473,3 +474,48 @@ func TestDedupAmounts(t *testing.T) { require.Equal(t, []eth2p0.Gwei{0, 100, 300, 500}, amounts) } + +func TestWithdrawalCredsFromAddrOrCreds(t *testing.T) { + const ( + checksummed = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" + lower = "5aaeb6053f3e94c9b9a09f33669435e7ef1beaed" + ) + + tests := []struct { + name string + input string + compounding bool + expected string + expectedErr string + }{ + {name: "checksummed address", input: checksummed, expected: "010000000000000000000000" + lower}, + {name: "lowercase address", input: "0x" + lower, expected: "010000000000000000000000" + lower}, + {name: "uppercase address", input: "0x" + strings.ToUpper(lower), expected: "010000000000000000000000" + lower}, + {name: "compounding address", input: checksummed, compounding: true, expected: "020000000000000000000000" + lower}, + {name: "bad checksum", input: "0x5AAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", expectedErr: "does not match its EIP-55 checksum"}, + {name: "0x01 credentials", input: "0x010000000000000000000000" + lower, expected: "010000000000000000000000" + lower}, + {name: "0x02 credentials", input: "0x020000000000000000000000" + strings.ToUpper(lower), compounding: true, expected: "020000000000000000000000" + lower}, + {name: "0x01 credentials compounding", input: "0x010000000000000000000000" + lower, compounding: true, expectedErr: "prefix does not match"}, + {name: "0x02 credentials not compounding", input: "0x020000000000000000000000" + lower, expectedErr: "prefix does not match"}, + {name: "0x00 credentials", input: "0x000000000000000000000000" + lower, expectedErr: "prefix does not match"}, + {name: "bad padding", input: "0x010000000000000000000001" + lower, expectedErr: "bytes 1 to 11 must be zero"}, + {name: "no 0x prefix", input: lower, expectedErr: "must be 0x-prefixed"}, + {name: "19 bytes", input: "0x" + lower[2:], expectedErr: "must be 20 bytes"}, + {name: "33 bytes", input: "0x00010000000000000000000000" + lower, expectedErr: "must be 20 bytes"}, + {name: "not hex", input: "0x" + strings.Repeat("z", 40), expectedErr: "invalid withdrawal address"}, + {name: "credentials not hex", input: "0x" + strings.Repeat("z", 64), expectedErr: "decode withdrawal credentials"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + creds, err := deposit.WithdrawalCredsFromAddrOrCreds(test.input, test.compounding) + if test.expectedErr != "" { + require.ErrorContains(t, err, test.expectedErr) + return + } + + require.NoError(t, err) + require.Equal(t, test.expected, hex.EncodeToString(creds[:])) + }) + } +} diff --git a/eth2util/helpers.go b/eth2util/helpers.go index 8f8694f486..5dc91e83d6 100644 --- a/eth2util/helpers.go +++ b/eth2util/helpers.go @@ -87,6 +87,24 @@ func ChecksumAddress(address string) (string, error) { return checksumAddressBytes(b), nil } +// ValidateChecksumAddress validates an Ethereum address and returns its EIP-55 checksummed form. +// The address must be "0x" followed by 40 hex characters. All-lowercase and all-uppercase hex +// is accepted (no checksum); mixed case must match the EIP-55 checksum exactly. +func ValidateChecksumAddress(addr string) (string, error) { + checksummed, err := ChecksumAddress(addr) + if err != nil { + return "", err + } + + hexPart := strings.TrimPrefix(addr, "0x") + if hexPart != strings.ToLower(hexPart) && hexPart != strings.ToUpper(hexPart) && addr != checksummed { + return "", errors.New("address does not match its EIP-55 checksum; use all-lowercase or the checksummed form", + z.Str("addr", addr), z.Str("checksummed", checksummed)) + } + + return checksummed, nil +} + // checksumAddressBytes returns an EIP55-compliant 0xhex representation of the binary ethereum address. func checksumAddressBytes(addressBytes []byte) string { hexAddr := hex.EncodeToString(addressBytes) diff --git a/eth2util/helpers_test.go b/eth2util/helpers_test.go index b94f34ca3b..65cc602a53 100644 --- a/eth2util/helpers_test.go +++ b/eth2util/helpers_test.go @@ -14,6 +14,38 @@ import ( "github.com/obolnetwork/charon/eth2util" ) +func TestValidateChecksumAddress(t *testing.T) { + const checksummed = "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" + + tests := []struct { + name string + addr string + expectedErr string + }{ + {name: "checksummed", addr: checksummed}, + {name: "lowercase", addr: strings.ToLower(checksummed)}, + {name: "uppercase", addr: "0x" + strings.ToUpper(checksummed[2:])}, + {name: "bad checksum", addr: "0x5AAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", expectedErr: "does not match its EIP-55 checksum"}, + {name: "uppercase 0X prefix", addr: "0X" + checksummed[2:], expectedErr: "invalid ethereum address"}, + {name: "no 0x prefix", addr: checksummed[2:], expectedErr: "invalid ethereum address"}, + {name: "too short", addr: checksummed[:40], expectedErr: "invalid ethereum address"}, + {name: "not hex", addr: "0x" + strings.Repeat("z", 40), expectedErr: "invalid ethereum hex address"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resp, err := eth2util.ValidateChecksumAddress(test.addr) + if test.expectedErr != "" { + require.ErrorContains(t, err, test.expectedErr) + return + } + + require.NoError(t, err) + require.Equal(t, checksummed, resp) + }) + } +} + func TestChecksummedAddress(t *testing.T) { // Test examples from https://eips.ethereum.org/EIPS/eip-55. addrs := []string{ diff --git a/go.mod b/go.mod index f9044b52c2..fc91d3ac45 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/obolnetwork/charon -go 1.26.0 +go 1.27 toolchain go1.27.3 @@ -10,9 +10,9 @@ require ( github.com/attestantio/go-eth2-client v0.29.0 github.com/coinbase/kryptology v1.5.6-0.20220316191335-269410e1b06b github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 - github.com/drand/kyber v1.3.2 - github.com/drand/kyber-bls12381 v0.3.4 - github.com/ethereum/go-ethereum v1.17.5 + github.com/drand/kyber v1.3.3 + github.com/drand/kyber-bls12381 v0.3.5 + github.com/ethereum/go-ethereum v1.17.8 github.com/ferranbt/fastssz v1.0.0 github.com/fsnotify/fsnotify v1.10.1 github.com/golang/snappy v1.0.1-0.20260716114414-9ae09f520e93 @@ -24,62 +24,63 @@ require ( github.com/ipfs/go-log/v2 v2.9.2 github.com/jonboulle/clockwork v0.5.0 github.com/jsternberg/zap-logfmt v1.3.0 - github.com/libp2p/go-libp2p v0.48.0 + github.com/libp2p/go-libp2p v0.50.0 github.com/libp2p/go-msgio v0.3.0 github.com/multiformats/go-multiaddr v0.16.1 - github.com/prometheus/client_golang v1.24.1 - github.com/prometheus/client_model v0.6.2 + github.com/prometheus/client_golang v1.25.0 + github.com/prometheus/client_model v0.6.3 github.com/protolambda/eth2-shuffle v1.1.0 github.com/r3labs/sse/v2 v2.10.0 github.com/rs/zerolog v1.35.1 - github.com/showwin/speedtest-go v1.8.2 + github.com/showwin/speedtest-go v1.8.3 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.12.1 github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4 v1.4.1 - go.opentelemetry.io/otel v1.46.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 - go.opentelemetry.io/otel/sdk v1.46.0 - go.opentelemetry.io/otel/trace v1.46.0 + go.opentelemetry.io/otel v1.47.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 + go.opentelemetry.io/otel/sdk v1.47.0 + go.opentelemetry.io/otel/trace v1.47.0 go.uber.org/automaxprocs v1.6.0 go.uber.org/goleak v1.3.0 go.uber.org/zap v1.28.0 - golang.org/x/crypto v0.57.0 - golang.org/x/net v0.60.0 - golang.org/x/sync v0.23.0 - golang.org/x/term v0.46.0 - golang.org/x/text v0.42.0 - golang.org/x/time v0.15.0 - golang.org/x/tools v0.49.0 + golang.org/x/crypto v0.58.0 + golang.org/x/net v0.62.0 + golang.org/x/sync v0.24.0 + golang.org/x/term v0.47.0 + golang.org/x/text v0.43.0 + golang.org/x/time v0.16.0 + golang.org/x/tools v0.52.0 google.golang.org/protobuf v1.36.12 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) require ( - buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.11-20260626152828-968bf0468096.1 // indirect - buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.11-20250109164928-1da0de137947.1 // indirect - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 // indirect - buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260713175918-10d915f5b43b.1 // indirect - buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260713175918-10d915f5b43b.1 // indirect - buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1 // indirect - buf.build/go/app v0.2.1-0.20260626143626-be153867abea // indirect - buf.build/go/bufplugin v0.10.0 // indirect + buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20261002170247-538130002972.2 // indirect + buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect + buf.build/gen/go/bufbuild/registry/connectrpc/go/v2 v2.0.0-20261009230124-4d217860dade.1 // indirect + buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20261009230124-4d217860dade.2 // indirect + buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 // indirect + buf.build/go/app v0.2.1-0.20260928213032-9a091f2dc726 // indirect + buf.build/go/bufplugin v0.11.1 // indirect buf.build/go/bufprivateusage v0.1.0 // indirect buf.build/go/interrupt v1.1.0 // indirect - buf.build/go/protovalidate v1.2.0 // indirect + buf.build/go/protovalidate v1.4.0 // indirect buf.build/go/protoyaml v0.7.0 // indirect - buf.build/go/spdx v0.2.0 // indirect + buf.build/go/spdx v0.3.0 // indirect buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect - cel.dev/expr v0.25.2 // indirect - connectrpc.com/connect v1.20.0 // indirect - connectrpc.com/otelconnect v0.9.0 // indirect + cel.dev/cel-go v0.32.0 // indirect + cel.dev/expr v0.25.3 // indirect + connectrpc.com/connect/v2 v2.0.1 // indirect + connectrpc.com/otelconnect v0.11.0 // indirect filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 // indirect - filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b // indirect + filippo.io/keygen v1.0.0 // indirect github.com/DataDog/zstd v1.5.7 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/Microsoft/go-winio v0.6.3 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect github.com/RaduBerinde/axisds v0.1.0 // indirect github.com/RaduBerinde/btreemap v0.0.0-20250419174037-3d62b7205d54 // indirect @@ -88,9 +89,9 @@ require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bits-and-blooms/bitset v1.24.4 // indirect - github.com/bufbuild/buf v1.72.0 // indirect - github.com/bufbuild/protocompile v0.14.2-0.20260716165721-bb5762d29672 // indirect + github.com/bits-and-blooms/bitset v1.26.0 // indirect + github.com/bufbuild/buf v1.74.0 // indirect + github.com/bufbuild/protocompile v0.14.2-0.20261013160345-b79bfe2f85df // indirect github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b // indirect github.com/casbin/govaluate v1.10.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -105,9 +106,9 @@ require ( github.com/cockroachdb/pebble v1.1.5 // indirect github.com/cockroachdb/pebble/v2 v2.1.4 // indirect github.com/cockroachdb/redact v1.1.6 // indirect - github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b // indirect + github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258 // indirect github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect - github.com/consensys/gnark-crypto v0.19.2 // indirect + github.com/consensys/gnark-crypto v0.22.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect @@ -117,15 +118,16 @@ require ( github.com/dchest/siphash v1.2.3 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v29.6.1+incompatible // indirect - github.com/docker/docker-credential-helpers v0.9.8 // indirect - github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/cli v29.9.0+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.10 // indirect + github.com/docker/go-connections v0.8.2 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/dunglas/httpsfv v1.1.0 // indirect + github.com/dunglas/httpsfv v1.1.1 // indirect github.com/emicklei/dot v1.8.0 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.8 // indirect github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab // indirect github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/filecoin-project/go-clock v0.1.0 // indirect github.com/fjl/jsonw v0.1.0 // indirect github.com/flynn/noise v1.1.0 // indirect github.com/gballet/go-libpcsclite v0.0.0-20191108122812-4678299bea08 // indirect @@ -135,11 +137,10 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-yaml v1.17.0 // indirect - github.com/gofrs/flock v0.13.0 // indirect + github.com/gofrs/flock v0.13.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/google/cel-go v0.29.2 // indirect - github.com/google/go-containerregistry v0.21.7 // indirect + github.com/google/go-containerregistry v0.22.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/grafana/pyroscope-go v1.2.7 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect @@ -156,30 +157,29 @@ require ( github.com/influxdata/influxdb-client-go/v2 v2.14.0 // indirect github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c // indirect github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // indirect - github.com/ipfs/go-cid v0.5.0 // indirect + github.com/ipfs/go-cid v0.6.2 // indirect github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect github.com/jdx/go-netrc v1.0.0 // indirect github.com/jinzhu/copier v0.4.0 // indirect github.com/kilic/bls12-381 v0.1.0 // indirect - github.com/klauspost/compress v1.19.1 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/klauspost/pgzip v1.2.6 // indirect - github.com/koron/go-ssdp v0.0.6 // indirect + github.com/klauspost/compress v1.20.3 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/klauspost/pgzip v1.2.7 // indirect + github.com/koron/go-ssdp v0.9.1 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect - github.com/libp2p/go-flow-metrics v0.2.0 // indirect + github.com/libp2p/go-flow-metrics v0.3.0 // indirect github.com/libp2p/go-libp2p-asn-util v0.4.1 // indirect github.com/libp2p/go-netroute v0.4.0 // indirect github.com/libp2p/go-reuseport v0.4.0 // indirect - github.com/libp2p/go-yamux/v5 v5.0.1 // indirect + github.com/libp2p/go-yamux/v5 v5.1.0 // indirect github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect - github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-colorable v0.1.16 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.28 // indirect - github.com/miekg/dns v1.1.66 // indirect github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect github.com/minio/minlz v1.0.1-0.20250507153514-87eb42fe8882 // indirect @@ -188,18 +188,18 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/pointerstructure v1.2.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/moby/api v1.55.0 // indirect - github.com/moby/moby/client v0.5.0 // indirect - github.com/mr-tron/base58 v1.2.0 // indirect + github.com/moby/moby/api v1.56.1 // indirect + github.com/moby/moby/client v0.6.2 // indirect + github.com/mr-tron/base58 v1.3.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect - github.com/multiformats/go-multiaddr-dns v0.4.1 // indirect + github.com/multiformats/go-multiaddr-dns v0.6.0 // indirect github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect - github.com/multiformats/go-multibase v0.2.0 // indirect - github.com/multiformats/go-multicodec v0.9.1 // indirect + github.com/multiformats/go-multibase v0.3.0 // indirect + github.com/multiformats/go-multicodec v0.10.0 // indirect github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-multistream v0.6.1 // indirect - github.com/multiformats/go-varint v0.0.7 // indirect + github.com/multiformats/go-varint v0.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oapi-codegen/runtime v1.6.0 // indirect github.com/onsi/gomega v1.36.2 // indirect @@ -208,9 +208,9 @@ require ( github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/peterh/liner v1.2.2 // indirect - github.com/petermattis/goid v0.0.0-20260716134002-a9b348f0a2b9 // indirect + github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b // indirect github.com/pion/datachannel v1.5.10 // indirect - github.com/pion/dtls/v3 v3.1.4 // indirect + github.com/pion/dtls/v3 v3.1.5 // indirect github.com/pion/ice/v4 v4.0.10 // indirect github.com/pion/interceptor v0.1.40 // indirect github.com/pion/logging v0.2.4 // indirect @@ -221,19 +221,19 @@ require ( github.com/pion/sctp v1.8.39 // indirect github.com/pion/sdp/v3 v3.0.18 // indirect github.com/pion/srtp/v3 v3.0.6 // indirect - github.com/pion/stun/v3 v3.1.5 // indirect + github.com/pion/stun/v3 v3.1.7 // indirect github.com/pion/transport/v3 v3.0.7 // indirect - github.com/pion/transport/v4 v4.0.2 // indirect + github.com/pion/transport/v4 v4.1.0 // indirect github.com/pion/turn/v4 v4.0.2 // indirect github.com/pion/webrtc/v4 v4.1.2 // indirect github.com/pk910/dynamic-ssz v1.3.2 // indirect github.com/pk910/hashtree-bindings v0.2.2 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/common v0.70.1 // indirect - github.com/prometheus/procfs v0.21.1 // indirect + github.com/prometheus/common v0.72.0 // indirect + github.com/prometheus/procfs v0.22.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.60.0 // indirect - github.com/quic-go/webtransport-go v0.11.1 // indirect + github.com/quic-go/quic-go v0.63.0 // indirect + github.com/quic-go/webtransport-go v0.13.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/cors v1.11.1 // indirect @@ -242,7 +242,7 @@ require ( github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect - github.com/sirupsen/logrus v1.9.4 // indirect + github.com/sirupsen/logrus v1.10.2 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -251,11 +251,11 @@ require ( github.com/supranational/blst v0.3.16 // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect github.com/tetratelabs/wazero v1.12.0 // indirect - github.com/tidwall/btree v1.8.1 // indirect + github.com/tidwall/btree v1.8.2 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/urfave/cli/v2 v2.27.6 // indirect - github.com/vektra/mockery/v2 v2.53.6 // indirect + github.com/vektra/mockery/v2 v2.53.8 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect @@ -264,28 +264,29 @@ require ( go.lsp.dev/protocol v0.12.0 // indirect go.lsp.dev/uri v0.3.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect - go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/dig v1.19.0 // indirect go.uber.org/fx v1.24.0 // indirect - go.uber.org/mock v0.5.2 // indirect + go.uber.org/mock v0.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect - golang.org/x/mod v0.41.0 // indirect - golang.org/x/sys v0.48.0 // indirect - golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect + golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49 // indirect + golang.org/x/mod v0.42.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/telemetry v0.0.0-20261009151841-97e8ea77aa2a // indirect golang.org/x/vuln v1.1.4 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect + google.golang.org/grpc v1.83.2 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect mvdan.cc/xurls/v2 v2.6.0 // indirect - pluginrpc.com/pluginrpc v0.5.0 // indirect + pluginrpc.com/pluginrpc v0.6.0 // indirect ) // We're replacing kryptology with our own fork, which fixes dependencies' security vulnerabilities. diff --git a/go.sum b/go.sum index 0c59df6151..3769e6b5b7 100644 --- a/go.sum +++ b/go.sum @@ -1,47 +1,49 @@ -buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.11-20260626152828-968bf0468096.1 h1:bHi5/cwz7IPRhFcrRA4iytTjDI3WwFkv1m5K7y+YOTc= -buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.11-20260626152828-968bf0468096.1/go.mod h1:1Znr6gmYBhbxWUPRrrVnSLXQsz8bvFVw1HHJq2bI3VQ= -buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.11-20250109164928-1da0de137947.1 h1:HwzzCRS4ZrEm1++rzSDxHnO0DOjiT1b8I/24e8a4exY= -buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.11-20250109164928-1da0de137947.1/go.mod h1:8PRKXhgNes29Tjrnv8KdZzg3I1QceOkzibW1QK7EXv0= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 h1:fXh8CsdNpjRr8R5vFdqtIxPt/Lno2IIJlYOdZBIZn0w= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= -buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260713175918-10d915f5b43b.1 h1:c4w6fQ6wAhZLgVI+HZozlWX9I2aVPN34vrl/8cuovVc= -buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260713175918-10d915f5b43b.1/go.mod h1:mbcuFKpnCQsF8H2fBs1lN1dQVAiASPQKwFgTkXqYscI= -buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260713175918-10d915f5b43b.1 h1:qJUlFKLFdb3OFCpmA+r5AQbysgBD7WLRapE9lOs08r8= -buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260713175918-10d915f5b43b.1/go.mod h1:1JJi9jvOqRxSMa+JxiZSm57doB+db/1WYCIa2lHfc40= -buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1 h1:iGPvEJltOXUMANWf0zajcRcbiOXLD90ZwPUFvbcuv6Q= -buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1/go.mod h1:nWVKKRA29zdt4uvkjka3i/y4mkrswyWwiu0TbdX0zts= -buf.build/go/app v0.2.1-0.20260626143626-be153867abea h1:krHaIyUJSTnurb0fbYQtsXkZWiWQ/ydOgfm61TPSODc= -buf.build/go/app v0.2.1-0.20260626143626-be153867abea/go.mod h1:V32mBaPWsfq6REAeZvvs/rQl7ZCl9Dn7eW1BBrmH0GQ= -buf.build/go/bufplugin v0.10.0 h1:vZBX0mq9as5UIBug8U+/DkGRaHNlM/HVOw59O8fvOIU= -buf.build/go/bufplugin v0.10.0/go.mod h1:ax7obVurKDH1I2nR4pFTS+TE6K3kZhTmwDCN2YgdV8I= +buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20261002170247-538130002972.2 h1:nNNsSGs3l9F8q4NfHaIa8RdGfucw957dlFuNf9Z3H/w= +buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.12-20261002170247-538130002972.2/go.mod h1:Owcuj2phVyvThVYCD74iSV8ki0dAXw/uxkdcdZScdCc= +buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2 h1:tE4A2RVukQOSd4K+6ndK+8s65FbjiBt2k5CiddXxCCo= +buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.12-20250109164928-1da0de137947.2/go.mod h1:Gjxz0o7qPHokNsmlX8D+/aKd29NRLRr9CCUkPeMAzJ0= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +buf.build/gen/go/bufbuild/registry/connectrpc/go/v2 v2.0.0-20261009230124-4d217860dade.1 h1:9utgoyULVklOqVzOG+mnd+ZQcOiWPKVP5KmKPXZaVYw= +buf.build/gen/go/bufbuild/registry/connectrpc/go/v2 v2.0.0-20261009230124-4d217860dade.1/go.mod h1:U3WcHUGfS/L67G7R0PmcaHA7MfviJJqDqhHXiCWeOEY= +buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20261009230124-4d217860dade.2 h1:Bqu3+Hwxoh8gvRvGMtkz/Kbq4U+cOvyk37jLzE4PkC0= +buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.12-20261009230124-4d217860dade.2/go.mod h1:v7IaNcBKy5G3G8gpG2m92P0R3HK2qtMGclWzf+1Aut4= +buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2 h1:9xw8g8Q6zHLC7CO/jtpl2UK0kQticxIG+mI6N86f2/M= +buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.12-20241007202033-cf42259fcbfc.2/go.mod h1:+0bw+k8FY5MU3tojFh33HeA5xMfGWjAUQpr+maW5Z3o= +buf.build/go/app v0.2.1-0.20260928213032-9a091f2dc726 h1:Xx8K9w2tN9TfQSq1g6g4vKuBbG3ZlOR2bb9A+PbgBt4= +buf.build/go/app v0.2.1-0.20260928213032-9a091f2dc726/go.mod h1:KuUkGoXxZnxNVGN/AB7s4UCUG2/1xtk8NjxqdSCSSyM= +buf.build/go/bufplugin v0.11.1 h1:fegT6xiiGVwyNiLlxBz07ochKTX/DAtIKe/oPdrqrCs= +buf.build/go/bufplugin v0.11.1/go.mod h1:0xoAhb/CPqXwS/FUup9vwIRBYxDcXYXeOhYXHca5uuc= buf.build/go/bufprivateusage v0.1.0 h1:SzCoCcmzS3zyXHEXHeSQhGI7OTkgtljoknLzsUz9Gg4= buf.build/go/bufprivateusage v0.1.0/go.mod h1:GlCCJ3VVF7EqqU0CoRmo1FzAwwaKymEWSr+ty69xU5w= buf.build/go/interrupt v1.1.0 h1:olBuhgv9Sav4/9pkSLoxgiOsZDgM5VhRhvRpn3DL0lE= buf.build/go/interrupt v1.1.0/go.mod h1:ql56nXPG1oHlvZa6efNC7SKAQ/tUjS6z0mhJl0gyeRM= -buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0= -buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4= +buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM= +buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs= buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0= -buf.build/go/spdx v0.2.0 h1:IItqM0/cMxvFJJumcBuP8NrsIzMs/UYjp/6WSpq8LTw= -buf.build/go/spdx v0.2.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8= +buf.build/go/spdx v0.3.0 h1:vVxy1W0x1n3sqhZmJ7VZ/UtzNwK3xm0QowGGtwric4Y= +buf.build/go/spdx v0.3.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8= buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 h1:njYKSWoLiq2i5O7y2bPPU2Yzp7iAU0Wk9KJ2OoAhNiU= buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5/go.mod h1:DQmodNT9EHX94WzUaWiZK+/4EaFa/xZTc1gzfCxZVXU= -cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= -cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= -connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= -connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= -connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= -connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA= -connectrpc.com/otelconnect v0.9.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc= +cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= +cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= +cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= +cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +connectrpc.com/connect/v2 v2.0.1 h1:WTAycxe87UqxSM5oxS30d1dalZ+C5xz8Ec8O6zVYkHQ= +connectrpc.com/connect/v2 v2.0.1/go.mod h1:N32OLrstcPUzkykFynnefFro3+xzSdOrZlK2jHmS/xo= +connectrpc.com/grpcreflect/v2 v2.0.0 h1:Ah7U5kSd/kslH5KFNS8jh/e3u6UYVoX7lwkjcIfSq6A= +connectrpc.com/grpcreflect/v2 v2.0.0/go.mod h1:V7Hj+ibxuZH3667Ng2zKKvxJQJIri20iG50vzG7i8GM= +connectrpc.com/otelconnect v0.11.0 h1:n1HjvoZLzsGJ5nb2PGnFH2msXPQLf4j3EWzLDylhdWQ= +connectrpc.com/otelconnect v0.11.0/go.mod h1:xE0w5pyAk519Nub9zP63x9oOIBwcKofv5IYKQAhzQF4= filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5 h1:JA0fFr+kxpqTdxR9LOBiTWpGNchqmkcsgmdeJZRclZ0= filippo.io/bigmod v0.1.1-0.20260103110540-f8a47775ebe5/go.mod h1:OjOXDNlClLblvXdwgFFOQFJEocLhhtai8vGLy0JCZlI= -filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b h1:REI1FbdW71yO56Are4XAxD+OS/e+BQsB3gE4mZRQEXY= -filippo.io/keygen v0.0.0-20260114151900-8e2790ea4c5b/go.mod h1:9nnw1SlYHYuPSo/3wjQzNjSbeHlq2NsKo5iEtfJPWP0= +filippo.io/keygen v1.0.0 h1:u0/Fhxlgz3uPv+XxhfgTq3BJt5VesIPM5ue/OuG7qjQ= +filippo.io/keygen v1.0.0/go.mod h1:9nnw1SlYHYuPSo/3wjQzNjSbeHlq2NsKo5iEtfJPWP0= github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Microsoft/go-winio v0.6.3 h1:M/52tsoyTfwaxajLnXZm7PqkbMGRH8iSWP+8fcg9MQ4= +github.com/Microsoft/go-winio v0.6.3/go.mod h1:ma0QpxizD4fyyzJ1C7MIblymTD2Nxz5Mho/3CepwIZg= github.com/ObolNetwork/go-eth2-client v0.28.1-obol h1:Xith6vfi0R7m/zl+vy0EWJbtFwUMTD0dmGnABi9rzkY= github.com/ObolNetwork/go-eth2-client v0.28.1-obol/go.mod h1:1MBvz1DVe6jZduHJDJuJ/1S4LzYmDmvx+r5alo4pyrM= github.com/ObolNetwork/kryptology v0.1.0 h1:AhoG4My70+xMhEJSpVaJay/t+T/vIUNHQYLjsDJHulI= @@ -71,18 +73,18 @@ github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= -github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.26.0 h1:WPvcPZbP1yuKofmjs2SYIFO1EjzIGhCP9ducElLsh5Q= +github.com/bits-and-blooms/bitset v1.26.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bmatcuk/doublestar v1.1.1 h1:YroD6BJCZBYx06yYFEWvUuKVWQn3vLLQAVmDmvTSaiQ= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= -github.com/bufbuild/buf v1.72.0 h1:VMmGFtCLrxyS2wkpghExmhhiqJDdmc8DcwAvsGJGJ94= -github.com/bufbuild/buf v1.72.0/go.mod h1:bhtIlPDo3q/PDw4yaTdx2+jxkc33Aq+ygFIi6Diz2yU= -github.com/bufbuild/protocompile v0.14.2-0.20260716165721-bb5762d29672 h1:6xykiXQPoF/hhOjAhwiHQ0kgcAvrDZw0Tjl1VQkpu5c= -github.com/bufbuild/protocompile v0.14.2-0.20260716165721-bb5762d29672/go.mod h1:jPUiZUFWc8E3Kc2Y4SRlGAdjde4amGkHY0BUACNS43E= +github.com/bufbuild/buf v1.74.0 h1:zk0IvEFZpUBWKUh9Hyt6vKtIu7uoSjgU8le9sysJYhI= +github.com/bufbuild/buf v1.74.0/go.mod h1:ovhzeVGqUqy4vuumyWvsUbAq0bqdJHptYKRktjiIqJA= +github.com/bufbuild/protocompile v0.14.2-0.20261013160345-b79bfe2f85df h1:DRUi+xCmK/geLBx7WYsdWRVsg7OmyZPytgEA9koxI0c= +github.com/bufbuild/protocompile v0.14.2-0.20261013160345-b79bfe2f85df/go.mod h1:bX3ObJfML+aki7PJevkWLSlOfEZ8EnNsfCz0hiYkhiI= github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b h1:b7wvo9ZhjLzCp7tGbOUMvgtYTnd33zGSAmMxcdxMnhQ= github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ= github.com/canonical/go-sp800.90a-drbg v0.0.0-20210314144037-6eeb1040d6c3 h1:oe6fCvaEpkhyW3qAicT0TnGtyht/UrgvOwMcEgLb7Aw= @@ -119,12 +121,12 @@ github.com/cockroachdb/pebble/v2 v2.1.4 h1:j9wPgMDbkErFdAKYFGhsoCcvzcjR+6zrJ4jhK github.com/cockroachdb/pebble/v2 v2.1.4/go.mod h1:Reo1RTniv1UjVTAu/Fv74y5i3kJ5gmVrPhO9UtFiKn8= github.com/cockroachdb/redact v1.1.6 h1:zXJBwDZ84xJNlHl1rMyCojqyIxv+7YUpQiJLQ7n4314= github.com/cockroachdb/redact v1.1.6/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= -github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b h1:VXvSNzmr8hMj8XTuY0PT9Ane9qZGul/p67vGYwl9BFI= -github.com/cockroachdb/swiss v0.0.0-20251224182025-b0f6560f979b/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg= +github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258 h1:IJ+uNItEm0qx9FE2AgIc1PMsCUtk8nbSIzhQE1t5GWw= +github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= -github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80= -github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0= +github.com/consensys/gnark-crypto v0.22.0 h1:RYH7FX1/zkmNrSiABo70HAqew41SRUsoluKundObo3o= +github.com/consensys/gnark-crypto v0.22.0/go.mod h1:HL8XflRG4cvau1PEG3CWBTD2kkzUzdlqZF6BBgy57F8= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -151,32 +153,34 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3h github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/cli v29.6.1+incompatible h1:oO7F4nn3Ovr/5TlfTUWFbMwBSS/B7Xs6Epv26gBrUP8= -github.com/docker/cli v29.6.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q= -github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= -github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= -github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/cli v29.9.0+incompatible h1:Cw9x50ioSGgGwXJ4eO+hIBry/w3LLbyF8GPAEKz1fnE= +github.com/docker/cli v29.9.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.10 h1:LCWwv7UmS7fQVmdKDWeMuureOeY5kkbC5kZfkzxkS6k= +github.com/docker/docker-credential-helpers v0.9.10/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc= +github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/drand/kyber v1.3.2 h1:Cf3NNcb5bV3eODopr3XVHzImjDK40GiObhFUFG93Zeo= -github.com/drand/kyber v1.3.2/go.mod h1:ciDFWoC7ajb89niGJnS4C1Xeo4lSJMmbi+km5w8juAI= -github.com/drand/kyber-bls12381 v0.3.4 h1:rrmYcRcXmtOAvKWVBxRQxi22qNMVcS2Jz7MAebZQJxI= -github.com/drand/kyber-bls12381 v0.3.4/go.mod h1:jh3IGIAQfdLrdNKYz1HWZ3YdfJM0DWlN1TxXkh60utk= -github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= -github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= +github.com/drand/kyber v1.3.3 h1:iUIg5JWlAr8Qdqkl36J+PbHK0BMdQlLm+P/wV34EDX8= +github.com/drand/kyber v1.3.3/go.mod h1:ptX3XpLo5LH4tX2y297cXSwFOIuZii5O7MkDgG0I7mA= +github.com/drand/kyber-bls12381 v0.3.5 h1:k6il25UDe3jhC0vEbrAV5y6SIZNLmleMmcnPFSPBIQ4= +github.com/drand/kyber-bls12381 v0.3.5/go.mod h1:fU16hOyVzjOob1G1A8TRRVNCSd2Z0VNv43XlXo2gtbU= +github.com/dunglas/httpsfv v1.1.1 h1:HoSs101zIE9I23DlqlmljJ/OIi7ILwrH347pXhRZdxI= +github.com/dunglas/httpsfv v1.1.1/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= github.com/emicklei/dot v1.8.0 h1:HnD60yAKFAevNeT+TPYr9pb8VB9bqdeSo0nzwIW6IOI= github.com/emicklei/dot v1.8.0/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/ethereum/c-kzg-4844/v2 v2.1.8 h1:oQ48q/TMe2SKU8qBE3N7e4/HlG3EpJftom6EsPQgJ58= github.com/ethereum/c-kzg-4844/v2 v2.1.8/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= -github.com/ethereum/go-ethereum v1.17.5 h1:o9BIXs2Q/3cPHVxw49n+Zjn2i6rB9TOXatev46duOC4= -github.com/ethereum/go-ethereum v1.17.5/go.mod h1:vz2YvG7RewA4sFHTgzLyW+WmFG1N4jfk/hgXQVhhn9c= +github.com/ethereum/go-ethereum v1.17.8 h1:iKuXrT38t1PaU6aBw6MjBJhGoCl/+Ir0vf5aqQhYT38= +github.com/ethereum/go-ethereum v1.17.8/go.mod h1:Iui379BD7ryQSFeIDn3sh9Od2I1uheqJD2/6N0m4xm0= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/ferranbt/fastssz v1.0.0 h1:9EXXYsracSqQRBQiHeaVsG/KQeYblPf40hsQPb9Dzk8= github.com/ferranbt/fastssz v1.0.0/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/filecoin-project/go-clock v0.1.0 h1:SFbYIM75M8NnFm1yMHhN9Ahy3W5bEZV9gd6MPfXbKVU= +github.com/filecoin-project/go-clock v0.1.0/go.mod h1:4uB/O4PvOjlx1VCMdZ9MyDZXRm//gkj1ELEbxfI1AZs= github.com/fjl/jsonw v0.1.0 h1:V3MyR79fjLpn/+bMgvegdGUIhoJOzjmqWcKDgcOmY1I= github.com/fjl/jsonw v0.1.0/go.mod h1:2KMLevM6FXEJnfhtk7naXu9vZdVfOma1GlnGdPRlumU= github.com/flynn/noise v1.1.0 h1:KjPQoQCEFdZDiP03phOvGi11+SVVhBG2wOWAorLsstg= @@ -208,8 +212,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.17.0 h1:JJhayi67p5LeTLh9UJYFhayPIOGDZjAqQNoEzHhYvik= github.com/goccy/go-yaml v1.17.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= -github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= +github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= @@ -226,8 +230,6 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.1-0.20260716114414-9ae09f520e93 h1:GpQQr4L8jsBtJSURCDqQboOdgpVMU6vR9REjc8nR4Qc= github.com/golang/snappy v1.0.1-0.20260716114414-9ae09f520e93/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/cel-go v0.29.2 h1:ZtDxkeiMmz0mxbKDYiNkE5Lk7V5edMRcaaDf2jX002k= -github.com/google/cel-go v0.29.2/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -236,8 +238,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= -github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= +github.com/google/go-containerregistry v0.22.1 h1:RZuuSYhTvlDvtsK+NkutoCZ//C0X2ebLK8X8l3ULs84= +github.com/google/go-containerregistry v0.22.1/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= @@ -287,8 +289,8 @@ github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSH github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf h1:7JTmneyiNEwVBOHSjoMxiWAqB992atOeepeFYegn5RU= github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= -github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= -github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= +github.com/ipfs/go-cid v0.6.2 h1:VuGwJd+KJTaMJ4S4d5EEf9SXc17YUblS5axCbocn9YE= +github.com/ipfs/go-cid v0.6.2/go.mod h1:Xhwg8NzHeK9xPCEZkCw4idzPiuNMpX3fARuI5Iwj1Lo= github.com/ipfs/go-log/v2 v2.9.2 h1:O/5BB0elpkRILvT24rCJ5976wWd7u0nJ436T3rdYdc4= github.com/ipfs/go-log/v2 v2.9.2/go.mod h1:RziRwwXWhndlk8L75RnEe0zeAYaq2heKtEMc3jqUov0= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= @@ -297,8 +299,8 @@ github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABo github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk= github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ= github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8= -github.com/jhump/protoreflect/v2 v2.0.0-beta.2 h1:qZU+rEZUOYTz1Bnhi3xbwn+VxdXkLVeEpAeZzVXLY88= -github.com/jhump/protoreflect/v2 v2.0.0-beta.2/go.mod h1:4tnOYkB/mq7QTyS3YKtVtNrJv4Psqout8HA1U+hZtgM= +github.com/jhump/protoreflect/v2 v2.0.0 h1:dBg0utH2witLqXfczkVVrHFrFQc8SN1rvaKkOR5ALDg= +github.com/jhump/protoreflect/v2 v2.0.0/go.mod h1:Sz8TB0WtuTKOF4eYj2iZovXSSU3tqZ5Ii8Oeg9k9vKY= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= @@ -310,14 +312,14 @@ github.com/kilic/bls12-381 v0.1.0 h1:encrdjqKMEvabVQ7qYOKu1OvhqpK4s47wDYtNiPtlp4 github.com/kilic/bls12-381 v0.1.0/go.mod h1:vDTTHJONJ6G+P2R74EhnyotQDTliQDnFEwhdmfzw1ig= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= -github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= -github.com/koron/go-ssdp v0.0.6 h1:Jb0h04599eq/CY7rB5YEqPS83HmRfHP2azkxMN2rFtU= -github.com/koron/go-ssdp v0.0.6/go.mod h1:0R9LfRJGek1zWTjN3JUNlm5INCDYGpRDfAptnct63fI= +github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4= +github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/klauspost/pgzip v1.2.7 h1:02QB3Ttao6zOWDnSsv3bIvjN24bX0eGjWniQ8vuBfkA= +github.com/klauspost/pgzip v1.2.7/go.mod h1:g7E6NrOKHOzah4QwK6Ue1tNCJs8IDiNOfjiXTr85U2E= +github.com/koron/go-ssdp v0.9.1 h1:zvxbAAuJftJIZ8Jh8mda+LI7V92hYZf/sKprmOxpxwA= +github.com/koron/go-ssdp v0.9.1/go.mod h1:C43c047jWkDaeg9YuZlSh/QGqOieuWV6dbhWi/jcaLk= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -331,10 +333,10 @@ github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzW github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= -github.com/libp2p/go-flow-metrics v0.2.0 h1:EIZzjmeOE6c8Dav0sNv35vhZxATIXWZg6j/C08XmmDw= -github.com/libp2p/go-flow-metrics v0.2.0/go.mod h1:st3qqfu8+pMfh+9Mzqb2GTiwrAGjIPszEjZmtksN8Jc= -github.com/libp2p/go-libp2p v0.48.0 h1:h2BrLAgrj7X8bEN05K7qmrjpNHYA+6tnsGRdprjTnvo= -github.com/libp2p/go-libp2p v0.48.0/go.mod h1:Q1fBZNdmC2Hf82husCTfkKJVfHm2we5zk+NWmOGEmWk= +github.com/libp2p/go-flow-metrics v0.3.0 h1:q31zcHUvHnwDO0SHaukewPYgwOBSxtt830uJtUx6784= +github.com/libp2p/go-flow-metrics v0.3.0/go.mod h1:nuhlreIwEguM1IvHAew3ij7A8BMlyHQJ279ao24eZZo= +github.com/libp2p/go-libp2p v0.50.0 h1:A0tBP6mr6GV7l5ip2Q04a/IxPqVYYaCiif5s8JuXkN4= +github.com/libp2p/go-libp2p v0.50.0/go.mod h1:RjqB+dxCWNZ33yw0yeK5Pu157oNxKuaTZY6vLed4t8w= github.com/libp2p/go-libp2p-asn-util v0.4.1 h1:xqL7++IKD9TBFMgnLPZR6/6iYhawHKHl950SO9L6n94= github.com/libp2p/go-libp2p-asn-util v0.4.1/go.mod h1:d/NI6XZ9qxw67b4e+NgpQexCIiFYJjErASrYW4PFDN8= github.com/libp2p/go-libp2p-testing v0.12.0 h1:EPvBb4kKMWO29qP4mZGyhVzUyR25dvfUIK5WDu6iPUA= @@ -345,21 +347,19 @@ github.com/libp2p/go-netroute v0.4.0 h1:sZZx9hyANYUx9PZyqcgE/E1GUG3iEtTZHUEvdtXT github.com/libp2p/go-netroute v0.4.0/go.mod h1:Nkd5ShYgSMS5MUKy/MU2T57xFoOKvvLR92Lic48LEyA= github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s= github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU= -github.com/libp2p/go-yamux/v5 v5.0.1 h1:f0WoX/bEF2E8SbE4c/k1Mo+/9z0O4oC/hWEA+nfYRSg= -github.com/libp2p/go-yamux/v5 v5.0.1/go.mod h1:en+3cdX51U0ZslwRdRLrvQsdayFt3TSUKvBGErzpWbU= -github.com/marcopolo/simnet v0.0.4 h1:50Kx4hS9kFGSRIbrt9xUS3NJX33EyPqHVmpXvaKLqrY= -github.com/marcopolo/simnet v0.0.4/go.mod h1:tfQF1u2DmaB6WHODMtQaLtClEf3a296CKQLq5gAsIS0= +github.com/libp2p/go-yamux/v5 v5.1.0 h1:8Qlxj4E9JGJAQVW6+uj2o7mqkqsIVlSUGmTWhlXzoHE= +github.com/libp2p/go-yamux/v5 v5.1.0/go.mod h1:tgIQ07ObtRR/I0IWsFOyQIL9/dR5UXgc2s8xKmNZv1o= +github.com/marcopolo/simnet v0.0.7 h1:DpH8BMGsF9+1w13L8rvCaAhb6nYJdY+dIXncDrssvUs= +github.com/marcopolo/simnet v0.0.7/go.mod h1:tfQF1u2DmaB6WHODMtQaLtClEf3a296CKQLq5gAsIS0= github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk= github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU= -github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= -github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiAt84A= +github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU= github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= -github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= -github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c h1:bzE/A84HN25pxAuk9Eej1Kz9OUelF97nAc82bDquQI8= github.com/mikioh/tcp v0.0.0-20190314235350-803a9b46060c/go.mod h1:0SQS9kMwD2VsyFEB++InYyBJroV/FRmBgcydeSUcJms= github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b h1:z78hV3sbSMAUoyUMM0I83AUIT6Hu17AWfgjzIbtrYFc= @@ -381,13 +381,13 @@ github.com/mitchellh/pointerstructure v1.2.1 h1:ZhBBeX8tSlRpu/FFhXH4RC4OJzFlqsQh github.com/mitchellh/pointerstructure v1.2.1/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= -github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= -github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/moby/api v1.56.1 h1:PpWkvVPB7Fr/No8w+TfyJ/I6rWZ2YPZhT3JorIg+06c= +github.com/moby/moby/api v1.56.1/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= +github.com/moby/moby/client v0.6.2 h1:ekSPlMccrZGcw6Scb8T9D39qE0mB+XupU/yS0zNw9N4= +github.com/moby/moby/client v0.6.2/go.mod h1:DGcnO7qmPJrqobx/ggQgQ/AUk+2wgVdQv7uxB0/mEsc= github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= -github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= -github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI= +github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= @@ -395,23 +395,27 @@ github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a github.com/multiformats/go-multiaddr v0.1.1/go.mod h1:aMKBKNEYmzmDmxfX88/vz+J5IU55txyt0p4aiWVohjo= github.com/multiformats/go-multiaddr v0.16.1 h1:fgJ0Pitow+wWXzN9do+1b8Pyjmo8m5WhGfzpL82MpCw= github.com/multiformats/go-multiaddr v0.16.1/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= -github.com/multiformats/go-multiaddr-dns v0.4.1 h1:whi/uCLbDS3mSEUMb1MsoT4uzUeZB0N32yzufqS0i5M= -github.com/multiformats/go-multiaddr-dns v0.4.1/go.mod h1:7hfthtB4E4pQwirrz+J0CcDUfbWzTqEzVyYKKIKpgkc= +github.com/multiformats/go-multiaddr-dns v0.6.0 h1:yKIW08WJHSPJ8bDAT2O/5fypCaUu9Bjl8r/1eJ4XAW8= +github.com/multiformats/go-multiaddr-dns v0.6.0/go.mod h1:dwIQwdORZfnNQCeS7xLXyn+7626oRmMsVP30Uronhf0= github.com/multiformats/go-multiaddr-fmt v0.1.0 h1:WLEFClPycPkp4fnIzoFoV9FVd49/eQsuaL3/CWe167E= github.com/multiformats/go-multiaddr-fmt v0.1.0/go.mod h1:hGtDIW4PU4BqJ50gW2quDuPVjyWNZxToGUh/HwTZYJo= -github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= -github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= -github.com/multiformats/go-multicodec v0.9.1 h1:x/Fuxr7ZuR4jJV4Os5g444F7xC4XmyUaT/FWtE+9Zjo= -github.com/multiformats/go-multicodec v0.9.1/go.mod h1:LLWNMtyV5ithSBUo3vFIMaeDy+h3EbkMTek1m+Fybbo= +github.com/multiformats/go-multibase v0.3.0 h1:8helZD2+4Db7NNWFiktk2NePbF0boolBe6bDQvM4r68= +github.com/multiformats/go-multibase v0.3.0/go.mod h1:MoBLQPCkRTOL3eveIPO81860j2AQY8JwcnNlRkGRUfI= +github.com/multiformats/go-multicodec v0.10.0 h1:UpP223cig/Cx8J76jWt91njpK3GTAO1w02sdcjZDSuc= +github.com/multiformats/go-multicodec v0.10.0/go.mod h1:wg88pM+s2kZJEQfRCKBNU+g32F5aWBEjyFHXvZLTcLI= github.com/multiformats/go-multihash v0.0.8/go.mod h1:YSLudS+Pi8NHE7o6tb3D8vrpKa63epEDmG8nTduyAew= github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= github.com/multiformats/go-multistream v0.6.1 h1:4aoX5v6T+yWmc2raBHsTvzmFhOI8WVOer28DeBBEYdQ= github.com/multiformats/go-multistream v0.6.1/go.mod h1:ksQf6kqHAb6zIsyw7Zm+gAuVo57Qbq84E27YlYqavqw= -github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= -github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= +github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= +github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/naoina/go-stringutil v0.1.0 h1:rCUeRUHjBjGTSHl0VC00jUPLz8/F9dDzYI70Hzifhks= +github.com/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0= +github.com/naoina/toml v0.1.2-0.20170918210437-9fafd6967416 h1:shk/vn9oCoOTmwcouEdwIeOtOGA/ELRUw/GwvxwfT+0= +github.com/naoina/toml v0.1.2-0.20170918210437-9fafd6967416/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E= github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= @@ -438,14 +442,14 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= -github.com/petermattis/goid v0.0.0-20260716134002-a9b348f0a2b9 h1:UyKlK0Ke63afxhHrgJAk8KlCt+kP9KYBRUsWG6lK2WM= -github.com/petermattis/goid v0.0.0-20260716134002-a9b348f0a2b9/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b h1:OzNsuVdSWGwXvWKTtChx9ve89k4cFJ6NxYM4Aw4/f+E= +github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= -github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= -github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= +github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= +github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= @@ -466,12 +470,12 @@ github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4= github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY= -github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8= -github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= +github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E= +github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= -github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= -github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ= +github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps= github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs= github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54= @@ -488,14 +492,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= -github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= -github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= -github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= -github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0= +github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4= +github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9 h1:arwj11zP0yJIxIRiDn22E0H8PxfF7TsTrc2wIPFIsf4= github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9/go.mod h1:SKZx6stCn03JN3BOWTwvVIO2ajMkb/zQdTceXYhKw/4= github.com/protolambda/eth2-shuffle v1.1.0 h1:gixIBI84IeugTwwHXm8vej1bSSEhueBCSryA4lAKRLU= @@ -506,10 +510,10 @@ github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4 github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= -github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= -github.com/quic-go/webtransport-go v0.11.1 h1:rrFQMO+7/52ZDJ04fsrjIaWqn6q1z1MYo9iVFq6JtbA= -github.com/quic-go/webtransport-go v0.11.1/go.mod h1:SHgEzUFVyj+9WUSuGB1P6Zd351Pww2leWV3SwlTovkA= +github.com/quic-go/quic-go v0.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78A= +github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= +github.com/quic-go/webtransport-go v0.13.0 h1:RJLrTUHlTj8jJaQlQJUy0z0Mf7u1fVM0I6L1b9pe2M0= +github.com/quic-go/webtransport-go v0.13.0/go.mod h1:K83X9YHbAqgSLO6ikS6BXCMdWOvqh9JTHALulvb2JVk= github.com/r3labs/sse/v2 v2.10.0 h1:hFEkLLFY4LDifoHdiCN/LlGBAdVJYsANaLqNYa1l/v0= github.com/r3labs/sse/v2 v2.10.0/go.mod h1:Igau6Whc+F17QUgML1fYe1VPZzTV6EMCnYktEmkNJ7I= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -533,10 +537,10 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/showwin/speedtest-go v1.8.2 h1:vvNQBnk2BFqv96fmjEI+q0bNZ1WIaBkYXYrF6bDsm9A= -github.com/showwin/speedtest-go v1.8.2/go.mod h1:N4bn2lgNNWmfJyC1AIHD4T5wYhU+9D21OfacVhJsxiU= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/showwin/speedtest-go v1.8.3 h1:x988hyZFsa5V1EEwox84niAji632uxBTk8Clsc1itF8= +github.com/showwin/speedtest-go v1.8.3/go.mod h1:N4bn2lgNNWmfJyC1AIHD4T5wYhU+9D21OfacVhJsxiU= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -568,16 +572,16 @@ github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70 github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= -github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= -github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= +github.com/tidwall/btree v1.8.2 h1:mTY5iM4UTh2e80TG7RHDQQmnAY3xGNQA/DbU0olkhkA= +github.com/tidwall/btree v1.8.2/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= -github.com/vektra/mockery/v2 v2.53.6 h1:qfUB6saauu652ZlMF/mEdlj7B/A0fw2XR0XBACBrf7Y= -github.com/vektra/mockery/v2 v2.53.6/go.mod h1:fjxC+mskIZqf67+z34pHxRRyyZnPnWNA36Cirf01Pkg= +github.com/vektra/mockery/v2 v2.53.8 h1:wSyeOK1BfE+wwls7VKy2uKQoVVA8mPVBJvnm2RfZLic= +github.com/vektra/mockery/v2 v2.53.8/go.mod h1:MUc05xv+h/jtny5E3RrYgVwGmCuD+P2T/CPzlzsjyV0= github.com/wealdtech/go-eth2-types/v2 v2.8.2 h1:b5aXlNBLKgjAg/Fft9VvGlqAUCQMP5LzYhlHRrr4yPg= github.com/wealdtech/go-eth2-types/v2 v2.8.2/go.mod h1:IAz9Lz1NVTaHabQa+4zjk2QDKMv8LVYo0n46M9o/TXw= github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4 v1.4.1 h1:9j7bpwjT9wmwBb54ZkBhTm1uNIlFFcCJXefd/YskZPw= @@ -604,26 +608,28 @@ go.lsp.dev/uri v0.3.0 h1:KcZJmh6nFIBeJzTugn5JTU6OOyG0lDOo3R9KwTxTYbo= go.lsp.dev/uri v0.3.0/go.mod h1:P5sbO1IQR+qySTWOCnhnK7phBx+W3zbLqSMDJNTw88I= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw= go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI= -go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= -go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM= -go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= -go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= -go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= -go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= -go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= -go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= go.opentelemetry.io/otel/trace v1.6.3/go.mod h1:GNJQusJlUgZl9/TQBPKU/Y/ty+0iVB5fjhKeJGZPGFs= -go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= -go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= @@ -634,8 +640,8 @@ go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= -go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= @@ -651,14 +657,14 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g= -golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49 h1:pQo5TwmxKJvI8k39uYS2Gqkk8OXWxX/TMSis2Twp1AQ= +golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -669,15 +675,15 @@ golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -694,26 +700,26 @@ golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= -golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/telemetry v0.0.0-20261009151841-97e8ea77aa2a h1:wnveMBKHwx8/nIb8WYT5P9vtefdXOvIWxzycCkc5n6Q= +golang.org/x/telemetry v0.0.0-20261009151841-97e8ea77aa2a/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= +golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= @@ -728,12 +734,12 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= -google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= -google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= -google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 h1:cFbkNigDInw+wVTXc+CyhUsoHGJCw0BR6mlNoRVfre0= +google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8/go.mod h1:QK6p7IgD7IZEZJBhhSUCib5X2vMOzlrGCGUw2XyVDh0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -770,5 +776,5 @@ mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= -pluginrpc.com/pluginrpc v0.5.0 h1:tOQj2D35hOmvHyPu8e7ohW2/QvAnEtKscy2IJYWQ2yo= -pluginrpc.com/pluginrpc v0.5.0/go.mod h1:UNWZ941hcVAoOZUn8YZsMmOZBzbUjQa3XMns8RQLp9o= +pluginrpc.com/pluginrpc v0.6.0 h1:Dx5r3y9Nbbt02NSm+bKHvs+WgXM34lalD5grkjd8sno= +pluginrpc.com/pluginrpc v0.6.0/go.mod h1:9FPMdIy8nmkALE5eSe2ipE42G83WpZnA2+I7M7ADijY= diff --git a/p2p/metrics.go b/p2p/metrics.go index 6f4a04398c..68eddfea8c 100644 --- a/p2p/metrics.go +++ b/p2p/metrics.go @@ -3,6 +3,7 @@ package p2p import ( + "sync" "time" "github.com/libp2p/go-libp2p" @@ -12,6 +13,7 @@ import ( "github.com/libp2p/go-libp2p/core/protocol" "github.com/libp2p/go-libp2p/p2p/net/swarm" "github.com/prometheus/client_golang/prometheus" + "google.golang.org/protobuf/proto" "github.com/obolnetwork/charon/app/promauto" ) @@ -36,7 +38,7 @@ var ( sendDurations = promauto.NewHistogramVec(prometheus.HistogramOpts{ Namespace: "p2p", Name: "send_duration_seconds", - Help: "Wall-clock duration of synchronous libp2p Send (one-way) and SendReceive (round-trip) calls, by peer, protocol, and topic. Topic is a sub-protocol label (e.g. qbft_pre_prepare, parsigex_proposer); empty when not set by the caller.", + Help: "Wall-clock duration of synchronous libp2p Send (one-way) and SendReceive (round-trip) attempts, by peer, protocol, and topic. Sends with retries enabled observe each attempt separately, excluding backoff. Topic is a sub-protocol label (e.g. qbft_pre_prepare, parsigex_proposer); empty when not set by the caller.", Buckets: prometheus.ExponentialBuckets(0.0001, 2, 18), // 0.1ms .. ~13.1s, covers the ~7s default SendReceive timeout }, []string{"peer", "protocol", "topic"}) @@ -99,8 +101,127 @@ var ( Name: "peer_network_sent_bytes_total", Help: "Total number of network bytes sent to the peer by protocol and transport. Transport is based on first active connection (accurate in steady state).", }, []string{"peer", "protocol", "transport"}) + + receivedMsgSizeHist = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "p2p", + Name: "received_message_size_bytes", + Help: "Size in bytes of received libp2p protobuf messages by protocol and sending peer.", + Buckets: messageSizeBuckets, + }, []string{"protocol", "peer"}) + + sentMsgSizeHist = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "p2p", + Name: "sent_message_size_bytes", + Help: "Size in bytes of sent libp2p protobuf messages by protocol. Not labelled by peer since duty messages are broadcast identically to all peers.", + Buckets: messageSizeBuckets, + }, []string{"protocol"}) + + msgReadErrorCounter = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "p2p", + Name: "message_read_errors_total", + Help: "Total number of failures reading a libp2p message by protocol and sending peer. Includes messages exceeding the protocol read limit.", + }, []string{"protocol", "peer"}) + + msgHandlerPanicCounter = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "p2p", + Name: "message_handler_panics_total", + Help: "Total number of panics recovered while handling an inbound libp2p message by protocol and sending peer. A non-zero value indicates a handler bug or malformed peer message.", + }, []string{"protocol", "peer"}) + + inflightGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "p2p", + Name: "inflight_requests", + Help: "Current number of inbound libp2p messages being handled (stream accept to handler completion) by protocol and sending peer.", + }, []string{"protocol", "peer"}) + + concurrentRequestsHist = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "p2p", + Name: "concurrent_requests", + Help: "Number of concurrently handled inbound messages, observed at each message arrival, by protocol and sending peer. Unlike the sampled inflight_requests gauge, this captures bursts between scrapes.", + Buckets: []float64{1, 2, 4, 8, 16, 32, 64, 128, 256}, + }, []string{"protocol", "peer"}) + + handlerDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "p2p", + Name: "handler_duration_seconds", + Help: "Duration of inbound libp2p message handling from stream accept to handler completion by protocol. Explains inflight_requests: inflight equals message rate times this duration.", + Buckets: prometheus.ExponentialBuckets(0.001, 2, 15), // 1ms .. ~16s, covers the ~10s default receive timeout. + }, []string{"protocol"}) ) +// inflightKey identifies a per-protocol, per-peer inflight request counter. +type inflightKey struct { + protocol string + peer string +} + +// inflightCounts tracks the number of concurrently handled inbound messages per (protocol, peer). +// It is the source of truth for both inflightGauge and concurrentRequestsHist so they never drift. +var inflightCounts = struct { + sync.Mutex + + counts map[inflightKey]int +}{counts: make(map[inflightKey]int)} + +// observeHandlerStart records the start of inbound message handling and returns a done function +// to be called (deferred) when handling completes. +func observeHandlerStart(pID protocol.ID, peerID peer.ID) func() { + key := inflightKey{protocol: string(pID), peer: PeerName(peerID)} + t0 := time.Now() + + inflightCounts.Lock() + inflightCounts.counts[key]++ + n := inflightCounts.counts[key] + // Update the gauge while holding the lock so concurrent Set calls cannot be reordered. + inflightGauge.WithLabelValues(key.protocol, key.peer).Set(float64(n)) + inflightCounts.Unlock() + + concurrentRequestsHist.WithLabelValues(key.protocol, key.peer).Observe(float64(n)) + + return func() { + inflightCounts.Lock() + inflightCounts.counts[key]-- + + n := inflightCounts.counts[key] + if n <= 0 { + delete(inflightCounts.counts, key) // Don't grow the map with idle keys. + } + + inflightGauge.WithLabelValues(key.protocol, key.peer).Set(float64(n)) + inflightCounts.Unlock() + + handlerDuration.WithLabelValues(key.protocol).Observe(time.Since(t0).Seconds()) + } +} + +// messageSizeBuckets covers charon p2p message sizes from small control messages up to the +// 128MiB default read limit. The 32MiB and 128MiB edges match protocol read limits, so +// messages that (would) exceed a limit are countable by exact bucket subtraction. +var messageSizeBuckets = []float64{ + 256, 1 << 10, 4 << 10, 16 << 10, 64 << 10, 256 << 10, 512 << 10, + 1 << 20, 2 << 20, 4 << 20, 8 << 20, 16 << 20, 32 << 20, 64 << 20, 128 << 20, +} + +// observeReceivedMessage records the size of a successfully read libp2p protobuf message. +func observeReceivedMessage(pID protocol.ID, peerID peer.ID, msg proto.Message) { + receivedMsgSizeHist.WithLabelValues(string(pID), PeerName(peerID)).Observe(float64(proto.Size(msg))) +} + +// observeSentMessage records the size of a successfully written libp2p protobuf message. +func observeSentMessage(pID protocol.ID, msg proto.Message) { + sentMsgSizeHist.WithLabelValues(string(pID)).Observe(float64(proto.Size(msg))) +} + +// incMessageReadError increments the read error counter for the protocol and sending peer. +func incMessageReadError(pID protocol.ID, peerID peer.ID) { + msgReadErrorCounter.WithLabelValues(string(pID), PeerName(peerID)).Inc() +} + +// incMessageHandlerPanic increments the recovered handler panic counter for the protocol and sending peer. +func incMessageHandlerPanic(pID protocol.ID, peerID peer.ID) { + msgHandlerPanicCounter.WithLabelValues(string(pID), PeerName(peerID)).Inc() +} + func observePing(p peer.ID, d time.Duration) { pingLatencies.WithLabelValues(PeerName(p)).Observe(d.Seconds()) pingSuccess.WithLabelValues(PeerName(p)).Set(1) diff --git a/p2p/metrics_internal_test.go b/p2p/metrics_internal_test.go new file mode 100644 index 0000000000..42e0b0288c --- /dev/null +++ b/p2p/metrics_internal_test.go @@ -0,0 +1,220 @@ +// Copyright © 2022-2026 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package p2p + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/peerstore" + "github.com/libp2p/go-libp2p/core/protocol" + "github.com/prometheus/client_golang/prometheus" + promtestutil "github.com/prometheus/client_golang/prometheus/testutil" + pb "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + pbv1 "github.com/obolnetwork/charon/core/corepb/v1" + "github.com/obolnetwork/charon/testutil" +) + +// histSample returns the sample count and sum of the histogram for the given label values. +func histSample(t *testing.T, hist *prometheus.HistogramVec, labels ...string) (uint64, float64) { + t.Helper() + + m := new(pb.Metric) + + h, err := hist.GetMetricWithLabelValues(labels...) + require.NoError(t, err) + require.NoError(t, h.(prometheus.Histogram).Write(m)) + + return m.GetHistogram().GetSampleCount(), m.GetHistogram().GetSampleSum() +} + +func TestMessageSizeMetrics(t *testing.T) { + var ( + pID = protocol.ID("test-msg-size") + ctx = context.Background() + client = testutil.CreateHost(t, testutil.AvailableAddr(t)) + server = testutil.CreateHost(t, testutil.AvailableAddr(t)) + ) + + // Received sizes are labelled by peer name, so both directions must not share one. + for PeerName(client.ID()) == PeerName(server.ID()) { + client = testutil.CreateHost(t, testutil.AvailableAddr(t)) + } + + client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL) + + RegisterHandler("server", server, pID, + func() proto.Message { return new(pbv1.Duty) }, + func(_ context.Context, _ peer.ID, req proto.Message) (proto.Message, bool, error) { + return req, true, nil + }, + ) + + req := &pbv1.Duty{Slot: 123, Type: 4} + msgSize := float64(proto.Size(req)) + require.Positive(t, msgSize) + + sentCount0, sentSum0 := histSample(t, sentMsgSizeHist, string(pID)) + recvClient0, _ := histSample(t, receivedMsgSizeHist, string(pID), PeerName(server.ID())) + recvServer0, _ := histSample(t, receivedMsgSizeHist, string(pID), PeerName(client.ID())) + + resp := new(pbv1.Duty) + require.NoError(t, SendReceive(ctx, client, server.ID(), req, resp, pID)) + + // Client sent the request and server sent the identical echoed response. + // The server observes its send after writing the response, so wait for it. + require.Eventually(t, func() bool { + sentCount, _ := histSample(t, sentMsgSizeHist, string(pID)) + return sentCount == sentCount0+2 + }, time.Second*5, time.Millisecond*10) + + _, sentSum := histSample(t, sentMsgSizeHist, string(pID)) + require.InDelta(t, sentSum0+2*msgSize, sentSum, 0.1) + + // Server received the request from the client. + recvServer, recvServerSum := histSample(t, receivedMsgSizeHist, string(pID), PeerName(client.ID())) + require.Equal(t, recvServer0+1, recvServer) + require.InDelta(t, msgSize, recvServerSum, 0.1) + + // Client received the response from the server. + recvClient, _ := histSample(t, receivedMsgSizeHist, string(pID), PeerName(server.ID())) + require.Equal(t, recvClient0+1, recvClient) +} + +func TestMessageReadErrorMetric(t *testing.T) { + var ( + pID = protocol.ID("test-read-limit") + ctx = context.Background() + client = testutil.CreateHost(t, testutil.AvailableAddr(t)) + server = testutil.CreateHost(t, testutil.AvailableAddr(t)) + ) + + client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL) + + RegisterHandler("server", server, pID, + func() proto.Message { return new(pbv1.ParSigExMsg) }, + func(context.Context, peer.ID, proto.Message) (proto.Message, bool, error) { + require.Fail(t, "handler must not be called for oversized message") + return nil, false, nil + }, + WithReadLimit(16), + ) + + errCounter := msgReadErrorCounter.WithLabelValues(string(pID), PeerName(client.ID())) + errCount0 := promtestutil.ToFloat64(errCounter) + + // Message larger than the 16 byte read limit is rejected server side before the handler. + // Send is one-way so the local write succeeds regardless. + msg := &pbv1.ParSigExMsg{Duty: &pbv1.Duty{Slot: 99, Type: 2}, DataSet: &pbv1.ParSignedDataSet{ + Set: map[string]*pbv1.ParSignedData{"0xdeadbeef": {Data: make([]byte, 1024), Signature: make([]byte, 96)}}, + }} + require.NoError(t, Send(ctx, client, pID, server.ID(), msg)) + + require.Eventually(t, func() bool { + return promtestutil.ToFloat64(errCounter) >= errCount0+1 + }, time.Second*5, time.Millisecond*10) +} + +func TestInflightRequestMetrics(t *testing.T) { + var ( + pID = protocol.ID("test-inflight") + ctx = context.Background() + client = testutil.CreateHost(t, testutil.AvailableAddr(t)) + server = testutil.CreateHost(t, testutil.AvailableAddr(t)) + ) + + client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL) + + RegisterHandler("server", server, pID, + func() proto.Message { return new(pbv1.Duty) }, + func(_ context.Context, _ peer.ID, req proto.Message) (proto.Message, bool, error) { + return req, true, nil + }, + ) + + clientName := PeerName(client.ID()) + + resp := new(pbv1.Duty) + require.NoError(t, SendReceive(ctx, client, server.ID(), &pbv1.Duty{Slot: 1}, resp, pID)) + + // One arrival observed in the concurrency histogram with count 1. + concCount, concSum := histSample(t, concurrentRequestsHist, string(pID), clientName) + require.Equal(t, uint64(1), concCount) + require.InDelta(t, 1, concSum, 0.1) + + // Handler duration observed once. It is observed when the handler returns, after + // writing the response, so wait for it. + require.Eventually(t, func() bool { + durCount, _ := histSample(t, handlerDuration, string(pID)) + return durCount == 1 + }, time.Second*5, time.Millisecond*10) + + _, durSum := histSample(t, handlerDuration, string(pID)) + require.Positive(t, durSum) + + // In-flight gauge back to zero after completion (set before the duration is observed). + gauge, err := inflightGauge.GetMetricWithLabelValues(string(pID), clientName) + require.NoError(t, err) + require.Zero(t, promtestutil.ToFloat64(gauge)) +} + +func TestConcurrentRequestDepths(t *testing.T) { + var ( + pID = protocol.ID("test-concurrency") + ctx = context.Background() + client = testutil.CreateHost(t, testutil.AvailableAddr(t)) + server = testutil.CreateHost(t, testutil.AvailableAddr(t)) + ) + + client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL) + + release := make(chan struct{}) + + RegisterHandler("server", server, pID, + func() proto.Message { return new(pbv1.Duty) }, + func(_ context.Context, _ peer.ID, req proto.Message) (proto.Message, bool, error) { + <-release + return req, true, nil + }, + ) + + clientName := PeerName(client.ID()) + + // Issue concurrent requests against a blocked handler. + const n = 3 + + var wg sync.WaitGroup + for range n { + wg.Go(func() { + resp := new(pbv1.Duty) + _ = SendReceive(ctx, client, server.ID(), &pbv1.Duty{Slot: 1}, resp, pID) + }) + } + + gauge, err := inflightGauge.GetMetricWithLabelValues(string(pID), clientName) + require.NoError(t, err) + + // All requests block in the handler, so the gauge reaches n. + require.Eventually(t, func() bool { + return promtestutil.ToFloat64(gauge) == n + }, time.Second*5, time.Millisecond*10) + + close(release) + wg.Wait() + + // Gauge returns to zero once all handlers complete. + require.Eventually(t, func() bool { + return promtestutil.ToFloat64(gauge) == 0 + }, time.Second*5, time.Millisecond*10) + + // The concurrency histogram observed depths 1, 2 and 3 (in some order), so the sum is 6. + concCount, concSum := histSample(t, concurrentRequestsHist, string(pID), clientName) + require.Equal(t, uint64(n), concCount) + require.InDelta(t, 6, concSum, 0.1) +} diff --git a/p2p/receive.go b/p2p/receive.go index c6726757f9..d72c421f8c 100644 --- a/p2p/receive.go +++ b/p2p/receive.go @@ -54,12 +54,14 @@ func RegisterHandler(logTopic string, p2pNode host.Host, pID protocol.ID, t0 := time.Now() name := PeerName(s.Conn().RemotePeer()) + streamProtocol := s.Protocol() + _ = s.SetReadDeadline(time.Now().Add(o.receiveTimeout)) ctx, cancel := context.WithTimeout(context.Background(), o.receiveTimeout) ctx = log.WithTopic(ctx, logTopic) ctx = log.WithCtx(ctx, z.Str("peer", name), - z.Any("protocol", s.Protocol()), + z.Any("protocol", streamProtocol), ) defer cancel() @@ -68,6 +70,7 @@ func RegisterHandler(logTopic string, p2pNode host.Host, pID protocol.ID, // Recover any panic in this stream handler goroutine. defer func() { if r := recover(); r != nil { + incMessageHandlerPanic(streamProtocol, s.Conn().RemotePeer()) log.Error(ctx, "Recovered from panic handling p2p message; stream dropped and process kept alive", nil, z.Any("recover", r), z.Str("stacktrace", string(debug.Stack())), @@ -75,30 +78,49 @@ func RegisterHandler(logTopic string, p2pNode host.Host, pID protocol.ID, } }() - writeFunc, ok := o.writersByProtocol[s.Protocol()] + writeFunc, ok := o.writersByProtocol[streamProtocol] if !ok { - log.Error(ctx, "No writer registered for protocol. This may indicate an unsupported or misconfigured p2p protocol", nil, z.Any("protocol", s.Protocol())) + log.Error(ctx, "No writer registered for protocol. This may indicate an unsupported or misconfigured p2p protocol", nil, z.Any("protocol", streamProtocol)) return } - readFunc, ok := o.readersByProtocol[s.Protocol()] + readFunc, ok := o.readersByProtocol[streamProtocol] if !ok { - log.Error(ctx, "No reader registered for protocol. This may indicate an unsupported or misconfigured p2p protocol", nil, z.Any("protocol", s.Protocol())) + log.Error(ctx, "No reader registered for protocol. This may indicate an unsupported or misconfigured p2p protocol", nil, z.Any("protocol", streamProtocol)) return } + handlerDone := observeHandlerStart(streamProtocol, s.Conn().RemotePeer()) + defer handlerDone() + req := zeroReq() err := readFunc(s).ReadMsg(req) if IsRelayError(err) { + // Resets on relayed (limited) connections are benign circuit recycling, + // but a reset on a direct connection is a genuine read failure. + if !s.Conn().Stat().Limited { + incMessageReadError(streamProtocol, s.Conn().RemotePeer()) + } + return // Ignore relay errors. } else if netErr := net.Error(nil); errors.As(err, &netErr) && netErr.Timeout() { + incMessageReadError(streamProtocol, s.Conn().RemotePeer()) log.Error(ctx, "Timeout reading p2p message from peer. This may indicate network latency issues or unresponsive peer", err, z.Any("duration", time.Since(t0))) + return } else if err != nil { + incMessageReadError(streamProtocol, s.Conn().RemotePeer()) log.Error(ctx, "Failed to read p2p request from peer. Check network connectivity and peer health", err, z.Any("duration", time.Since(t0))) + return - } else if err := protonil.Check(req); err != nil { + } + + // Observe before application-level validation so malformed-but-readable messages + // are still visible in the size histogram. + observeReceivedMessage(streamProtocol, s.Conn().RemotePeer(), req) + + if err := protonil.Check(req); err != nil { log.Warn(ctx, "LibP2P received invalid proto", err) return } @@ -119,5 +141,7 @@ func RegisterHandler(logTopic string, p2pNode host.Host, pID protocol.ID, log.Error(ctx, "Failed to write p2p response to peer. Connection may have been closed", err) return } + + observeSentMessage(streamProtocol, resp) }) } diff --git a/p2p/sender.go b/p2p/sender.go index 3006c03fb9..aedf11c952 100644 --- a/p2p/sender.go +++ b/p2p/sender.go @@ -17,6 +17,7 @@ import ( "google.golang.org/protobuf/proto" "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/app/expbackoff" "github.com/obolnetwork/charon/app/log" "github.com/obolnetwork/charon/app/z" ) @@ -202,6 +203,7 @@ type sendRecvOpts struct { rttCallback func(time.Duration) receiveTimeout time.Duration sendTimeout time.Duration + retries int // Number of additional send attempts after a failure. metricTopic string // Optional sub-protocol label for the send_duration metric. } @@ -213,12 +215,26 @@ func WithReceiveTimeout(timeout time.Duration) func(*sendRecvOpts) { } // WithSendTimeout returns an option for SendReceive that sets a timeout for sending messages. +// The timeout applies per attempt, so with WithRetries the whole call can take up to +// (retries+1) times this timeout plus the inter-attempt backoff. func WithSendTimeout(timeout time.Duration) func(*sendRecvOpts) { return func(opts *sendRecvOpts) { opts.sendTimeout = timeout } } +// WithRetries returns an option that retries a failed send up to the given number of +// additional attempts, each on a fresh stream with the full send timeout, backing off +// briefly in between. Only use it for protocols whose handlers tolerate duplicate delivery, +// since a send that failed on the sender side may still have been delivered (e.g. DKG +// ceremony messages, which are deduplicated by all receivers). Negative values are clamped +// to zero (no retries); left unclamped they would make the retry loop skip the send entirely. +func WithRetries(retries int) func(*sendRecvOpts) { + return func(opts *sendRecvOpts) { + opts.retries = max(retries, 0) + } +} + // WithSendMetricTopic returns an option that adds a topic label to the send_duration metric. // Use a small bounded set of values (e.g. a message-type name) to keep metric cardinality low. func WithSendMetricTopic(topic string) func(*sendRecvOpts) { @@ -288,6 +304,32 @@ func defaultSendRecvOpts(pID protocol.ID) sendRecvOpts { } } +// withRetries calls fn and retries it up to the given number of additional times, +// backing off briefly between attempts. Each attempt is independently timed by fn (via +// the send timeout), so the whole call takes up to (retries+1) attempts plus the backoffs. +// Cancellation of ctx stops retrying and surfaces as the context error (with the last +// attempt error attached as a field), so callers can detect it with errors.Is. +func withRetries(ctx context.Context, retries int, fn func() error) error { + var err error + + for attempt := range retries + 1 { + err = fn() + if err == nil || attempt == retries { + return err + } + + timer := time.NewTimer(expbackoff.Backoff(expbackoff.FastConfig, attempt)) + select { + case <-ctx.Done(): + timer.Stop() + return errors.Wrap(ctx.Err(), "aborting send retries", z.Err(err)) + case <-timer.C: + } + } + + return err // Unreachable: the final iteration (attempt == retries) always returns. +} + // SendReceive sends and receives a libp2p request and response message // pair synchronously and then closes the stream. // The provided response proto will be populated if err is nil. @@ -295,8 +337,6 @@ func defaultSendRecvOpts(pID protocol.ID) sendRecvOpts { func SendReceive(ctx context.Context, p2pNode host.Host, peerID peer.ID, req, resp proto.Message, pID protocol.ID, opts ...SendRecvOption, ) error { - tStart := time.Now() - if !isZeroProto(resp) { return errors.New("bug: response proto must be zero value") } @@ -306,23 +346,44 @@ func SendReceive(ctx context.Context, p2pNode host.Host, peerID peer.ID, opt(&o) } + return withRetries(ctx, o.retries, func() error { + // A failed attempt may have partially populated the response. + proto.Reset(resp) + + return sendReceive(ctx, p2pNode, peerID, req, resp, pID, o) + }) +} + +// sendReceive is a single SendReceive attempt, timed out after the send timeout. +func sendReceive(ctx context.Context, p2pNode host.Host, peerID peer.ID, + req, resp proto.Message, pID protocol.ID, o sendRecvOpts, +) error { + tStart := time.Now() + protoLabel := string(pID) // Updated to the negotiated protocol once NewStream succeeds. defer func() { sendDurations.WithLabelValues(PeerName(peerID), protoLabel, o.metricTopic).Observe(time.Since(tStart).Seconds()) }() + // The send timeout bounds this attempt: the context covers dialing and protocol + // negotiation, the stream deadline covers the write and response read. + deadline := time.Now().Add(o.sendTimeout) + + ctx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + // Circuit relay connections are transient s, err := p2pNode.NewStream(network.WithAllowLimitedConn(ctx, ""), peerID, o.protocols...) if err != nil { - return errors.Wrap(err, "new stream", z.Any("protocols", o.protocols)) + return errors.Wrap(err, "new stream", z.Any("protocols", o.protocols), z.Str("peer", PeerName(peerID))) } defer s.Close() protoLabel = string(s.Protocol()) - if err := s.SetDeadline(time.Now().Add(o.sendTimeout)); err != nil { - return errors.Wrap(err, "set deadline") + if err := s.SetDeadline(deadline); err != nil { + return errors.Wrap(err, "set deadline", z.Str("peer", PeerName(peerID))) } writeFunc, ok := o.writersByProtocol[s.Protocol()] @@ -341,7 +402,7 @@ func SendReceive(ctx context.Context, p2pNode host.Host, peerID peer.ID, t0 := time.Now() if err = writer.WriteMsg(req); err != nil { - return errors.Wrap(err, "write request", z.Any("protocol", s.Protocol())) + return errors.Wrap(err, "write request", z.Any("protocol", s.Protocol()), z.Str("peer", PeerName(peerID))) } if err := s.CloseWrite(); err != nil { @@ -354,14 +415,24 @@ func SendReceive(ctx context.Context, p2pNode host.Host, peerID peer.ID, if isCanceledStreamErr(err) { log.Debug(ctx, "Closing write of canceled stream", z.Err(err), z.Any("protocol", s.Protocol())) } else { - return errors.Wrap(err, "close write", z.Any("protocol", s.Protocol())) + return errors.Wrap(err, "close write", z.Any("protocol", s.Protocol()), z.Str("peer", PeerName(peerID))) } } + observeSentMessage(s.Protocol(), req) + if err = reader.ReadMsg(resp); err != nil { - return errors.Wrap(err, "read response", z.Any("protocol", s.Protocol())) + // Resets on relayed (limited) connections are benign circuit recycling, + // but any other failure (including a reset on a direct connection) counts. + if !IsRelayError(err) || !s.Conn().Stat().Limited { + incMessageReadError(s.Protocol(), peerID) + } + + return errors.Wrap(err, "read response", z.Any("protocol", s.Protocol()), z.Str("peer", PeerName(peerID))) } + observeReceivedMessage(s.Protocol(), peerID, resp) + o.rttCallback(time.Since(t0)) return nil @@ -371,13 +442,22 @@ func SendReceive(ctx context.Context, p2pNode host.Host, peerID peer.ID, func Send(ctx context.Context, p2pNode host.Host, protoID protocol.ID, peerID peer.ID, msg proto.Message, opts ...SendRecvOption, ) error { - t0 := time.Now() - o := defaultSendRecvOpts(protoID) for _, opt := range opts { opt(&o) } + return withRetries(ctx, o.retries, func() error { + return send(ctx, p2pNode, protoID, peerID, msg, o) + }) +} + +// send is a single Send attempt, timed out after the send timeout. +func send(ctx context.Context, p2pNode host.Host, protoID protocol.ID, peerID peer.ID, msg proto.Message, + o sendRecvOpts, +) error { + t0 := time.Now() + protoLabel := string(protoID) if len(o.protocols) > 0 { protoLabel = string(o.protocols[0]) @@ -386,17 +466,25 @@ func Send(ctx context.Context, p2pNode host.Host, protoID protocol.ID, peerID pe defer func() { sendDurations.WithLabelValues(PeerName(peerID), protoLabel, o.metricTopic).Observe(time.Since(t0).Seconds()) }() + + // The send timeout bounds this attempt: the context covers dialing and protocol + // negotiation, the stream deadline covers the message write. + deadline := time.Now().Add(o.sendTimeout) + + ctx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + // Circuit relay connections are transient s, err := p2pNode.NewStream(network.WithAllowLimitedConn(ctx, ""), peerID, o.protocols...) if err != nil { - return errors.Wrap(err, "p2pNode stream") + return errors.Wrap(err, "p2pNode stream", z.Str("peer", PeerName(peerID))) } defer s.Close() protoLabel = string(s.Protocol()) - if err := s.SetDeadline(time.Now().Add(o.sendTimeout)); err != nil { - return errors.Wrap(err, "set deadline") + if err := s.SetDeadline(deadline); err != nil { + return errors.Wrap(err, "set deadline", z.Str("peer", PeerName(peerID))) } writeFunc, ok := o.writersByProtocol[s.Protocol()] @@ -405,9 +493,11 @@ func Send(ctx context.Context, p2pNode host.Host, protoID protocol.ID, peerID pe } if err = writeFunc(s).WriteMsg(msg); err != nil { - return errors.Wrap(err, "write message", z.Any("protocol", s.Protocol())) + return errors.Wrap(err, "write message", z.Any("protocol", s.Protocol()), z.Str("peer", PeerName(peerID))) } + observeSentMessage(s.Protocol(), msg) + return nil } diff --git a/p2p/sender_test.go b/p2p/sender_test.go index 21a646357e..dd998729b8 100644 --- a/p2p/sender_test.go +++ b/p2p/sender_test.go @@ -12,13 +12,17 @@ import ( "time" "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/network" "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/peerstore" "github.com/libp2p/go-libp2p/core/protocol" "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" "google.golang.org/protobuf/proto" "github.com/obolnetwork/charon/app/log" + "github.com/obolnetwork/charon/app/z" pbv1 "github.com/obolnetwork/charon/core/corepb/v1" "github.com/obolnetwork/charon/p2p" "github.com/obolnetwork/charon/testutil" @@ -121,10 +125,9 @@ func TestSendReceiveClosesStreamOnError(t *testing.T) { func TestWithSendTimeout(t *testing.T) { servers := []host.Host{testutil.CreateHost(t, testutil.AvailableAddr(t)), testutil.CreateQUICHost(t, testutil.AvailableUDPAddr(t))} clients := []host.Host{testutil.CreateHost(t, testutil.AvailableAddr(t)), testutil.CreateQUICHost(t, testutil.AvailableUDPAddr(t))} - errors := []string{"deadline reached", "deadline exceeded"} for i := range len(servers) { - client, server, errorStr := clients[i], servers[i], errors[i] + client, server := clients[i], servers[i] client.Peerstore().AddAddrs(server.ID(), server.Addrs(), time.Hour) @@ -138,13 +141,333 @@ func TestWithSendTimeout(t *testing.T) { return nil, false, nil }) + // Depending on how far the tiny budget lets the call get, the deadline error + // surfaces at dialing ("context deadline exceeded"), or on the stream + // ("deadline reached" on TCP, "deadline exceeded" on QUIC). err := p2p.SendReceive(context.Background(), client, server.ID(), new(pbv1.Duty), new(pbv1.Duty), protocolID, p2p.WithSendTimeout(sendTimeout)) require.Error(t, err) - require.ErrorContains(t, err, errorStr) + require.ErrorContains(t, err, "deadline") + } +} + +// errFields extracts the structured fields of an error into a map. +func errFields(t *testing.T, err error) map[string]any { + t.Helper() + + fielder, ok := err.(interface{ Fields() []z.Field }) + require.True(t, ok, "error does not have structured fields") + + enc := zapcore.NewMapObjectEncoder() + + for _, field := range fielder.Fields() { + field(func(zf zap.Field) { + zf.AddTo(enc) + }) + } + + return enc.Fields +} + +func TestSendErrorContainsPeerField(t *testing.T) { + ctx := context.Background() + server := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL) + + peerName := p2p.PeerName(server.ID()) + + t.Run("send new stream error", func(t *testing.T) { + // No handler registered on the server, so protocol negotiation fails. + err := p2p.Send(ctx, client, "unknown", server.ID(), &pbv1.Duty{Slot: 1}) + require.Error(t, err) + require.Equal(t, peerName, errFields(t, err)["peer"]) + }) + + t.Run("send receive new stream error", func(t *testing.T) { + err := p2p.SendReceive(ctx, client, server.ID(), new(pbv1.Duty), new(pbv1.Duty), "unknown") + require.Error(t, err) + require.Equal(t, peerName, errFields(t, err)["peer"]) + }) + + t.Run("send write error", func(t *testing.T) { + protocolID := protocol.ID("testprotocol-peerfield") + p2p.RegisterHandler("test", server, protocolID, + func() proto.Message { return new(pbv1.Duty) }, + func(context.Context, peer.ID, proto.Message) (proto.Message, bool, error) { + return nil, false, nil + }) + + // The negative send timeout sets an already-expired stream deadline, failing the write. + err := p2p.Send(ctx, client, protocolID, server.ID(), &pbv1.Duty{Slot: 1}, + p2p.WithSendTimeout(-time.Second)) + require.Error(t, err) + require.Equal(t, peerName, errFields(t, err)["peer"]) + }) +} + +func TestSendRetries(t *testing.T) { + ctx := context.Background() + server := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL) + + received := make(chan *pbv1.Duty, 1) + protocolID := protocol.ID("testprotocol-retries") + p2p.RegisterHandler("test", server, protocolID, + func() proto.Message { return new(pbv1.Duty) }, + func(_ context.Context, _ peer.ID, req proto.Message) (proto.Message, bool, error) { + duty, ok := req.(*pbv1.Duty) + require.True(t, ok) + + received <- duty + + return nil, false, nil + }) + + t.Run("no retries by default", func(t *testing.T) { + flaky := testutil.NewFlakyHost(client, 1) + err := p2p.Send(ctx, flaky, protocolID, server.ID(), &pbv1.Duty{Slot: 1}) + require.Error(t, err) + require.Equal(t, 1, flaky.Calls()) + }) + + t.Run("retries transient failures", func(t *testing.T) { + flaky := testutil.NewFlakyHost(client, 2) + err := p2p.Send(ctx, flaky, protocolID, server.ID(), &pbv1.Duty{Slot: 2}, p2p.WithRetries(2)) + require.NoError(t, err) + require.Equal(t, 3, flaky.Calls()) + + select { + case duty := <-received: + require.EqualValues(t, 2, duty.GetSlot()) + case <-time.After(5 * time.Second): + require.Fail(t, "timed out waiting for message") + } + }) + + t.Run("fails when retries exhausted", func(t *testing.T) { + flaky := testutil.NewFlakyHost(client, 3) + err := p2p.Send(ctx, flaky, protocolID, server.ID(), &pbv1.Duty{Slot: 3}, p2p.WithRetries(2)) + require.ErrorContains(t, err, "transient stream failure") + require.Equal(t, 3, flaky.Calls()) + }) + + t.Run("each attempt gets the full send timeout", func(t *testing.T) { + // blocking dials never succeed, so every attempt runs until its own send + // timeout. The send timeout is per attempt, so the total spans all attempts. + const perAttempt = 300 * time.Millisecond + + blocking := blockingHost{Host: client} + t0 := time.Now() + + err := p2p.Send(ctx, blocking, protocolID, server.ID(), &pbv1.Duty{Slot: 5}, + p2p.WithRetries(2), p2p.WithSendTimeout(perAttempt)) + require.ErrorIs(t, err, context.DeadlineExceeded) + // 3 attempts each get the full timeout (not a sliced share of it). + require.GreaterOrEqual(t, time.Since(t0), 3*perAttempt) + }) + + t.Run("cancellation surfaces as context error", func(t *testing.T) { + cancelledCtx, cancel := context.WithCancel(ctx) + cancel() + + flaky := testutil.NewFlakyHost(client, 10) + err := p2p.Send(cancelledCtx, flaky, protocolID, server.ID(), &pbv1.Duty{Slot: 4}, p2p.WithRetries(5)) + require.ErrorIs(t, err, context.Canceled) + require.Equal(t, 1, flaky.Calls()) + }) +} + +// blockingHost wraps a host whose NewStream blocks until the context is done, +// simulating a hung dial or protocol negotiation. +type blockingHost struct { + host.Host +} + +func (h blockingHost) NewStream(ctx context.Context, _ peer.ID, _ ...protocol.ID) (network.Stream, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +func TestSendTimeoutBoundsStreamCreation(t *testing.T) { + ctx := context.Background() + server := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL) + + blocking := blockingHost{Host: client} + + tests := []struct { + name string + send func() error + }{ + { + name: "send", + send: func() error { + return p2p.Send(ctx, blocking, "proto", server.ID(), &pbv1.Duty{Slot: 1}, + p2p.WithSendTimeout(100*time.Millisecond), p2p.WithRetries(2)) + }, + }, + { + name: "send receive", + send: func() error { + return p2p.SendReceive(ctx, blocking, server.ID(), new(pbv1.Duty), new(pbv1.Duty), "proto", + p2p.WithSendTimeout(100*time.Millisecond), p2p.WithRetries(2)) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + errChan := make(chan error, 1) + go func() { + errChan <- test.send() + }() + + select { + case err := <-errChan: + require.ErrorIs(t, err, context.DeadlineExceeded) + case <-time.After(2 * time.Second): + require.Fail(t, "send blocked past its timeout budget on stream creation") + } + }) + } +} + +// blockOnceHost wraps a host whose first NewStream blocks until the context is done, +// simulating a hung dial on the first attempt only. +type blockOnceHost struct { + host.Host + + calls atomic.Int32 +} + +func (h *blockOnceHost) NewStream(ctx context.Context, peerID peer.ID, pIDs ...protocol.ID) (network.Stream, error) { + if h.calls.Add(1) == 1 { + <-ctx.Done() + return nil, ctx.Err() + } + + return h.Host.NewStream(ctx, peerID, pIDs...) +} + +// TestSendRetriesAfterStalledDial ensures a hung dial only consumes its slice of the +// total send budget, leaving room for a retry. +func TestSendRetriesAfterStalledDial(t *testing.T) { + ctx := context.Background() + server := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL) + + received := make(chan *pbv1.Duty, 1) + protocolID := protocol.ID("testprotocol-stalled-dial") + p2p.RegisterHandler("test", server, protocolID, + func() proto.Message { return new(pbv1.Duty) }, + func(_ context.Context, _ peer.ID, req proto.Message) (proto.Message, bool, error) { + duty, ok := req.(*pbv1.Duty) + require.True(t, ok) + + received <- duty + + return nil, false, nil + }) + + blocking := &blockOnceHost{Host: client} + err := p2p.Send(ctx, blocking, protocolID, server.ID(), &pbv1.Duty{Slot: 7}, + p2p.WithSendTimeout(2*time.Second), p2p.WithRetries(2)) + require.NoError(t, err) + require.GreaterOrEqual(t, blocking.calls.Load(), int32(2)) + + select { + case duty := <-received: + require.EqualValues(t, 7, duty.GetSlot()) + case <-time.After(5 * time.Second): + require.Fail(t, "timed out waiting for message") } } +// TestSendReceiveAllowsSlowResponse ensures each SendReceive attempt gets the full send +// timeout for the response wait, not a slice of it, so a peer that legitimately responds +// slower than sendTimeout/(retries+1) is not aborted. +func TestSendReceiveAllowsSlowResponse(t *testing.T) { + ctx := context.Background() + server := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL) + + var calls atomic.Int32 + + protocolID := protocol.ID("testprotocol-slow-response") + p2p.RegisterHandler("test", server, protocolID, + func() proto.Message { return new(pbv1.Duty) }, + func(_ context.Context, _ peer.ID, req proto.Message) (proto.Message, bool, error) { + calls.Add(1) + // Respond slower than a sliced budget (1s/6 ≈ 166ms) would allow, but + // well within the full 1s send timeout. + time.Sleep(400 * time.Millisecond) + + duty, ok := req.(*pbv1.Duty) + require.True(t, ok) + + return &pbv1.Duty{Slot: duty.GetSlot() + 1}, true, nil + }) + + resp := new(pbv1.Duty) + err := p2p.SendReceive(ctx, client, server.ID(), &pbv1.Duty{Slot: 41}, resp, protocolID, + p2p.WithSendTimeout(time.Second), p2p.WithRetries(5)) + require.NoError(t, err) + require.EqualValues(t, 42, resp.GetSlot()) + require.EqualValues(t, 1, calls.Load(), "slow but valid response must succeed on the first attempt") +} + +func TestWithRetriesClampsNegative(t *testing.T) { + ctx := context.Background() + server := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL) + + protocolID := protocol.ID("testprotocol-negative-retries") + p2p.RegisterHandler("test", server, protocolID, + func() proto.Message { return new(pbv1.Duty) }, + func(context.Context, peer.ID, proto.Message) (proto.Message, bool, error) { + return nil, false, nil + }) + + // A negative retry count must not panic (it would divide by zero when computing the + // per-attempt slice) and behaves as zero retries. + require.NotPanics(t, func() { + flaky := testutil.NewFlakyHost(client, 1) + err := p2p.Send(ctx, flaky, protocolID, server.ID(), &pbv1.Duty{Slot: 1}, p2p.WithRetries(-1)) + require.Error(t, err) + require.Equal(t, 1, flaky.Calls()) + }) +} + +func TestSendReceiveRetries(t *testing.T) { + ctx := context.Background() + server := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client := testutil.CreateHost(t, testutil.AvailableAddr(t)) + client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL) + + protocolID := protocol.ID("testprotocol-sendrecv-retries") + p2p.RegisterHandler("test", server, protocolID, + func() proto.Message { return new(pbv1.Duty) }, + func(_ context.Context, _ peer.ID, req proto.Message) (proto.Message, bool, error) { + duty, ok := req.(*pbv1.Duty) + require.True(t, ok) + + return &pbv1.Duty{Slot: duty.GetSlot() + 1}, true, nil + }) + + flaky := testutil.NewFlakyHost(client, 2) + resp := new(pbv1.Duty) + err := p2p.SendReceive(ctx, flaky, server.ID(), &pbv1.Duty{Slot: 41}, resp, protocolID, p2p.WithRetries(2)) + require.NoError(t, err) + require.Equal(t, 3, flaky.Calls()) + require.EqualValues(t, 42, resp.GetSlot()) +} + func TestSend(t *testing.T) { var ( undelimID = protocol.ID("undelimited") diff --git a/testutil/beaconmock/beaconmock.go b/testutil/beaconmock/beaconmock.go index 79b8b950ce..fe34f25b14 100644 --- a/testutil/beaconmock/beaconmock.go +++ b/testutil/beaconmock/beaconmock.go @@ -33,6 +33,7 @@ import ( eth2v1 "github.com/attestantio/go-eth2-client/api/v1" eth2spec "github.com/attestantio/go-eth2-client/spec" "github.com/attestantio/go-eth2-client/spec/altair" + "github.com/attestantio/go-eth2-client/spec/electra" eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/jonboulle/clockwork" @@ -230,6 +231,7 @@ type Mock struct { NodeVersionFunc func(context.Context, *eth2api.NodeVersionOpts) (*eth2api.Response[string], error) NodeVersionV2Func func(context.Context, *eth2api.NodeVersionV2Opts) (*eth2api.Response[*eth2v1.NodeVersionV2], error) ProxyFunc func(context.Context, *http.Request) (*http.Response, error) + PendingDepositsFunc func(context.Context, *eth2api.PendingDepositsOpts) ([]*electra.PendingDeposit, error) } func (m Mock) UpdateCacheIndices(ctx context.Context, idxs []eth2p0.ValidatorIndex) { @@ -355,6 +357,15 @@ func (m Mock) Validators(ctx context.Context, opts *eth2api.ValidatorsOpts) (*et return wrapResponse(vals), nil } +func (m Mock) PendingDeposits(ctx context.Context, opts *eth2api.PendingDepositsOpts) (*eth2api.Response[[]*electra.PendingDeposit], error) { + deposits, err := m.PendingDepositsFunc(ctx, opts) + if err != nil { + return nil, err + } + + return wrapResponse(deposits), nil +} + func (Mock) SetValidatorCache(func(context.Context) (eth2wrap.ActiveValidators, eth2wrap.CompleteValidators, error)) { // Ignore this, only rely on WithValidator functional option. } diff --git a/testutil/beaconmock/options.go b/testutil/beaconmock/options.go index b6af40334c..a315de79b0 100644 --- a/testutil/beaconmock/options.go +++ b/testutil/beaconmock/options.go @@ -798,6 +798,9 @@ func defaultMock(httpMock HTTPMock, httpServer *http.Server, clock clockwork.Clo return res, nil }, + PendingDepositsFunc: func(context.Context, *eth2api.PendingDepositsOpts) ([]*electra.PendingDeposit, error) { + return []*electra.PendingDeposit{}, nil + }, } } diff --git a/testutil/fuzz.go b/testutil/fuzz.go index 37563f038a..4e02009749 100644 --- a/testutil/fuzz.go +++ b/testutil/fuzz.go @@ -133,9 +133,9 @@ func NewEth2Fuzzer(t *testing.T, seed int64) *fuzz.Fuzzer { }, // Populate one of the versions of these VersionedSignedProposal types. func(e *core.VersionedSignedProposal, c fuzz.Continue) { - e.Version = allVersions[(c.Intn(len(allVersions)))] + e.Version = allVersions[c.Intn(len(allVersions))] if e.Blinded { - e.Version = blindedVersions[(c.Intn(len(blindedVersions)))] + e.Version = blindedVersions[c.Intn(len(blindedVersions))] } version, err := eth2util.DataVersionFromETH2(e.Version) @@ -270,7 +270,7 @@ func NewEth2Fuzzer(t *testing.T, seed int64) *fuzz.Fuzzer { } }, func(e *core.VersionedProposal, c fuzz.Continue) { - e.Version = allVersions[(c.Intn(len(allVersions)))] + e.Version = allVersions[c.Intn(len(allVersions))] version, err := eth2util.DataVersionFromETH2(e.Version) require.NoError(t, err) @@ -353,7 +353,7 @@ func NewEth2Fuzzer(t *testing.T, seed int64) *fuzz.Fuzzer { } }, func(e *core.VersionedAttestation, c fuzz.Continue) { - e.Version = allVersions[(c.Intn(len(allVersions)))] + e.Version = allVersions[c.Intn(len(allVersions))] version, err := eth2util.DataVersionFromETH2(e.Version) require.NoError(t, err) @@ -383,7 +383,7 @@ func NewEth2Fuzzer(t *testing.T, seed int64) *fuzz.Fuzzer { e.CommitteeBits = bits }, func(e *core.VersionedAggregatedAttestation, c fuzz.Continue) { - e.Version = allVersions[(c.Intn(len(allVersions)))] + e.Version = allVersions[c.Intn(len(allVersions))] version, err := eth2util.DataVersionFromETH2(e.Version) require.NoError(t, err) @@ -391,7 +391,7 @@ func NewEth2Fuzzer(t *testing.T, seed int64) *fuzz.Fuzzer { c.Fuzz(val) }, func(e *core.VersionedSignedAggregateAndProof, c fuzz.Continue) { - e.Version = allVersions[(c.Intn(len(allVersions)))] + e.Version = allVersions[c.Intn(len(allVersions))] version, err := eth2util.DataVersionFromETH2(e.Version) require.NoError(t, err) diff --git a/testutil/integration/infosync_test.go b/testutil/integration/infosync_test.go index 1de4efe7f3..0ecb28280d 100644 --- a/testutil/integration/infosync_test.go +++ b/testutil/integration/infosync_test.go @@ -39,8 +39,8 @@ func TestInfoSync(t *testing.T) { lock, p2pKeys, _ := cluster.NewForT(t, 1, n, n, seed, random) asserter := &priorityAsserter{ - asserter: asserter{Timeout: time.Second * 10}, - N: n, + Timeout: time.Second * 10, + N: n, } p2pNodeCallback := testutil.NewP2PNodeCallback(t, priority.Protocols()...) diff --git a/testutil/integration/ping_test.go b/testutil/integration/ping_test.go index 902a1df584..22c10b743a 100644 --- a/testutil/integration/ping_test.go +++ b/testutil/integration/ping_test.go @@ -85,11 +85,9 @@ func pingCluster(t *testing.T, test pingTest) { random := rand.New(rand.NewSource(int64(seed))) lock, p2pKeys, _ := cluster.NewForT(t, 1, n, n, seed, random) asserter := &pingAsserter{ - asserter: asserter{ - Timeout: time.Second * 10, - }, - N: n, - Lock: lock, + Timeout: time.Second * 10, + N: n, + Lock: lock, } var eg errgroup.Group diff --git a/testutil/obolapimock/deposit.go b/testutil/obolapimock/deposit.go index e45389d25a..057ea3dc46 100644 --- a/testutil/obolapimock/deposit.go +++ b/testutil/obolapimock/deposit.go @@ -78,6 +78,11 @@ func (ts *testServer) HandleSubmitPartialDeposit(writer http.ResponseWriter, req } for _, depositData := range data.PartialDepositData { + if len(depositData.WithdrawalCredentials) != 32 { + writeErr(writer, http.StatusBadRequest, "invalid withdrawal credentials length") + return + } + signedDepositsRoot, err := deposit.GetMessageSigningRoot(eth2p0.DepositMessage{ PublicKey: depositData.PublicKey, WithdrawalCredentials: depositData.WithdrawalCredentials, @@ -140,11 +145,9 @@ func (ts *testServer) HandleSubmitPartialDeposit(writer http.ResponseWriter, req } ts.partialDeposits[depositData.PublicKey.String()] = depositBlob{ - FullDepositResponse: obolapi.FullDepositResponse{ - PublicKey: depositData.PublicKey.String(), - WithdrawalCredentials: hex.EncodeToString(depositData.WithdrawalCredentials), - Amounts: amounts, - }, + PublicKey: depositData.PublicKey.String(), + WithdrawalCredentials: hex.EncodeToString(depositData.WithdrawalCredentials), + Amounts: amounts, } } diff --git a/testutil/random.go b/testutil/random.go index 1769d72918..a1a911f2c6 100644 --- a/testutil/random.go +++ b/testutil/random.go @@ -4,13 +4,16 @@ package testutil import ( + "context" "crypto/ecdsa" crand "crypto/rand" "fmt" "math" "math/rand" "net" + "slices" "strings" + "sync" "testing" "time" @@ -34,11 +37,15 @@ import ( "github.com/libp2p/go-libp2p" p2pcrypto "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/protocol" quic "github.com/libp2p/go-libp2p/p2p/transport/quic" //nolint:revive // Must be imported with alias "github.com/libp2p/go-libp2p/p2p/transport/tcp" "github.com/multiformats/go-multiaddr" "github.com/stretchr/testify/require" + "github.com/obolnetwork/charon/app/errors" "github.com/obolnetwork/charon/core" "github.com/obolnetwork/charon/eth2util" "github.com/obolnetwork/charon/eth2util/enr" @@ -144,11 +151,9 @@ func RandomDenebCoreVersionedAttestation() core.VersionedAttestation { vIdx := eth2p0.ValidatorIndex(0) return core.VersionedAttestation{ - VersionedAttestation: eth2spec.VersionedAttestation{ - Version: eth2spec.DataVersionDeneb, - ValidatorIndex: &vIdx, - Deneb: RandomPhase0Attestation(), - }, + Version: eth2spec.DataVersionDeneb, + ValidatorIndex: &vIdx, + Deneb: RandomPhase0Attestation(), } } @@ -156,11 +161,9 @@ func RandomElectraCoreVersionedAttestation() core.VersionedAttestation { vIdx := RandomVIdx() return core.VersionedAttestation{ - VersionedAttestation: eth2spec.VersionedAttestation{ - Version: eth2spec.DataVersionElectra, - ValidatorIndex: &vIdx, - Electra: RandomElectraAttestation(), - }, + Version: eth2spec.DataVersionElectra, + ValidatorIndex: &vIdx, + Electra: RandomElectraAttestation(), } } @@ -168,11 +171,9 @@ func RandomFuluCoreVersionedAttestation() core.VersionedAttestation { vIdx := RandomVIdx() return core.VersionedAttestation{ - VersionedAttestation: eth2spec.VersionedAttestation{ - Version: eth2spec.DataVersionFulu, - ValidatorIndex: &vIdx, - Fulu: RandomElectraAttestation(), - }, + Version: eth2spec.DataVersionFulu, + ValidatorIndex: &vIdx, + Fulu: RandomElectraAttestation(), } } @@ -207,13 +208,11 @@ func RandomAggregateAttestation() *eth2p0.Attestation { func RandomDenebCoreVersionedAggregateAttestation() core.VersionedAggregatedAttestation { return core.VersionedAggregatedAttestation{ - VersionedAttestation: eth2spec.VersionedAttestation{ - Version: eth2spec.DataVersionDeneb, - Deneb: ð2p0.Attestation{ - AggregationBits: RandomBitList(64), - Data: RandomAttestationDataPhase0(), - Signature: RandomEth2Signature(), - }, + Version: eth2spec.DataVersionDeneb, + Deneb: ð2p0.Attestation{ + AggregationBits: RandomBitList(64), + Data: RandomAttestationDataPhase0(), + Signature: RandomEth2Signature(), }, } } @@ -380,90 +379,76 @@ func RandomCapellaExecutionPayload() *capella.ExecutionPayload { func RandomBellatrixCoreVersionedProposal() core.VersionedProposal { return core.VersionedProposal{ - VersionedProposal: eth2api.VersionedProposal{ - Version: eth2spec.DataVersionBellatrix, - Bellatrix: RandomBellatrixBeaconBlock(), - }, + Version: eth2spec.DataVersionBellatrix, + Bellatrix: RandomBellatrixBeaconBlock(), } } func RandomCapellaCoreVersionedProposal() core.VersionedProposal { return core.VersionedProposal{ - VersionedProposal: eth2api.VersionedProposal{ - Version: eth2spec.DataVersionCapella, - Capella: RandomCapellaBeaconBlock(), - }, + Version: eth2spec.DataVersionCapella, + Capella: RandomCapellaBeaconBlock(), } } func RandomBellatrixCoreVersionedSignedProposal() core.VersionedSignedProposal { return core.VersionedSignedProposal{ - VersionedSignedProposal: eth2api.VersionedSignedProposal{ - Version: eth2spec.DataVersionBellatrix, - Bellatrix: &bellatrix.SignedBeaconBlock{ - Message: RandomBellatrixBeaconBlock(), - Signature: RandomEth2Signature(), - }, + Version: eth2spec.DataVersionBellatrix, + Bellatrix: &bellatrix.SignedBeaconBlock{ + Message: RandomBellatrixBeaconBlock(), + Signature: RandomEth2Signature(), }, } } func RandomCapellaCoreVersionedSignedProposal() core.VersionedSignedProposal { return core.VersionedSignedProposal{ - VersionedSignedProposal: eth2api.VersionedSignedProposal{ - Version: eth2spec.DataVersionCapella, - Capella: &capella.SignedBeaconBlock{ - Message: RandomCapellaBeaconBlock(), - Signature: RandomEth2Signature(), - }, + Version: eth2spec.DataVersionCapella, + Capella: &capella.SignedBeaconBlock{ + Message: RandomCapellaBeaconBlock(), + Signature: RandomEth2Signature(), }, } } func RandomDenebCoreVersionedSignedProposal() core.VersionedSignedProposal { return core.VersionedSignedProposal{ - VersionedSignedProposal: eth2api.VersionedSignedProposal{ - Version: eth2spec.DataVersionDeneb, - Deneb: ð2deneb.SignedBlockContents{ - SignedBlock: &deneb.SignedBeaconBlock{ - Message: RandomDenebBeaconBlock(), - Signature: RandomEth2Signature(), - }, - KZGProofs: []deneb.KZGProof{}, - Blobs: []deneb.Blob{}, + Version: eth2spec.DataVersionDeneb, + Deneb: ð2deneb.SignedBlockContents{ + SignedBlock: &deneb.SignedBeaconBlock{ + Message: RandomDenebBeaconBlock(), + Signature: RandomEth2Signature(), }, + KZGProofs: []deneb.KZGProof{}, + Blobs: []deneb.Blob{}, }, } } func RandomElectraCoreVersionedSignedProposal() core.VersionedSignedProposal { return core.VersionedSignedProposal{ - VersionedSignedProposal: eth2api.VersionedSignedProposal{ - Version: eth2spec.DataVersionElectra, - Electra: ð2electra.SignedBlockContents{ - SignedBlock: &electra.SignedBeaconBlock{ - Message: RandomElectraBeaconBlock(), - Signature: RandomEth2Signature(), - }, - KZGProofs: []deneb.KZGProof{}, - Blobs: []deneb.Blob{}, + Version: eth2spec.DataVersionElectra, + Electra: ð2electra.SignedBlockContents{ + SignedBlock: &electra.SignedBeaconBlock{ + Message: RandomElectraBeaconBlock(), + Signature: RandomEth2Signature(), }, + KZGProofs: []deneb.KZGProof{}, + Blobs: []deneb.Blob{}, }, } } func RandomFuluCoreVersionedSignedProposal() core.VersionedSignedProposal { return core.VersionedSignedProposal{ - VersionedSignedProposal: eth2api.VersionedSignedProposal{ - Version: eth2spec.DataVersionFulu, - Fulu: ð2fulu.SignedBlockContents{ - SignedBlock: &electra.SignedBeaconBlock{ - Message: RandomElectraBeaconBlock(), - Signature: RandomEth2Signature(), - }, - KZGProofs: []deneb.KZGProof{}, - Blobs: []deneb.Blob{}, + Version: eth2spec.DataVersionFulu, + Fulu: ð2fulu.SignedBlockContents{ + SignedBlock: &electra.SignedBeaconBlock{ + Message: RandomElectraBeaconBlock(), + Signature: RandomEth2Signature(), }, + KZGProofs: []deneb.KZGProof{}, + Blobs: []deneb.Blob{}, }, } } @@ -673,85 +658,71 @@ func RandomCapellaBlindedBeaconBlockBody() *eth2capella.BlindedBeaconBlockBody { func RandomBellatrixVersionedBlindedProposal() core.VersionedProposal { return core.VersionedProposal{ - VersionedProposal: eth2api.VersionedProposal{ - Version: eth2spec.DataVersionBellatrix, - Blinded: true, - BellatrixBlinded: RandomBellatrixBlindedBeaconBlock(), - }, + Version: eth2spec.DataVersionBellatrix, + Blinded: true, + BellatrixBlinded: RandomBellatrixBlindedBeaconBlock(), } } func RandomCapellaVersionedBlindedProposal() core.VersionedProposal { return core.VersionedProposal{ - VersionedProposal: eth2api.VersionedProposal{ - Version: eth2spec.DataVersionCapella, - Blinded: true, - CapellaBlinded: RandomCapellaBlindedBeaconBlock(), - }, + Version: eth2spec.DataVersionCapella, + Blinded: true, + CapellaBlinded: RandomCapellaBlindedBeaconBlock(), } } func RandomBellatrixVersionedSignedBlindedProposal() core.VersionedSignedProposal { return core.VersionedSignedProposal{ - VersionedSignedProposal: eth2api.VersionedSignedProposal{ - Blinded: true, - Version: eth2spec.DataVersionBellatrix, - BellatrixBlinded: ð2bellatrix.SignedBlindedBeaconBlock{ - Message: RandomBellatrixBlindedBeaconBlock(), - Signature: RandomEth2Signature(), - }, + Blinded: true, + Version: eth2spec.DataVersionBellatrix, + BellatrixBlinded: ð2bellatrix.SignedBlindedBeaconBlock{ + Message: RandomBellatrixBlindedBeaconBlock(), + Signature: RandomEth2Signature(), }, } } func RandomCapellaVersionedSignedBlindedProposal() core.VersionedSignedProposal { return core.VersionedSignedProposal{ - VersionedSignedProposal: eth2api.VersionedSignedProposal{ - Blinded: true, - Version: eth2spec.DataVersionCapella, - CapellaBlinded: ð2capella.SignedBlindedBeaconBlock{ - Message: RandomCapellaBlindedBeaconBlock(), - Signature: RandomEth2Signature(), - }, + Blinded: true, + Version: eth2spec.DataVersionCapella, + CapellaBlinded: ð2capella.SignedBlindedBeaconBlock{ + Message: RandomCapellaBlindedBeaconBlock(), + Signature: RandomEth2Signature(), }, } } func RandomDenebVersionedSignedBlindedProposal() core.VersionedSignedProposal { return core.VersionedSignedProposal{ - VersionedSignedProposal: eth2api.VersionedSignedProposal{ - Blinded: true, - Version: eth2spec.DataVersionDeneb, - DenebBlinded: ð2deneb.SignedBlindedBeaconBlock{ - Message: RandomDenebBlindedBeaconBlock(), - Signature: RandomEth2Signature(), - }, + Blinded: true, + Version: eth2spec.DataVersionDeneb, + DenebBlinded: ð2deneb.SignedBlindedBeaconBlock{ + Message: RandomDenebBlindedBeaconBlock(), + Signature: RandomEth2Signature(), }, } } func RandomElectraVersionedSignedBlindedProposal() core.VersionedSignedProposal { return core.VersionedSignedProposal{ - VersionedSignedProposal: eth2api.VersionedSignedProposal{ - Blinded: true, - Version: eth2spec.DataVersionElectra, - ElectraBlinded: ð2electra.SignedBlindedBeaconBlock{ - Message: RandomElectraBlindedBeaconBlock(), - Signature: RandomEth2Signature(), - }, + Blinded: true, + Version: eth2spec.DataVersionElectra, + ElectraBlinded: ð2electra.SignedBlindedBeaconBlock{ + Message: RandomElectraBlindedBeaconBlock(), + Signature: RandomEth2Signature(), }, } } func RandomFuluVersionedSignedBlindedProposal() core.VersionedSignedProposal { return core.VersionedSignedProposal{ - VersionedSignedProposal: eth2api.VersionedSignedProposal{ - Blinded: true, - Version: eth2spec.DataVersionFulu, - FuluBlinded: ð2electra.SignedBlindedBeaconBlock{ - Message: RandomElectraBlindedBeaconBlock(), - Signature: RandomEth2Signature(), - }, + Blinded: true, + Version: eth2spec.DataVersionFulu, + FuluBlinded: ð2electra.SignedBlindedBeaconBlock{ + Message: RandomElectraBlindedBeaconBlock(), + Signature: RandomEth2Signature(), }, } } @@ -918,10 +889,8 @@ func RandomCoreVersionedSignedValidatorRegistration(t *testing.T) core.Versioned t.Helper() return core.VersionedSignedValidatorRegistration{ - VersionedSignedValidatorRegistration: eth2api.VersionedSignedValidatorRegistration{ - Version: eth2spec.BuilderVersionV1, - V1: RandomSignedValidatorRegistration(t), - }, + Version: eth2spec.BuilderVersionV1, + V1: RandomSignedValidatorRegistration(t), } } @@ -1565,6 +1534,56 @@ func CreateHost(t *testing.T, addr *net.TCPAddr, opts ...libp2p.Option) host.Hos return CreateHostWithIdentity(t, addr, pkey, opts...) } +// NewFlakyHost wraps a host, failing the first `failures` outbound NewStream calls +// with a transient error. If protocols are provided, only NewStream calls for those +// protocols fail; other calls pass through. It is used to test p2p send retry behavior. +func NewFlakyHost(h host.Host, failures int, protocols ...protocol.ID) *FlakyHost { + return &FlakyHost{Host: h, failures: failures, protocols: protocols} +} + +// FlakyHost is a host whose first outbound NewStream calls fail with a transient error. +type FlakyHost struct { + host.Host + + protocols []protocol.ID + + mu sync.Mutex + failures int + calls int +} + +func (h *FlakyHost) NewStream(ctx context.Context, peerID peer.ID, pIDs ...protocol.ID) (network.Stream, error) { + match := len(h.protocols) == 0 + for _, pID := range pIDs { + if slices.Contains(h.protocols, pID) { + match = true + } + } + + h.mu.Lock() + h.calls++ + fail := match && h.failures > 0 + + if fail { + h.failures-- + } + h.mu.Unlock() + + if fail { + return nil, errors.New("transient stream failure") + } + + return h.Host.NewStream(ctx, peerID, pIDs...) +} + +// Calls returns the number of NewStream calls made on the host. +func (h *FlakyHost) Calls() int { + h.mu.Lock() + defer h.mu.Unlock() + + return h.calls +} + func CreateHostWithIdentity(t *testing.T, addr *net.TCPAddr, secret *k1.PrivateKey, opts ...libp2p.Option) host.Host { t.Helper()