Repository: ObolNetwork/charon

Issue:
cluster-lock merge script
# 🎯 Problem to be solved

We want to make it easier to add new keys to an existing cluster. 

# 🛠️ Proposed solution
- [ ] Approved design doc: *link*
- [ ] Core team consensus on the proposed solution

Ideal add validators is a longer workstream that may have an onchain component. This is an idea for solo or quite centralised setups. 

Create a `./cluster_merge.sh` script, that takes two full `cluster/` folders, and merges one into the other. This command should take the `distributed_validators` object from the second cluster, and inject it into the former, and should copy the keystores into the appropriate cluster1/node*/ subfolders. The command should tell the user to add `CHARON_NO_VERIFY=true` or `--no-verify=true` to their charon run command, along with warning the user to take caution around getting slashed. 


# 🧪 Tests
- [ ] Tested by new automated unit/integration/smoke tests
- [ ] Manually tested on core team/canary/test clusters
- [ ] Manually tested on local compose simnet

<!-- Optional sections

# 👐 Additional acceptance criteria
List any additional acceptance criteria for this issue to be marked as closed.

# ❌ Out of Scope
If there is anything to highlight as out of scope for this issue, please outline it here.

-->


Hints from issue comments:
@OisinKyne: Worth noting that lido want fresh keys to be DKG'd, but existing keys to be split. We'll need to gracefully handle both cases in this script

Solution patch:
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 1c0d57c8..539739a0 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -19,17 +19,4 @@ jobs:
       - name: golangci-lint
         uses: golangci/golangci-lint-action@v8
         with:
-          version: v2.2.1
-      - name: notify failure
-        if: failure() && github.ref == 'refs/heads/main'
-        env:
-          DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
-          DISCORD_USERNAME: GitHub
-          DISCORD_AVATAR: https://avatars.githubusercontent.com/u/583231
-          DISCORD_EMBEDS: |
-            [{
-              "title": "🚨  Main branch workflow failed: ${{ github.workflow }}",
-              "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
-              "color": 10038562
-            }]
-        uses: Ilshidur/action-discord@0.3.2
+          version: v2.3.1
diff --git a/.github/workflows/nightly-tests.yml b/.github/workflows/nightly-tests.yml
index 8e39ba1c..540cdedb 100644
--- a/.github/workflows/nightly-tests.yml
+++ b/.github/workflows/nightly-tests.yml
@@ -2,7 +2,7 @@
 name: nightly tests
 on:
   schedule:
-    - cron: '00 12 * * *'
+    - cron: "00 12 * * *"
 
 jobs:
   nightly_test:
@@ -49,22 +49,3 @@ jobs:
           name: fuzz-test-logs
           path: testutil/compose/fuzz/*.log
           retention-days: 3
-
-  notify_failure:
-    runs-on: ubuntu-latest
-    needs: [ nightly_test ]
-    # Syntax ref: https://github.com/actions/runner/issues/1251
-    if: always() && github.ref == 'refs/heads/main' && contains(join(needs.*.result, ','), 'failure')
-    steps:
-      - name: notify failure
-        uses: Ilshidur/action-discord@0.3.2
-        env:
-          DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
-          DISCORD_USERNAME: GitHub
-          DISCORD_AVATAR: https://avatars.githubusercontent.com/u/583231
-          DISCORD_EMBEDS: |
-            [{
-              "title": "🚨  Nightly tests failed: ${{ github.workflow }}",
-              "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
-              "color": 10038562
-            }]
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
index a078d1c4..7774419c 100644
--- a/.github/workflows/pre-commit.yml
+++ b/.github/workflows/pre-commit.yml
@@ -16,17 +16,3 @@ jobs:
       - uses: actions/setup-python@v5
       - uses: ./.github/actions/setup-go
       - uses: pre-commit/action@v3.0.1
-
-      - name: notify failure
-        if: failure() && github.ref == 'refs/heads/main'
-        env:
-          DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
-          DISCORD_USERNAME: GitHub
-          DISCORD_AVATAR: https://avatars.githubusercontent.com/u/583231
-          DISCORD_EMBEDS: |
-            [{
-              "title": "🚨  Main branch workflow failed: ${{ github.workflow }}",
-              "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
-              "color": 10038562
-            }]
-        uses: Ilshidur/action-discord@0.3.2
diff --git a/.pre-commit/run_linter.sh b/.pre-commit/run_linter.sh
index 55addb7b..2e3628ed 100755
--- a/.pre-commit/run_linter.sh
+++ b/.pre-commit/run_linter.sh
@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 
-VERSION="2.2.1"
+VERSION="v2.3.1"
 
 if ! command -v golangci-lint &>/dev/null; then
     echo "golangci-lint could not be found"
diff --git a/app/app.go b/app/app.go
index 47865e4c..f242f6df 100644
--- a/app/app.go
+++ b/app/app.go
@@ -129,8 +129,8 @@ type TestConfig struct {
 	BroadcastCallback func(context.Context, core.Duty, core.SignedDataSet) error
 	// PrioritiseCallback is called with priority protocol results.
 	PrioritiseCallback func(context.Context, core.Duty, []priority.TopicResult) error
-	// TCPNodeCallback provides test logic access to the libp2p host.
-	TCPNodeCallback func(host.Host)
+	// P2PNodeCallback provides test logic access to the libp2p host.
+	P2PNodeCallback func(host.Host)
 	// LibP2POpts provide test specific libp2p options.
 	LibP2POpts []libp2p.Option
 	// P2PFuzz enables peer to peer fuzzing of charon nodes in a cluster.
@@ -221,12 +221,12 @@ func Run(ctx context.Context, conf Config) (err error) {
 
 	lockHashHex := hex7(cluster.GetInitialMutationHash())
 
-	tcpNode, err := wireP2P(ctx, life, conf, cluster, p2pKey, lockHashHex)
+	p2pNode, err := wireP2P(ctx, life, conf, cluster, p2pKey, lockHashHex)
 	if err != nil {
 		return err
 	}
 
-	nodeIdx, err := manifest.ClusterNodeIdx(cluster, tcpNode.ID())
+	nodeIdx, err := manifest.ClusterNodeIdx(cluster, p2pNode.ID())
 	if err != nil {
 		return errors.Wrap(err, "private key not matching cluster manifest file")
 	}
@@ -237,7 +237,7 @@ func Run(ctx context.Context, conf Config) (err error) {
 	}
 
 	log.Info(ctx, "Lock file loaded",
-		z.Str("peer_name", p2p.PeerName(tcpNode.ID())),
+		z.Str("peer_name", p2p.PeerName(p2pNode.ID())),
 		z.Str("nickname", conf.Nickname),
 		z.Int("peer_index", nodeIdx.PeerIdx),
 		z.Str("cluster_name", cluster.GetName()),
@@ -250,7 +250,7 @@ func Run(ctx context.Context, conf Config) (err error) {
 	labels := map[string]string{
 		"cluster_hash":    lockHashHex,
 		"cluster_name":    cluster.GetName(),
-		"cluster_peer":    p2p.PeerName(tcpNode.ID()),
+		"cluster_peer":    p2p.PeerName(p2pNode.ID()),
 		"nickname":        conf.Nickname,
 		"cluster_network": network,
 		"charon_version":  version.Version.String(),
@@ -262,7 +262,7 @@ func Run(ctx context.Context, conf Config) (err error) {
 		return err
 	}
 
-	initStartupMetrics(p2p.PeerName(tcpNode.ID()), int(cluster.GetThreshold()), len(cluster.GetOperators()), len(cluster.GetValidators()), network)
+	initStartupMetrics(p2p.PeerName(p2pNode.ID()), int(cluster.GetThreshold()), len(cluster.GetOperators()), len(cluster.GetValidators()), network)
 
 	eth2Cl, subEth2Cl, err := newETH2Client(ctx, conf, life, cluster, cluster.GetForkVersion(), conf.BeaconNodeTimeout, conf.BeaconNodeSubmitTimeout)
 	if err != nil {
@@ -290,7 +290,7 @@ func Run(ctx context.Context, conf Config) (err error) {
 		return errors.New("nickname can not exceed 32 characters")
 	}
 
-	wirePeerInfo(life, tcpNode, peerIDs, cluster.GetInitialMutationHash(), sender, conf.BuilderAPI, conf.Nickname)
+	wirePeerInfo(life, p2pNode, peerIDs, cluster.GetInitialMutationHash(), sender, conf.BuilderAPI, conf.Nickname)
 
 	// seenPubkeys channel to send seen public keys from validatorapi to monitoringapi.
 	seenPubkeys := make(chan core.PubKey)
@@ -316,10 +316,10 @@ func Run(ctx context.Context, conf Config) (err error) {
 
 	consensusDebugger := consensus.NewDebugger()
 
-	wireMonitoringAPI(ctx, life, conf.MonitoringAddr, conf.DebugAddr, tcpNode, eth2Cl, peerIDs,
+	wireMonitoringAPI(ctx, life, conf.MonitoringAddr, conf.DebugAddr, p2pNode, eth2Cl, peerIDs,
 		promRegistry, consensusDebugger, pubkeys, seenPubkeys, vapiCalls, len(cluster.GetValidators()))
 
-	err = wireCoreWorkflow(ctx, life, conf, cluster, nodeIdx, tcpNode, p2pKey, eth2Cl, subEth2Cl,
+	err = wireCoreWorkflow(ctx, life, conf, cluster, nodeIdx, p2pNode, p2pKey, eth2Cl, subEth2Cl,
 		peerIDs, sender, consensusDebugger, pubkeys, seenPubkeysFunc, sseListener, vapiCallsFunc)
 	if err != nil {
 		return err
@@ -330,13 +330,13 @@ func Run(ctx context.Context, conf Config) (err error) {
 }
 
 // wirePeerInfo wires the peerinfo protocol.
-func wirePeerInfo(life *lifecycle.Manager, tcpNode host.Host, peers []peer.ID, lockHash []byte, sender *p2p.Sender, builderEnabled bool, nickname string) {
+func wirePeerInfo(life *lifecycle.Manager, p2pNode host.Host, peers []peer.ID, lockHash []byte, sender *p2p.Sender, builderEnabled bool, nickname string) {
 	gitHash, _ := version.GitCommit()
-	peerInfo := peerinfo.New(tcpNode, peers, version.Version, lockHash, gitHash, sender.SendReceive, builderEnabled, nickname)
+	peerInfo := peerinfo.New(p2pNode, peers, version.Version, lockHash, gitHash, sender.SendReceive, builderEnabled, nickname)
 	life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartPeerInfo, lifecycle.HookFuncCtx(peerInfo.Run))
 }
 
-// wireP2P constructs the p2p tcp (libp2p) and udp (discv5) nodes and registers it with the life cycle manager.
+// wireP2P constructs the p2p tcp or udp (libp2p) nodes and registers it with the life cycle manager.
 func wireP2P(ctx context.Context, life *lifecycle.Manager, conf Config,
 	cluster *manifestpb.Cluster, p2pKey *k1.PrivateKey, lockHashHex string,
 ) (host.Host, error) {
@@ -355,42 +355,48 @@ func wireP2P(ctx context.Context, life *lifecycle.Manager, conf Config,
 		return nil, err
 	}
 
-	// Start libp2p TCP node.
+	// Start libp2p node.
 	opts := []libp2p.Option{
 		p2p.WithBandwidthReporter(peerIDs),
 		libp2p.ResourceManager(new(network.NullResourceManager)),
 	}
 	opts = append(opts, conf.TestConfig.LibP2POpts...)
 
-	tcpNode, err := p2p.NewTCPNode(ctx, conf.P2P, p2pKey, connGater,
-		false, opts...)
+	var p2pNode host.Host
+	if featureset.Enabled(featureset.QUIC) {
+		p2pNode, err = p2p.NewNode(ctx, conf.P2P, p2pKey, connGater, false, p2p.NodeTypeQUIC, opts...)
+	} else {
+		p2pNode, err = p2p.NewNode(ctx, conf.P2P, p2pKey, connGater, false, p2p.NodeTypeTCP, opts...)
+	}
+
 	if err != nil {
 		return nil, err
 	}
 
-	if conf.TestConfig.TCPNodeCallback != nil {
-		conf.TestConfig.TCPNodeCallback(tcpNode)
+	if conf.TestConfig.P2PNodeCallback != nil {
+		conf.TestConfig.P2PNodeCallback(p2pNode)
 	}
 
-	p2p.RegisterConnectionLogger(ctx, tcpNode, peerIDs)
+	p2p.RegisterConnectionLogger(ctx, p2pNode, peerIDs)
 
-	life.RegisterStop(lifecycle.StopP2PTCPNode, lifecycle.HookFuncErr(tcpNode.Close))
+	life.RegisterStop(lifecycle.StopP2PNode, lifecycle.HookFuncErr(p2pNode.Close))
 
 	for _, relay := range relays {
-		life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartRelay, p2p.NewRelayReserver(tcpNode, relay))
+		life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartRelay, p2p.NewRelayReserver(p2pNode, relay))
 	}
 
-	life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartP2PPing, p2p.NewPingService(tcpNode, peerIDs, conf.TestConfig.TestPingConfig))
-	life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartP2PEventCollector, p2p.NewEventCollector(tcpNode))
-	life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartP2PRouters, p2p.NewRelayRouter(tcpNode, peerIDs, relays))
-	life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartForceDirectConns, p2p.ForceDirectConnections(tcpNode, peerIDs))
+	life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartP2PPing, p2p.NewPingService(p2pNode, peerIDs, conf.TestConfig.TestPingConfig))
+	life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartP2PEventCollector, p2p.NewEventCollector(p2pNode))
+	life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartP2PRouters, p2p.NewRelayRouter(p2pNode, peerIDs, relays))
+	life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartForceDirectConns, p2p.ForceDirectConnections(p2pNode, peerIDs))
+	life.RegisterStart(lifecycle.AsyncAppCtx, lifecycle.StartForceQUICConns, p2p.UpgradeToQUICConnections(p2pNode, peerIDs))
 
-	return tcpNode, nil
+	return p2pNode, nil
 }
 
 // wireCoreWorkflow wires the core workflow components.
 func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config,
-	cluster *manifestpb.Cluster, nodeIdx cluster.NodeIdx, tcpNode host.Host, p2pKey *k1.PrivateKey,
+	cluster *manifestpb.Cluster, nodeIdx cluster.NodeIdx, p2pNode host.Host, p2pKey *k1.PrivateKey,
 	eth2Cl, submissionEth2Cl eth2wrap.Client, peerIDs []peer.ID, sender *p2p.Sender,
 	consensusDebugger consensus.Debugger, pubkeys []core.PubKey, seenPubkeys func(core.PubKey),
 	sseListener sse.Listener, vapiCalls func(),
@@ -572,7 +578,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config,
 			return err
 		}
 
-		parSigEx = parsigex.NewParSigEx(tcpNode, sender.SendAsync, nodeIdx.PeerIdx, peerIDs, verifyFunc, gaterFunc)
+		parSigEx = parsigex.NewParSigEx(p2pNode, sender.SendAsync, nodeIdx.PeerIdx, peerIDs, verifyFunc, gaterFunc)
 	}
 
 	sigAgg, err := sigagg.New(int(cluster.GetThreshold()), sigagg.NewVerifier(eth2Cl))
@@ -598,7 +604,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config,
 
 	// Consensus
 	consensusController, err := consensus.NewConsensusController(
-		ctx, tcpNode, sender, peers, p2pKey,
+		ctx, p2pNode, sender, peers, p2pKey,
 		deadlineFunc, gaterFunc, consensusDebugger)
 	if err != nil {
 		return err
@@ -610,7 +616,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config,
 	coreConsensus := consensusController.CurrentConsensus() // initially points to DefaultConsensus()
 
 	// Priority protocol always uses QBFTv2.
-	err = wirePrioritise(ctx, conf, life, tcpNode, peerIDs, int(cluster.GetThreshold()),
+	err = wirePrioritise(ctx, conf, life, p2pNode, peerIDs, int(cluster.GetThreshold()),
 		sender.SendReceive, defaultConsensus, sched, p2pKey, deadlineFunc,
 		consensusController, cluster.GetConsensusProtocol())
 	if err != nil {
@@ -662,7 +668,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config,
 }
 
 // wirePrioritise wires the priority protocol which determines cluster wide priorities for the next epoch.
-func wirePrioritise(ctx context.Context, conf Config, life *lifecycle.Manager, tcpNode host.Host,
+func wirePrioritise(ctx context.Context, conf Config, life *lifecycle.Manager, p2pNode host.Host,
 	peers []peer.ID, threshold int, sendFunc p2p.SendReceiveFunc, coreCons core.Consensus,
 	sched core.Scheduler, p2pKey *k1.PrivateKey, deadlineFunc func(duty core.Duty) (time.Time, bool),
 	consensusController core.ConsensusController, clusterPreferredProtocol string,
@@ -677,7 +683,7 @@ func wirePrioritise(ctx context.Context, conf Config, life *lifecycle.Manager, t
 	// It is long enough for all peers to exchange proposals both in prod and in testing.
 	const exchangeTimeout = time.Second * 6
 
-	prio, err := priority.NewComponent(ctx, tcpNode, peers, threshold,
+	prio, err := priority.NewComponent(ctx, p2pNode, peers, threshold,
 		sendFunc, p2p.RegisterHandler, cons, exchangeTimeout, p2pKey, deadlineFunc)
 	if err != nil {
 		return err
@@ -905,7 +911,7 @@ func newETH2Client(ctx context.Context, conf Config, life *lifecycle.Manager, cl
 	if conf.SimnetBMockFuzz {
 		log.Info(ctx, "Beaconmock fuzz configured!")
 
-		bmock, err := beaconmock.New(beaconmock.WithBeaconMockFuzzer(), beaconmock.WithForkVersion([4]byte(forkVersion)))
+		bmock, err := beaconmock.New(ctx, beaconmock.WithBeaconMockFuzzer(), beaconmock.WithForkVersion([4]byte(forkVersion)))
 		if err != nil {
 			return nil, nil, err
 		}
@@ -948,7 +954,7 @@ func newETH2Client(ctx context.Context, conf Config, life *lifecycle.Manager, cl
 
 		opts = append(opts, conf.TestConfig.SimnetBMockOpts...)
 
-		bmock, err := beaconmock.New(opts...)
+		bmock, err := beaconmock.New(ctx, opts...)
 		if err != nil {
 			return nil, nil, err
 		}
diff --git a/app/eth1wrap/mocks/erc1271.go b/app/eth1wrap/mocks/erc1271.go
index 9587b7be..e57cca03 100644
--- a/app/eth1wrap/mocks/erc1271.go
+++ b/app/eth1wrap/mocks/erc1271.go
@@ -1,11 +1,11 @@
 // Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
 
-// Code generated by mockery v2.42.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 package mocks
 
 import (
-	bind "github.com/ethereum/go-ethereum/accounts/abi/bind"
+	bind "github.com/ethereum/go-ethereum/accounts/abi/bind/v2"
 
 	mock "github.com/stretchr/testify/mock"
 )
diff --git a/app/eth1wrap/mocks/eth_client.go b/app/eth1wrap/mocks/eth_client.go
index 46efdd00..e575b62f 100644
--- a/app/eth1wrap/mocks/eth_client.go
+++ b/app/eth1wrap/mocks/eth_client.go
@@ -1,6 +1,6 @@
 // Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
 
-// Code generated by mockery v2.42.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 package mocks
 
@@ -292,7 +292,7 @@ func (_m *EthClient) ChainID(ctx context.Context) (*big.Int, error) {
 	return r0, r1
 }
 
-// Client provides a mock function with given fields:
+// Client provides a mock function with no fields
 func (_m *EthClient) Client() *rpc.Client {
 	ret := _m.Called()
 
@@ -312,7 +312,7 @@ func (_m *EthClient) Client() *rpc.Client {
 	return r0
 }
 
-// Close provides a mock function with given fields:
+// Close provides a mock function with no fields
 func (_m *EthClient) Close() {
 	_m.Called()
 }
diff --git a/app/eth1wrap/mocks/eth_client_runner.go b/app/eth1wrap/mocks/eth_client_runner.go
index f87d15fc..31de82ba 100644
--- a/app/eth1wrap/mocks/eth_client_runner.go
+++ b/app/eth1wrap/mocks/eth_client_runner.go
@@ -1,6 +1,6 @@
 // Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
 
-// Code generated by mockery v2.42.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 package mocks
 
diff --git a/app/eth2wrap/eth2wrap.go b/app/eth2wrap/eth2wrap.go
index c4b2d1db..4cecb0d3 100644
--- a/app/eth2wrap/eth2wrap.go
+++ b/app/eth2wrap/eth2wrap.go
@@ -7,6 +7,7 @@ import (
 	"context"
 	"net"
 	"net/url"
+	"strings"
 	"sync"
 	"time"
 
@@ -163,13 +164,7 @@ func provide[O any](ctx context.Context, clients []Client, fallbacks []Client,
 
 	zero := func() O { var z O; return z }()
 
-	runForkJoin := func(clients []Client, isFallback bool) (O, error) {
-		if isFallback {
-			usingFallbackGauge.Set(1)
-		} else {
-			usingFallbackGauge.Set(0)
-		}
-
+	runForkJoin := func(clients []Client) (O, error) {
 		fork, join, cancel := forkjoin.New(ctx, work,
 			forkjoin.WithoutFailFast(),
 			forkjoin.WithWorkers(len(clients)),
@@ -208,13 +203,29 @@ func provide[O any](ctx context.Context, clients []Client, fallbacks []Client,
 
 		return nokResp.Output, nokResp.Err
 	}
+	output, err := runForkJoin(clients)
 
-	output, err := runForkJoin(clients, false)
-	if err == nil || ctx.Err() != nil || len(fallbacks) == 0 {
-		return output, err
+	// Call fallback nodes when request to beacon node timeout or if it's syncing
+	if err != nil && len(fallbacks) != 0 && (isTimeoutError(err) || isSyncingError(err)) {
+		usingFallbackGauge.Set(1)
+		return runForkJoin(fallbacks)
 	}
 
-	return runForkJoin(fallbacks, true)
+	usingFallbackGauge.Set(0)
+
+	return output, err
+}
+
+// isTimeoutError returns true if error message contains known strings for timeout related issues.
+func isTimeoutError(err error) bool {
+	msg := err.Error()
+	return strings.Contains(msg, "http request timeout") || strings.Contains(msg, "client is not active") || strings.Contains(msg, "context deadline exceeded")
+}
+
+// isSyncingError returns true if error message contains the word "syncing".
+func isSyncingError(err error) bool {
+	msg := err.Error()
+	return strings.Contains(msg, "syncing")
 }
 
 type empty struct{}
diff --git a/app/eth2wrap/eth2wrap_gen.go b/app/eth2wrap/eth2wrap_gen.go
index a6fb97b1..09ebd85a 100644
--- a/app/eth2wrap/eth2wrap_gen.go
+++ b/app/eth2wrap/eth2wrap_gen.go
@@ -14,19 +14,11 @@ import (
 	"github.com/attestantio/go-eth2-client/spec"
 	"github.com/attestantio/go-eth2-client/spec/altair"
 	"github.com/attestantio/go-eth2-client/spec/phase0"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
 )
 
 // Client defines all go-eth2-client interfaces used in charon.
 type Client interface {
 	eth2client.Service
-	eth2exp.BeaconCommitteeSelectionAggregator
-	eth2exp.SyncCommitteeSelectionAggregator
-	eth2exp.ProposerConfigProvider
-	BlockAttestationsProvider
-	BlockProvider
-	BeaconStateCommitteesProvider
-	NodePeerCountProvider
 
 	CachedValidatorsProvider
 	SetValidatorCache(func(context.Context) (ActiveValidators, CompleteValidators, error))
@@ -38,14 +30,18 @@ type Client interface {
 	eth2client.AttestationDataProvider
 	eth2client.AttestationsSubmitter
 	eth2client.AttesterDutiesProvider
+	eth2client.BeaconBlockAttestationsProvider
 	eth2client.BeaconBlockRootProvider
+	eth2client.BeaconCommitteeSelectionsProvider
 	eth2client.BeaconCommitteeSubscriptionsSubmitter
+	eth2client.BeaconCommitteesProvider
 	eth2client.BlindedProposalSubmitter
 	eth2client.DepositContractProvider
 	eth2client.DomainProvider
 	eth2client.ForkProvider
 	eth2client.ForkScheduleProvider
 	eth2client.GenesisProvider
+	eth2client.NodePeerCountProvider
 	eth2client.NodeSyncingProvider
 	eth2client.NodeVersionProvider
 	eth2client.ProposalPreparationsSubmitter
@@ -60,6 +56,7 @@ type Client interface {
 	eth2client.SyncCommitteeContributionsSubmitter
 	eth2client.SyncCommitteeDutiesProvider
 	eth2client.SyncCommitteeMessagesSubmitter
+	eth2client.SyncCommitteeSelectionsProvider
 	eth2client.SyncCommitteeSubscriptionsSubmitter
 	eth2client.ValidatorRegistrationsSubmitter
 	eth2client.ValidatorsProvider
@@ -135,6 +132,27 @@ func (m multi) SignedBeaconBlock(ctx context.Context, opts *api.SignedBeaconBloc
 	return res0, err
 }
 
+// BeaconCommittees fetches all beacon committees for the given options.
+func (m multi) BeaconCommittees(ctx context.Context, opts *api.BeaconCommitteesOpts) (*api.Response[[]*apiv1.BeaconCommittee], error) {
+	const label = "beacon_committees"
+	defer latency(ctx, label, false)()
+	defer incRequest(label)
+
+	res0, err := provide(ctx, m.clients, m.fallbacks,
+		func(ctx context.Context, args provideArgs) (*api.Response[[]*apiv1.BeaconCommittee], error) {
+			return args.client.BeaconCommittees(ctx, opts)
+		},
+		nil, m.selector,
+	)
+
+	if err != nil {
+		incError(label)
+		err = wrapError(ctx, err, label)
+	}
+
+	return res0, err
+}
+
 // AggregateAttestation fetches the aggregate attestation for the given options.
 func (m multi) AggregateAttestation(ctx context.Context, opts *api.AggregateAttestationOpts) (*api.Response[*spec.VersionedAttestation], error) {
 	const label = "aggregate_attestation"
@@ -368,6 +386,27 @@ func (m multi) SubmitSyncCommitteeContributions(ctx context.Context, contributio
 	return err
 }
 
+// SyncCommitteeSelections submits sync committee selections.
+func (m multi) SyncCommitteeSelections(ctx context.Context, opts *api.SyncCommitteeSelectionsOpts) (*api.Response[[]*apiv1.SyncCommitteeSelection], error) {
+	const label = "sync_committee_selections"
+	defer latency(ctx, label, false)()
+	defer incRequest(label)
+
+	res0, err := provide(ctx, m.clients, m.fallbacks,
+		func(ctx context.Context, args provideArgs) (*api.Response[[]*apiv1.SyncCommitteeSelection], error) {
+			return args.client.SyncCommitteeSelections(ctx, opts)
+		},
+		nil, m.selector,
+	)
+
+	if err != nil {
+		incError(label)
+		err = wrapError(ctx, err, label)
+	}
+
+	return res0, err
+}
+
 // Proposal fetches a proposal for signing.
 func (m multi) Proposal(ctx context.Context, opts *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) {
 	const label = "proposal"
@@ -411,6 +450,27 @@ func (m multi) BeaconBlockRoot(ctx context.Context, opts *api.BeaconBlockRootOpt
 	return res0, err
 }
 
+// BeaconBlockAttestations fetches a block's attestations given a set of options.
+func (m multi) BeaconBlockAttestations(ctx context.Context, opts *api.BeaconBlockAttestationsOpts) (*api.Response[[]*spec.VersionedAttestation], error) {
+	const label = "beacon_block_attestations"
+	defer latency(ctx, label, false)()
+	defer incRequest(label)
+
+	res0, err := provide(ctx, m.clients, m.fallbacks,
+		func(ctx context.Context, args provideArgs) (*api.Response[[]*spec.VersionedAttestation], error) {
+			return args.client.BeaconBlockAttestations(ctx, opts)
+		},
+		nil, m.selector,
+	)
+
+	if err != nil {
+		incError(label)
+		err = wrapError(ctx, err, label)
+	}
+
+	return res0, err
+}
+
 // SubmitProposal submits a proposal.
 func (m multi) SubmitProposal(ctx context.Context, opts *api.SubmitProposalOpts) error {
 	const label = "submit_proposal"
@@ -453,6 +513,27 @@ func (m multi) SubmitBeaconCommitteeSubscriptions(ctx context.Context, subscript
 	return err
 }
 
+// BeaconCommitteeSelections submits beacon committee selections.
+func (m multi) BeaconCommitteeSelections(ctx context.Context, opts *api.BeaconCommitteeSelectionsOpts) (*api.Response[[]*apiv1.BeaconCommitteeSelection], error) {
+	const label = "beacon_committee_selections"
+	defer latency(ctx, label, false)()
+	defer incRequest(label)
+
+	res0, err := provide(ctx, m.clients, m.fallbacks,
+		func(ctx context.Context, args provideArgs) (*api.Response[[]*apiv1.BeaconCommitteeSelection], error) {
+			return args.client.BeaconCommitteeSelections(ctx, opts)
+		},
+		nil, m.selector,
+	)
+
+	if err != nil {
+		incError(label)
+		err = wrapError(ctx, err, label)
+	}
+
+	return res0, err
+}
+
 // SubmitBlindedProposal submits a beacon block.
 func (m multi) SubmitBlindedProposal(ctx context.Context, opts *api.SubmitBlindedProposalOpts) error {
 	const label = "submit_blinded_proposal"
@@ -559,6 +640,28 @@ func (m multi) Genesis(ctx context.Context, opts *api.GenesisOpts) (*api.Respons
 	return res0, err
 }
 
+// NodePeerCount provides the peer count of the node.
+// Note this endpoint is cached in go-eth2-client.
+func (m multi) NodePeerCount(ctx context.Context, opts *api.NodePeerCountOpts) (*api.Response[*apiv1.PeerCount], error) {
+	const label = "node_peer_count"
+
+	defer incRequest(label)
+
+	res0, err := provide(ctx, m.clients, m.fallbacks,
+		func(ctx context.Context, args provideArgs) (*api.Response[*apiv1.PeerCount], error) {
+			return args.client.NodePeerCount(ctx, opts)
+		},
+		nil, m.selector,
+	)
+
+	if err != nil {
+		incError(label)
+		err = wrapError(ctx, err, label)
+	}
+
+	return res0, err
+}
+
 // NodeSyncing provides the state of the node's synchronization with the chain.
 func (m multi) NodeSyncing(ctx context.Context, opts *api.NodeSyncingOpts) (*api.Response[*apiv1.SyncState], error) {
 	const label = "node_syncing"
@@ -790,6 +893,16 @@ func (l *lazy) SignedBeaconBlock(ctx context.Context, opts *api.SignedBeaconBloc
 	return cl.SignedBeaconBlock(ctx, opts)
 }
 
+// BeaconCommittees fetches all beacon committees for the given options.
+func (l *lazy) BeaconCommittees(ctx context.Context, opts *api.BeaconCommitteesOpts) (res0 *api.Response[[]*apiv1.BeaconCommittee], err error) {
+	cl, err := l.getOrCreateClient(ctx)
+	if err != nil {
+		return res0, err
+	}
+
+	return cl.BeaconCommittees(ctx, opts)
+}
+
 // AggregateAttestation fetches the aggregate attestation for the given options.
 func (l *lazy) AggregateAttestation(ctx context.Context, opts *api.AggregateAttestationOpts) (res0 *api.Response[*spec.VersionedAttestation], err error) {
 	cl, err := l.getOrCreateClient(ctx)
@@ -901,6 +1014,16 @@ func (l *lazy) SubmitSyncCommitteeContributions(ctx context.Context, contributio
 	return cl.SubmitSyncCommitteeContributions(ctx, contributionAndProofs)
 }
 
+// SyncCommitteeSelections submits sync committee selections.
+func (l *lazy) SyncCommitteeSelections(ctx context.Context, opts *api.SyncCommitteeSelectionsOpts) (res0 *api.Response[[]*apiv1.SyncCommitteeSelection], err error) {
+	cl, err := l.getOrCreateClient(ctx)
+	if err != nil {
+		return res0, err
+	}
+
+	return cl.SyncCommitteeSelections(ctx, opts)
+}
+
 // Proposal fetches a proposal for signing.
 func (l *lazy) Proposal(ctx context.Context, opts *api.ProposalOpts) (res0 *api.Response[*api.VersionedProposal], err error) {
 	cl, err := l.getOrCreateClient(ctx)
@@ -921,6 +1044,16 @@ func (l *lazy) BeaconBlockRoot(ctx context.Context, opts *api.BeaconBlockRootOpt
 	return cl.BeaconBlockRoot(ctx, opts)
 }
 
+// BeaconBlockAttestations fetches a block's attestations given a set of options.
+func (l *lazy) BeaconBlockAttestations(ctx context.Context, opts *api.BeaconBlockAttestationsOpts) (res0 *api.Response[[]*spec.VersionedAttestation], err error) {
+	cl, err := l.getOrCreateClient(ctx)
+	if err != nil {
+		return res0, err
+	}
+
+	return cl.BeaconBlockAttestations(ctx, opts)
+}
+
 // SubmitProposal submits a proposal.
 func (l *lazy) SubmitProposal(ctx context.Context, opts *api.SubmitProposalOpts) (err error) {
 	cl, err := l.getOrCreateClient(ctx)
@@ -941,6 +1074,16 @@ func (l *lazy) SubmitBeaconCommitteeSubscriptions(ctx context.Context, subscript
 	return cl.SubmitBeaconCommitteeSubscriptions(ctx, subscriptions)
 }
 
+// BeaconCommitteeSelections submits beacon committee selections.
+func (l *lazy) BeaconCommitteeSelections(ctx context.Context, opts *api.BeaconCommitteeSelectionsOpts) (res0 *api.Response[[]*apiv1.BeaconCommitteeSelection], err error) {
+	cl, err := l.getOrCreateClient(ctx)
+	if err != nil {
+		return res0, err
+	}
+
+	return cl.BeaconCommitteeSelections(ctx, opts)
+}
+
 // SubmitBlindedProposal submits a beacon block.
 func (l *lazy) SubmitBlindedProposal(ctx context.Context, opts *api.SubmitBlindedProposalOpts) (err error) {
 	cl, err := l.getOrCreateClient(ctx)
@@ -991,6 +1134,16 @@ func (l *lazy) Genesis(ctx context.Context, opts *api.GenesisOpts) (res0 *api.Re
 	return cl.Genesis(ctx, opts)
 }
 
+// NodePeerCount provides the peer count of the node.
+func (l *lazy) NodePeerCount(ctx context.Context, opts *api.NodePeerCountOpts) (res0 *api.Response[*apiv1.PeerCount], err error) {
+	cl, err := l.getOrCreateClient(ctx)
+	if err != nil {
+		return res0, err
+	}
+
+	return cl.NodePeerCount(ctx, opts)
+}
+
 // NodeSyncing provides the state of the node's synchronization with the chain.
 func (l *lazy) NodeSyncing(ctx context.Context, opts *api.NodeSyncingOpts) (res0 *api.Response[*apiv1.SyncState], err error) {
 	cl, err := l.getOrCreateClient(ctx)
diff --git a/app/eth2wrap/genwrap/genwrap.go b/app/eth2wrap/genwrap/genwrap.go
index cf8025ea..53f03376 100644
--- a/app/eth2wrap/genwrap/genwrap.go
+++ b/app/eth2wrap/genwrap/genwrap.go
@@ -47,13 +47,6 @@ import (
 // Client defines all go-eth2-client interfaces used in charon.
 type Client interface {
     eth2client.Service
-    eth2exp.BeaconCommitteeSelectionAggregator
-    eth2exp.SyncCommitteeSelectionAggregator
-    eth2exp.ProposerConfigProvider
-    BlockAttestationsProvider
-		BlockProvider
-    BeaconStateCommitteesProvider
-    NodePeerCountProvider
 
     CachedValidatorsProvider
     SetValidatorCache(func(context.Context) (ActiveValidators, CompleteValidators, error))
@@ -113,6 +106,9 @@ type Client interface {
 		"BeaconBlockRootProvider":               {Latency: false, Log: false},
 		"ProposalSubmitter":                     {Latency: true, Log: false},
 		"BeaconCommitteeSubscriptionsSubmitter": {Latency: true, Log: false},
+		"BeaconBlockAttestationsProvider":       {Latency: true, Log: false},
+		"BeaconCommitteeSelectionsProvider":     {Latency: true, Log: false},
+		"BeaconCommitteesProvider":              {Latency: true, Log: false},
 		"BlindedProposalProvider":               {Latency: true, Log: false},
 		"BlindedProposalSubmitter":              {Latency: true, Log: false},
 		"DepositContractProvider":               {Latency: false, Log: false},
@@ -120,6 +116,7 @@ type Client interface {
 		"ForkProvider":                          {Latency: true, Log: false},
 		"ForkScheduleProvider":                  {Latency: true, Log: false},
 		"GenesisProvider":                       {Latency: false, Log: false},
+		"NodePeerCountProvider":                 {Latency: false, Log: false},
 		"NodeSyncingProvider":                   {Latency: true, Log: false},
 		"NodeVersionProvider":                   {Latency: false, Log: false},
 		"ProposerDutiesProvider":                {Latency: true, Log: false},
@@ -133,6 +130,7 @@ type Client interface {
 		"SyncCommitteeContributionsSubmitter":   {Latency: true, Log: false},
 		"SyncCommitteeMessagesSubmitter":        {Latency: true, Log: false},
 		"SyncCommitteeSubscriptionsSubmitter":   {Latency: true, Log: false},
+		"SyncCommitteeSelectionsProvider":       {Latency: true, Log: false},
 		"ValidatorsProvider":                    {Latency: true, Log: true},
 		"ValidatorRegistrationsSubmitter":       {Latency: true, Log: false},
 		"VoluntaryExitSubmitter":                {Latency: true, Log: false},
diff --git a/app/eth2wrap/httpwrap.go b/app/eth2wrap/httpwrap.go
index 938d1b64..f6be47eb 100644
--- a/app/eth2wrap/httpwrap.go
+++ b/app/eth2wrap/httpwrap.go
@@ -3,15 +3,8 @@
 package eth2wrap
 
 import (
-	"bytes"
 	"context"
 	"encoding/hex"
-	"encoding/json"
-	"fmt"
-	"io"
-	"net/http"
-	"net/url"
-	"strconv"
 	"sync"
 	"testing"
 	"time"
@@ -19,46 +12,12 @@ import (
 	"github.com/attestantio/go-eth2-client/api"
 	apiv1 "github.com/attestantio/go-eth2-client/api/v1"
 	eth2http "github.com/attestantio/go-eth2-client/http"
-	"github.com/attestantio/go-eth2-client/spec"
-	eth2e "github.com/attestantio/go-eth2-client/spec/electra"
 	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
 
 	"github.com/obolnetwork/charon/app/errors"
-	"github.com/obolnetwork/charon/app/z"
 	"github.com/obolnetwork/charon/eth2util"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
-	"github.com/obolnetwork/charon/eth2util/statecomm"
 )
 
-// BlockAttestationsProvider is the interface for providing attestations included in blocks.
-// It is a standard beacon API endpoint not implemented by eth2client.
-// See https://ethereum.github.io/beacon-APIs/#/Beacon/getBlockAttestations.
-type BlockAttestationsProvider interface {
-	BlockAttestations(ctx context.Context, stateID string) ([]*spec.VersionedAttestation, error)
-}
-
-// BlockProvider is the interface for providing block details.
-// It is a standard beacon API endpoint not implemented by eth2client.
-// See https://ethereum.github.io/beacon-APIs/#/Beacon/getBlockV2.
-type BlockProvider interface {
-	Block(ctx context.Context, stateID string) (*spec.VersionedSignedBeaconBlock, error)
-}
-
-// BeaconStateCommitteesProvider is the interface for providing committees for given slot.
-// It is a standard beacon API endpoint not implemented by eth2client.
-// See https://ethereum.github.io/beacon-APIs/#/Beacon/getEpochCommittees.
-type BeaconStateCommitteesProvider interface {
-	BeaconStateCommittees(ctx context.Context, slot uint64) ([]*statecomm.StateCommittee, error)
-}
-
-// NodePeerCountProvider is the interface for providing node peer count.
-// It is a standard beacon API endpoint not implemented by eth2client.
-// See https://ethereum.github.io/beacon-APIs/#/Node/getPeerCount.
-type NodePeerCountProvider interface {
-	// NodePeerCount provides peer count of the beacon node.
-	NodePeerCount(ctx context.Context) (int, error)
-}
-
 // NewHTTPAdapterForT returns a http adapter for testing non-eth2service methods as it is nil.
 func NewHTTPAdapterForT(_ *testing.T, address string, headers map[string]string, timeout time.Duration) Client {
 	return newHTTPAdapter(nil, address, headers, timeout)
@@ -157,237 +116,6 @@ func (h *httpAdapter) Validators(ctx context.Context, opts *api.ValidatorsOpts)
 	return h.Service.Validators(reqCtx, opts)
 }
 
-// AggregateBeaconCommitteeSelections implements eth2exp.BeaconCommitteeSelectionAggregator.
-func (h *httpAdapter) AggregateBeaconCommitteeSelections(ctx context.Context, selections []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error) {
-	reqBody, err := json.Marshal(selections)
-	if err != nil {
-		return nil, errors.Wrap(err, "marshal submit beacon committee selections")
-	}
-
-	respBody, err := httpPost(ctx, h.address, "/eth/v1/validator/beacon_committee_selections", bytes.NewReader(reqBody), h.headers, h.timeout)
-	if err != nil {
-		return nil, errors.Wrap(err, "submit beacon committee selections")
-	}
-
-	var resp submitBeaconCommitteeSelectionsJSON
-	if err := json.Unmarshal(respBody, &resp); err != nil {
-		return nil, errors.Wrap(err, "failed to parse beacon committee selections response")
-	}
-
-	return resp.Data, nil
-}
-
-// AggregateSyncCommitteeSelections implements eth2exp.SyncCommitteeSelectionAggregator.
-func (h *httpAdapter) AggregateSyncCommitteeSelections(ctx context.Context, selections []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error) {
-	reqBody, err := json.Marshal(selections)
-	if err != nil {
-		return nil, errors.Wrap(err, "marshal sync committee selections")
-	}
-
-	respBody, err := httpPost(ctx, h.address, "/eth/v1/validator/sync_committee_selections", bytes.NewReader(reqBody), h.headers, h.timeout)
-	if err != nil {
-		return nil, errors.Wrap(err, "submit sync committee selections")
-	}
-
-	var resp submitSyncCommitteeSelectionsJSON
-	if err := json.Unmarshal(respBody, &resp); err != nil {
-		return nil, errors.Wrap(err, "failed to parse sync committee selections response")
-	}
-
-	return resp.Data, nil
-}
-
-// BlockAttestations returns the attestations included in the requested block.
-// See https://ethereum.github.io/beacon-APIs/#/Beacon/getBlockAttestationsV2.
-func (h *httpAdapter) BlockAttestations(ctx context.Context, stateID string) ([]*spec.VersionedAttestation, error) {
-	path := fmt.Sprintf("/eth/v2/beacon/blocks/%s/attestations", stateID)
-
-	ctx, cancel := context.WithTimeout(ctx, h.timeout)
-	defer cancel()
-
-	resp, err := httpGetRaw(ctx, h.address, path, h.headers, nil)
-	if err != nil {
-		return nil, errors.Wrap(err, "request block attestations")
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode == http.StatusNotFound {
-		return nil, nil // No block for slot, so no attestations.
-	} else if resp.StatusCode != http.StatusOK {
-		return nil, errors.New("request block attestations failed", z.Int("status", resp.StatusCode))
-	}
-
-	respBody, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, errors.Wrap(err, "request block attestations body")
-	}
-
-	version, err := fetchConsensusVersion(resp)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to get consensus version")
-	}
-
-	res := []*spec.VersionedAttestation{}
-	switch version {
-	case spec.DataVersionPhase0:
-		var respAttestation p0AttestationsJSON
-		if err := json.Unmarshal(respBody, &respAttestation); err != nil {
-			return nil, errors.Wrap(err, "failed to parse block attestations response")
-		}
-
-		for _, a := range respAttestation.Data {
-			res = append(res, &spec.VersionedAttestation{Version: version, Phase0: a})
-		}
-	case spec.DataVersionAltair:
-		var respAttestation p0AttestationsJSON
-		if err := json.Unmarshal(respBody, &respAttestation); err != nil {
-			return nil, errors.Wrap(err, "failed to parse block attestations response")
-		}
-
-		for _, a := range respAttestation.Data {
-			res = append(res, &spec.VersionedAttestation{Version: version, Altair: a})
-		}
-	case spec.DataVersionBellatrix:
-		var respAttestation p0AttestationsJSON
-		if err := json.Unmarshal(respBody, &respAttestation); err != nil {
-			return nil, errors.Wrap(err, "failed to parse block attestations response")
-		}
-
-		for _, a := range respAttestation.Data {
-			res = append(res, &spec.VersionedAttestation{Version: version, Bellatrix: a})
-		}
-	case spec.DataVersionCapella:
-		var respAttestation p0AttestationsJSON
-		if err := json.Unmarshal(respBody, &respAttestation); err != nil {
-			return nil, errors.Wrap(err, "failed to parse block attestations response")
-		}
-
-		for _, a := range respAttestation.Data {
-			res = append(res, &spec.VersionedAttestation{Version: version, Capella: a})
-		}
-	case spec.DataVersionDeneb:
-		var respAttestation p0AttestationsJSON
-		if err := json.Unmarshal(respBody, &respAttestation); err != nil {
-			return nil, errors.Wrap(err, "failed to parse block attestations response")
-		}
-
-		for _, a := range respAttestation.Data {
-			res = append(res, &spec.VersionedAttestation{Version: version, Deneb: a})
-		}
-	case spec.DataVersionElectra:
-		var respAttestation electraAttestationsJSON
-		if err := json.Unmarshal(respBody, &respAttestation); err != nil {
-			return nil, errors.Wrap(err, "failed to parse block attestations response")
-		}
-
-		for _, a := range respAttestation.Data {
-			res = append(res, &spec.VersionedAttestation{Version: version, Electra: a})
-		}
-	case spec.DataVersionUnknown:
-		return nil, errors.New("attestations data version unknown")
-	}
-
-	return res, nil
-}
-
-// Block returns the block details.
-// See https://ethereum.github.io/beacon-APIs/#/Beacon/getBlockV2.
-func (h *httpAdapter) Block(ctx context.Context, stateID string) (*spec.VersionedSignedBeaconBlock, error) {
-	path := "/eth/v2/beacon/blocks/" + stateID
-
-	ctx, cancel := context.WithTimeout(ctx, h.timeout)
-	defer cancel()
-
-	resp, err := httpGetRaw(ctx, h.address, path, h.headers, nil)
-	if err != nil {
-		return nil, errors.Wrap(err, "request block")
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode == http.StatusNotFound {
-		return nil, nil //nolint:nilnil // No block for slot.
-	} else if resp.StatusCode != http.StatusOK {
-		return nil, errors.New("request block failed", z.Int("status", resp.StatusCode))
-	}
-
-	respBody, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, errors.Wrap(err, "request block attestations body")
-	}
-
-	res := spec.VersionedSignedBeaconBlock{}
-	if err := json.Unmarshal(respBody, &res); err != nil {
-		return nil, errors.Wrap(err, "failed to parse block response")
-	}
-
-	return &res, nil
-}
-
-// BeaconStateCommittees returns the attestations included in the requested block.
-// See https://ethereum.github.io/beacon-APIs/#/Beacon/getStateValidators.
-func (h *httpAdapter) BeaconStateCommittees(ctx context.Context, slot uint64) ([]*statecomm.StateCommittee, error) {
-	r := strconv.FormatUint(slot, 10)
-	path := fmt.Sprintf("/eth/v1/beacon/states/%v/committees", r)
-	queryParams := map[string]string{
-		"slot": strconv.FormatUint(slot, 10),
-	}
-
-	respBody, statusCode, err := httpGet(ctx, h.address, path, h.headers, queryParams, h.timeout)
-	if err != nil {
-		return nil, errors.Wrap(err, "request state committees for slot", z.Int("status", statusCode), z.U64("slot", slot))
-	}
-
-	if statusCode != http.StatusOK {
-		return nil, errors.New("request state committees for slot failed", z.Int("status", statusCode), z.U64("slot", slot))
-	}
-
-	var res statecomm.StateCommitteesResponse
-
-	err = json.Unmarshal(respBody, &res)
-	if err != nil {
-		return nil, errors.Wrap(err, "unmarshal state committees", z.Int("status", statusCode), z.U64("slot", slot))
-	}
-
-	return res.Data, nil
-}
-
-// ProposerConfig implements eth2exp.ProposerConfigProvider.
-func (h *httpAdapter) ProposerConfig(ctx context.Context) (*eth2exp.ProposerConfigResponse, error) {
-	respBody, statusCode, err := httpGet(ctx, h.address, "/proposer_config", h.headers, nil, h.timeout)
-	if err != nil {
-		return nil, errors.Wrap(err, "submit sync committee selections")
-	} else if statusCode != http.StatusOK {
-		return nil, errors.New("request proposer config failed", z.Int("status", statusCode), z.Str("body", string(respBody)))
-	}
-
-	var resp eth2exp.ProposerConfigResponse
-	if err := json.Unmarshal(respBody, &resp); err != nil {
-		return nil, errors.Wrap(err, "failed to parse sync committee selections response")
-	}
-
-	return &resp, nil
-}
-
-// NodePeerCount provides the peer count of the beacon node.
-// See https://ethereum.github.io/beacon-APIs/#/Node/getPeerCount.
-func (h *httpAdapter) NodePeerCount(ctx context.Context) (int, error) {
-	const path = "/eth/v1/node/peer_count"
-
-	respBody, statusCode, err := httpGet(ctx, h.address, path, h.headers, nil, h.timeout)
-	if err != nil {
-		return 0, errors.Wrap(err, "request beacon node peer count")
-	} else if statusCode != http.StatusOK {
-		return 0, errors.New("request beacon node peer count failed", z.Int("status", statusCode), z.Str("body", string(respBody)))
-	}
-
-	var resp peerCountJSON
-	if err := json.Unmarshal(respBody, &resp); err != nil {
-		return 0, errors.Wrap(err, "failed to parse beacon node peer count response")
-	}
-
-	return resp.Data.Connected, nil
-}
-
 // Domain returns the signing domain for a given domain type.
 // After EIP-7044, the VOLUNTARY_EXIT domain must always return a domain relative to the Capella hardfork.
 // This method returns just that for that domain type, otherwise follows the standard go-eth2-client flow.
@@ -403,158 +131,3 @@ func (h *httpAdapter) Domain(ctx context.Context, domainType eth2p0.DomainType,
 
 	return h.Service.Domain(ctx, domainType, epoch)
 }
-
-type submitBeaconCommitteeSelectionsJSON struct {
-	Data []*eth2exp.BeaconCommitteeSelection `json:"data"`
-}
-
-type submitSyncCommitteeSelectionsJSON struct {
-	Data []*eth2exp.SyncCommitteeSelection `json:"data"`
-}
-
-type p0AttestationsJSON struct {
-	Data []*eth2p0.Attestation `json:"data"`
-}
-
-type electraAttestationsJSON struct {
-	Data []*eth2e.Attestation `json:"data"`
-}
-
-type peerCountJSON struct {
-	Data struct {
-		Connected int `json:"connected,string"`
-	} `json:"data"`
-}
-
-type responseMetadata struct {
-	Version spec.DataVersion `json:"version"`
-}
-
-func httpPost(ctx context.Context, base string, endpoint string, body io.Reader, headers map[string]string, timeout time.Duration) ([]byte, error) {
-	ctx, cancel := context.WithTimeout(ctx, timeout)
-	defer cancel()
-
-	addr, err := url.JoinPath(base, endpoint)
-	if err != nil {
-		return nil, errors.Wrap(err, "invalid address")
-	}
-
-	url, err := url.ParseRequestURI(addr)
-	if err != nil {
-		return nil, errors.Wrap(err, "invalid endpoint")
-	}
-
-	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), body)
-	if err != nil {
-		return nil, errors.Wrap(err, "new POST request with ctx")
-	}
-
-	for k, v := range headers {
-		req.Header.Add(k, v)
-	}
-
-	res, err := new(http.Client).Do(req)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to call POST endpoint")
-	}
-	defer res.Body.Close()
-
-	data, err := io.ReadAll(res.Body)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to read POST response")
-	}
-
-	if res.StatusCode/100 != 2 {
-		return nil, errors.New("post failed", z.Int("status", res.StatusCode), z.Str("body", string(data)))
-	}
-
-	return data, nil
-}
-
-// httpGetRaw performs a GET request and returns the raw http response or an error.
-func httpGetRaw(ctx context.Context, base string, endpoint string, headers map[string]string, queryParams map[string]string) (*http.Response, error) {
-	addr, err := url.JoinPath(base, endpoint)
-	if err != nil {
-		return nil, errors.Wrap(err, "invalid address")
-	}
-
-	u, err := url.ParseRequestURI(addr)
-	if err != nil {
-		return nil, errors.Wrap(err, "invalid endpoint")
-	}
-
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
-	if err != nil {
-		return nil, errors.Wrap(err, "new GET request with ctx")
-	}
-
-	for k, v := range headers {
-		req.Header.Add(k, v)
-	}
-
-	q := req.URL.Query()
-	for key, val := range queryParams {
-		q.Add(key, val)
-	}
-
-	req.URL.RawQuery = q.Encode()
-
-	res, err := new(http.Client).Do(req)
-	if err != nil {
-		return nil, errors.Wrap(err, "failed to call GET endpoint")
-	}
-
-	return res, nil
-}
-
-// httpGet performs a GET request and returns the body and status code or an error.
-func httpGet(ctx context.Context, base string, endpoint string, headers map[string]string, queryParams map[string]string, timeout time.Duration) ([]byte, int, error) {
-	ctx, cancel := context.WithTimeout(ctx, timeout)
-	defer cancel()
-
-	res, err := httpGetRaw(ctx, base, endpoint, headers, queryParams)
-	if err != nil {
-		return nil, 0, errors.Wrap(err, "failed to read GET response")
-	}
-	defer res.Body.Close()
-
-	data, err := io.ReadAll(res.Body)
-	if err != nil {
-		return nil, res.StatusCode, errors.Wrap(err, "failed to read GET response body")
-	}
-
-	return data, res.StatusCode, nil
-}
-
-// fetchConsensusVersion attempts to extract the consensus version from the beacon node http response.
-func fetchConsensusVersion(resp *http.Response) (spec.DataVersion, error) {
-	respConsensusVersions, exists := resp.Header["Eth-Consensus-Version"]
-	if !exists {
-		// No consensus version supplied in response; obtain it from the body if possible.
-		var metadata responseMetadata
-
-		body, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return spec.DataVersionUnknown, errors.Wrap(err, "read resp body")
-		}
-
-		if err := json.Unmarshal(body, &metadata); err != nil {
-			return spec.DataVersionUnknown, errors.Wrap(err, "no consensus version header and failed to parse response")
-		}
-
-		return metadata.Version, nil
-	}
-
-	if len(respConsensusVersions) != 1 {
-		return spec.DataVersionUnknown, errors.New("malformed consensus version", z.Int("entries", len(respConsensusVersions)))
-	}
-
-	var dataVersion spec.DataVersion
-
-	err := dataVersion.UnmarshalJSON([]byte(fmt.Sprintf("%q", respConsensusVersions[0])))
-	if err != nil {
-		return spec.DataVersionUnknown, errors.Wrap(err, "unmarshal consensus version header to data version")
-	}
-
-	return dataVersion, nil
-}
diff --git a/app/eth2wrap/lazy.go b/app/eth2wrap/lazy.go
index 51a436d5..08ea2633 100644
--- a/app/eth2wrap/lazy.go
+++ b/app/eth2wrap/lazy.go
@@ -6,11 +6,6 @@ import (
 	"context"
 	"sync"
 	"time"
-
-	"github.com/attestantio/go-eth2-client/spec"
-
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
-	"github.com/obolnetwork/charon/eth2util/statecomm"
 )
 
 //go:generate mockery --name=Client --output=mocks --outpkg=mocks --case=underscore
@@ -166,66 +161,3 @@ func (l *lazy) SetValidatorCache(valCache func(context.Context) (ActiveValidator
 		cl.SetValidatorCache(valCache)
 	}
 }
-
-func (l *lazy) ProposerConfig(ctx context.Context) (*eth2exp.ProposerConfigResponse, error) {
-	cl, err := l.getOrCreateClient(ctx)
-	if err != nil {
-		return nil, err
-	}
-
-	return cl.ProposerConfig(ctx)
-}
-
-func (l *lazy) AggregateBeaconCommitteeSelections(ctx context.Context, partialSelections []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error) {
-	cl, err := l.getOrCreateClient(ctx)
-	if err != nil {
-		return nil, err
-	}
-
-	return cl.AggregateBeaconCommitteeSelections(ctx, partialSelections)
-}
-
-func (l *lazy) AggregateSyncCommitteeSelections(ctx context.Context, partialSelections []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error) {
-	cl, err := l.getOrCreateClient(ctx)
-	if err != nil {
-		return nil, err
-	}
-
-	return cl.AggregateSyncCommitteeSelections(ctx, partialSelections)
-}
-
-func (l *lazy) BlockAttestations(ctx context.Context, stateID string) ([]*spec.VersionedAttestation, error) {
-	cl, err := l.getOrCreateClient(ctx)
-	if err != nil {
-		return nil, err
-	}
-
-	return cl.BlockAttestations(ctx, stateID)
-}
-
-func (l *lazy) Block(ctx context.Context, stateID string) (*spec.VersionedSignedBeaconBlock, error) {
-	cl, err := l.getOrCreateClient(ctx)
-	if err != nil {
-		return nil, err
-	}
-
-	return cl.Block(ctx, stateID)
-}
-
-func (l *lazy) BeaconStateCommittees(ctx context.Context, slot uint64) ([]*statecomm.StateCommittee, error) {
-	cl, err := l.getOrCreateClient(ctx)
-	if err != nil {
-		return nil, err
-	}
-
-	return cl.BeaconStateCommittees(ctx, slot)
-}
-
-func (l *lazy) NodePeerCount(ctx context.Context) (int, error) {
-	cl, err := l.getOrCreateClient(ctx)
-	if err != nil {
-		return 0, err
-	}
-
-	return cl.NodePeerCount(ctx)
-}
diff --git a/app/eth2wrap/mocks/client.go b/app/eth2wrap/mocks/client.go
index 839f509a..f7ab2197 100644
--- a/app/eth2wrap/mocks/client.go
+++ b/app/eth2wrap/mocks/client.go
@@ -10,8 +10,6 @@ import (
 
 	context "context"
 
-	eth2exp "github.com/obolnetwork/charon/eth2util/eth2exp"
-
 	eth2wrap "github.com/obolnetwork/charon/app/eth2wrap"
 
 	mock "github.com/stretchr/testify/mock"
@@ -20,8 +18,6 @@ import (
 
 	spec "github.com/attestantio/go-eth2-client/spec"
 
-	statecomm "github.com/obolnetwork/charon/eth2util/statecomm"
-
 	time "time"
 
 	v1 "github.com/attestantio/go-eth2-client/api/v1"
@@ -110,66 +106,6 @@ func (_m *Client) AggregateAttestation(ctx context.Context, opts *api.AggregateA
 	return r0, r1
 }
 
-// AggregateBeaconCommitteeSelections provides a mock function with given fields: ctx, partialSelections
-func (_m *Client) AggregateBeaconCommitteeSelections(ctx context.Context, partialSelections []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error) {
-	ret := _m.Called(ctx, partialSelections)
-
-	if len(ret) == 0 {
-		panic("no return value specified for AggregateBeaconCommitteeSelections")
-	}
-
-	var r0 []*eth2exp.BeaconCommitteeSelection
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error)); ok {
-		return rf(ctx, partialSelections)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, []*eth2exp.BeaconCommitteeSelection) []*eth2exp.BeaconCommitteeSelection); ok {
-		r0 = rf(ctx, partialSelections)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]*eth2exp.BeaconCommitteeSelection)
-		}
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, []*eth2exp.BeaconCommitteeSelection) error); ok {
-		r1 = rf(ctx, partialSelections)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// AggregateSyncCommitteeSelections provides a mock function with given fields: ctx, partialSelections
-func (_m *Client) AggregateSyncCommitteeSelections(ctx context.Context, partialSelections []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error) {
-	ret := _m.Called(ctx, partialSelections)
-
-	if len(ret) == 0 {
-		panic("no return value specified for AggregateSyncCommitteeSelections")
-	}
-
-	var r0 []*eth2exp.SyncCommitteeSelection
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error)); ok {
-		return rf(ctx, partialSelections)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, []*eth2exp.SyncCommitteeSelection) []*eth2exp.SyncCommitteeSelection); ok {
-		r0 = rf(ctx, partialSelections)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]*eth2exp.SyncCommitteeSelection)
-		}
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, []*eth2exp.SyncCommitteeSelection) error); ok {
-		r1 = rf(ctx, partialSelections)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
 // AttestationData provides a mock function with given fields: ctx, opts
 func (_m *Client) AttestationData(ctx context.Context, opts *api.AttestationDataOpts) (*api.Response[*phase0.AttestationData], error) {
 	ret := _m.Called(ctx, opts)
@@ -230,28 +166,28 @@ func (_m *Client) AttesterDuties(ctx context.Context, opts *api.AttesterDutiesOp
 	return r0, r1
 }
 
-// BeaconBlockRoot provides a mock function with given fields: ctx, opts
-func (_m *Client) BeaconBlockRoot(ctx context.Context, opts *api.BeaconBlockRootOpts) (*api.Response[*phase0.Root], error) {
+// BeaconBlockAttestations provides a mock function with given fields: ctx, opts
+func (_m *Client) BeaconBlockAttestations(ctx context.Context, opts *api.BeaconBlockAttestationsOpts) (*api.Response[[]*spec.VersionedAttestation], error) {
 	ret := _m.Called(ctx, opts)
 
 	if len(ret) == 0 {
-		panic("no return value specified for BeaconBlockRoot")
+		panic("no return value specified for BeaconBlockAttestations")
 	}
 
-	var r0 *api.Response[*phase0.Root]
+	var r0 *api.Response[[]*spec.VersionedAttestation]
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *api.BeaconBlockRootOpts) (*api.Response[*phase0.Root], error)); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, *api.BeaconBlockAttestationsOpts) (*api.Response[[]*spec.VersionedAttestation], error)); ok {
 		return rf(ctx, opts)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, *api.BeaconBlockRootOpts) *api.Response[*phase0.Root]); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, *api.BeaconBlockAttestationsOpts) *api.Response[[]*spec.VersionedAttestation]); ok {
 		r0 = rf(ctx, opts)
 	} else {
 		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*api.Response[*phase0.Root])
+			r0 = ret.Get(0).(*api.Response[[]*spec.VersionedAttestation])
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, *api.BeaconBlockRootOpts) error); ok {
+	if rf, ok := ret.Get(1).(func(context.Context, *api.BeaconBlockAttestationsOpts) error); ok {
 		r1 = rf(ctx, opts)
 	} else {
 		r1 = ret.Error(1)
@@ -260,29 +196,29 @@ func (_m *Client) BeaconBlockRoot(ctx context.Context, opts *api.BeaconBlockRoot
 	return r0, r1
 }
 
-// BeaconStateCommittees provides a mock function with given fields: ctx, slot
-func (_m *Client) BeaconStateCommittees(ctx context.Context, slot uint64) ([]*statecomm.StateCommittee, error) {
-	ret := _m.Called(ctx, slot)
+// BeaconBlockRoot provides a mock function with given fields: ctx, opts
+func (_m *Client) BeaconBlockRoot(ctx context.Context, opts *api.BeaconBlockRootOpts) (*api.Response[*phase0.Root], error) {
+	ret := _m.Called(ctx, opts)
 
 	if len(ret) == 0 {
-		panic("no return value specified for BeaconStateCommittees")
+		panic("no return value specified for BeaconBlockRoot")
 	}
 
-	var r0 []*statecomm.StateCommittee
+	var r0 *api.Response[*phase0.Root]
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, uint64) ([]*statecomm.StateCommittee, error)); ok {
-		return rf(ctx, slot)
+	if rf, ok := ret.Get(0).(func(context.Context, *api.BeaconBlockRootOpts) (*api.Response[*phase0.Root], error)); ok {
+		return rf(ctx, opts)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, uint64) []*statecomm.StateCommittee); ok {
-		r0 = rf(ctx, slot)
+	if rf, ok := ret.Get(0).(func(context.Context, *api.BeaconBlockRootOpts) *api.Response[*phase0.Root]); ok {
+		r0 = rf(ctx, opts)
 	} else {
 		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]*statecomm.StateCommittee)
+			r0 = ret.Get(0).(*api.Response[*phase0.Root])
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, uint64) error); ok {
-		r1 = rf(ctx, slot)
+	if rf, ok := ret.Get(1).(func(context.Context, *api.BeaconBlockRootOpts) error); ok {
+		r1 = rf(ctx, opts)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -290,29 +226,29 @@ func (_m *Client) BeaconStateCommittees(ctx context.Context, slot uint64) ([]*st
 	return r0, r1
 }
 
-// Block provides a mock function with given fields: ctx, stateID
-func (_m *Client) Block(ctx context.Context, stateID string) (*spec.VersionedSignedBeaconBlock, error) {
-	ret := _m.Called(ctx, stateID)
+// BeaconCommitteeSelections provides a mock function with given fields: ctx, opts
+func (_m *Client) BeaconCommitteeSelections(ctx context.Context, opts *api.BeaconCommitteeSelectionsOpts) (*api.Response[[]*v1.BeaconCommitteeSelection], error) {
+	ret := _m.Called(ctx, opts)
 
 	if len(ret) == 0 {
-		panic("no return value specified for Block")
+		panic("no return value specified for BeaconCommitteeSelections")
 	}
 
-	var r0 *spec.VersionedSignedBeaconBlock
+	var r0 *api.Response[[]*v1.BeaconCommitteeSelection]
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, string) (*spec.VersionedSignedBeaconBlock, error)); ok {
-		return rf(ctx, stateID)
+	if rf, ok := ret.Get(0).(func(context.Context, *api.BeaconCommitteeSelectionsOpts) (*api.Response[[]*v1.BeaconCommitteeSelection], error)); ok {
+		return rf(ctx, opts)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, string) *spec.VersionedSignedBeaconBlock); ok {
-		r0 = rf(ctx, stateID)
+	if rf, ok := ret.Get(0).(func(context.Context, *api.BeaconCommitteeSelectionsOpts) *api.Response[[]*v1.BeaconCommitteeSelection]); ok {
+		r0 = rf(ctx, opts)
 	} else {
 		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*spec.VersionedSignedBeaconBlock)
+			r0 = ret.Get(0).(*api.Response[[]*v1.BeaconCommitteeSelection])
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
-		r1 = rf(ctx, stateID)
+	if rf, ok := ret.Get(1).(func(context.Context, *api.BeaconCommitteeSelectionsOpts) error); ok {
+		r1 = rf(ctx, opts)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -320,29 +256,29 @@ func (_m *Client) Block(ctx context.Context, stateID string) (*spec.VersionedSig
 	return r0, r1
 }
 
-// BlockAttestations provides a mock function with given fields: ctx, stateID
-func (_m *Client) BlockAttestations(ctx context.Context, stateID string) ([]*spec.VersionedAttestation, error) {
-	ret := _m.Called(ctx, stateID)
+// BeaconCommittees provides a mock function with given fields: ctx, opts
+func (_m *Client) BeaconCommittees(ctx context.Context, opts *api.BeaconCommitteesOpts) (*api.Response[[]*v1.BeaconCommittee], error) {
+	ret := _m.Called(ctx, opts)
 
 	if len(ret) == 0 {
-		panic("no return value specified for BlockAttestations")
+		panic("no return value specified for BeaconCommittees")
 	}
 
-	var r0 []*spec.VersionedAttestation
+	var r0 *api.Response[[]*v1.BeaconCommittee]
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, string) ([]*spec.VersionedAttestation, error)); ok {
-		return rf(ctx, stateID)
+	if rf, ok := ret.Get(0).(func(context.Context, *api.BeaconCommitteesOpts) (*api.Response[[]*v1.BeaconCommittee], error)); ok {
+		return rf(ctx, opts)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, string) []*spec.VersionedAttestation); ok {
-		r0 = rf(ctx, stateID)
+	if rf, ok := ret.Get(0).(func(context.Context, *api.BeaconCommitteesOpts) *api.Response[[]*v1.BeaconCommittee]); ok {
+		r0 = rf(ctx, opts)
 	} else {
 		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]*spec.VersionedAttestation)
+			r0 = ret.Get(0).(*api.Response[[]*v1.BeaconCommittee])
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
-		r1 = rf(ctx, stateID)
+	if rf, ok := ret.Get(1).(func(context.Context, *api.BeaconCommitteesOpts) error); ok {
+		r1 = rf(ctx, opts)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -614,27 +550,29 @@ func (_m *Client) Name() string {
 	return r0
 }
 
-// NodePeerCount provides a mock function with given fields: ctx
-func (_m *Client) NodePeerCount(ctx context.Context) (int, error) {
-	ret := _m.Called(ctx)
+// NodePeerCount provides a mock function with given fields: ctx, opts
+func (_m *Client) NodePeerCount(ctx context.Context, opts *api.NodePeerCountOpts) (*api.Response[*v1.PeerCount], error) {
+	ret := _m.Called(ctx, opts)
 
 	if len(ret) == 0 {
 		panic("no return value specified for NodePeerCount")
 	}
 
-	var r0 int
+	var r0 *api.Response[*v1.PeerCount]
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context) (int, error)); ok {
-		return rf(ctx)
+	if rf, ok := ret.Get(0).(func(context.Context, *api.NodePeerCountOpts) (*api.Response[*v1.PeerCount], error)); ok {
+		return rf(ctx, opts)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context) int); ok {
-		r0 = rf(ctx)
+	if rf, ok := ret.Get(0).(func(context.Context, *api.NodePeerCountOpts) *api.Response[*v1.PeerCount]); ok {
+		r0 = rf(ctx, opts)
 	} else {
-		r0 = ret.Get(0).(int)
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*api.Response[*v1.PeerCount])
+		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context) error); ok {
-		r1 = rf(ctx)
+	if rf, ok := ret.Get(1).(func(context.Context, *api.NodePeerCountOpts) error); ok {
+		r1 = rf(ctx, opts)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -732,36 +670,6 @@ func (_m *Client) Proposal(ctx context.Context, opts *api.ProposalOpts) (*api.Re
 	return r0, r1
 }
 
-// ProposerConfig provides a mock function with given fields: ctx
-func (_m *Client) ProposerConfig(ctx context.Context) (*eth2exp.ProposerConfigResponse, error) {
-	ret := _m.Called(ctx)
-
-	if len(ret) == 0 {
-		panic("no return value specified for ProposerConfig")
-	}
-
-	var r0 *eth2exp.ProposerConfigResponse
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context) (*eth2exp.ProposerConfigResponse, error)); ok {
-		return rf(ctx)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context) *eth2exp.ProposerConfigResponse); ok {
-		r0 = rf(ctx)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*eth2exp.ProposerConfigResponse)
-		}
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context) error); ok {
-		r1 = rf(ctx)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
 // ProposerDuties provides a mock function with given fields: ctx, opts
 func (_m *Client) ProposerDuties(ctx context.Context, opts *api.ProposerDutiesOpts) (*api.Response[[]*v1.ProposerDuty], error) {
 	ret := _m.Called(ctx, opts)
@@ -1176,6 +1084,36 @@ func (_m *Client) SyncCommitteeDuties(ctx context.Context, opts *api.SyncCommitt
 	return r0, r1
 }
 
+// SyncCommitteeSelections provides a mock function with given fields: ctx, opts
+func (_m *Client) SyncCommitteeSelections(ctx context.Context, opts *api.SyncCommitteeSelectionsOpts) (*api.Response[[]*v1.SyncCommitteeSelection], error) {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SyncCommitteeSelections")
+	}
+
+	var r0 *api.Response[[]*v1.SyncCommitteeSelection]
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.SyncCommitteeSelectionsOpts) (*api.Response[[]*v1.SyncCommitteeSelection], error)); ok {
+		return rf(ctx, opts)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *api.SyncCommitteeSelectionsOpts) *api.Response[[]*v1.SyncCommitteeSelection]); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*api.Response[[]*v1.SyncCommitteeSelection])
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *api.SyncCommitteeSelectionsOpts) error); ok {
+		r1 = rf(ctx, opts)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
 // Validators provides a mock function with given fields: ctx, opts
 func (_m *Client) Validators(ctx context.Context, opts *api.ValidatorsOpts) (*api.Response[map[phase0.ValidatorIndex]*v1.Validator], error) {
 	ret := _m.Called(ctx, opts)
diff --git a/app/eth2wrap/multi.go b/app/eth2wrap/multi.go
index b3868297..bc159e0f 100644
--- a/app/eth2wrap/multi.go
+++ b/app/eth2wrap/multi.go
@@ -4,11 +4,6 @@ package eth2wrap
 
 import (
 	"context"
-
-	"github.com/attestantio/go-eth2-client/spec"
-
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
-	"github.com/obolnetwork/charon/eth2util/statecomm"
 )
 
 // NewMultiForT creates a new mutil client for testing.
@@ -124,137 +119,3 @@ func (m multi) CompleteValidators(ctx context.Context) (CompleteValidators, erro
 
 	return res0, err
 }
-
-func (m multi) ProposerConfig(ctx context.Context) (*eth2exp.ProposerConfigResponse, error) {
-	const label = "proposer_config"
-	defer latency(ctx, label, false)()
-	defer incRequest(label)
-
-	res0, err := provide(ctx, m.clients, m.fallbacks,
-		func(ctx context.Context, args provideArgs) (*eth2exp.ProposerConfigResponse, error) {
-			return args.client.ProposerConfig(ctx)
-		},
-		nil, m.selector,
-	)
-	if err != nil {
-		incError(label)
-		err = wrapError(ctx, err, label)
-	}
-
-	return res0, err
-}
-
-func (m multi) AggregateBeaconCommitteeSelections(ctx context.Context, selections []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error) {
-	const label = "aggregate_beacon_committee_selections"
-	defer latency(ctx, label, false)()
-	defer incRequest(label)
-
-	res0, err := provide(ctx, m.clients, m.fallbacks,
-		func(ctx context.Context, args provideArgs) ([]*eth2exp.BeaconCommitteeSelection, error) {
-			return args.client.AggregateBeaconCommitteeSelections(ctx, selections)
-		},
-		nil, m.selector,
-	)
-	if err != nil {
-		incError(label)
-		err = wrapError(ctx, err, label)
-	}
-
-	return res0, err
-}
-
-func (m multi) AggregateSyncCommitteeSelections(ctx context.Context, selections []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error) {
-	const label = "aggregate_sync_committee_selections"
-	defer latency(ctx, label, false)()
-	defer incRequest(label)
-
-	res, err := provide(ctx, m.clients, m.fallbacks,
-		func(ctx context.Context, args provideArgs) ([]*eth2exp.SyncCommitteeSelection, error) {
-			return args.client.AggregateSyncCommitteeSelections(ctx, selections)
-		},
-
-		nil, m.selector,
-	)
-	if err != nil {
-		incError(label)
-		err = wrapError(ctx, err, label)
-	}
-
-	return res, err
-}
-
-func (m multi) BlockAttestations(ctx context.Context, stateID string) ([]*spec.VersionedAttestation, error) {
-	const label = "block_attestations_v2"
-	defer latency(ctx, label, false)()
-	defer incRequest(label)
-
-	res, err := provide(ctx, m.clients, m.fallbacks,
-		func(ctx context.Context, args provideArgs) ([]*spec.VersionedAttestation, error) {
-			return args.client.BlockAttestations(ctx, stateID)
-		},
-		nil, m.selector,
-	)
-	if err != nil {
-		incError(label)
-		err = wrapError(ctx, err, label)
-	}
-
-	return res, err
-}
-
-func (m multi) Block(ctx context.Context, stateID string) (*spec.VersionedSignedBeaconBlock, error) {
-	const label = "block_v2"
-	defer latency(ctx, label, false)()
-	defer incRequest(label)
-
-	res, err := provide(ctx, m.clients, m.fallbacks,
-		func(ctx context.Context, args provideArgs) (*spec.VersionedSignedBeaconBlock, error) {
-			return args.client.Block(ctx, stateID)
-		},
-		nil, m.selector,
-	)
-	if err != nil {
-		incError(label)
-		err = wrapError(ctx, err, label)
-	}
-
-	return res, err
-}
-
-func (m multi) BeaconStateCommittees(ctx context.Context, slot uint64) ([]*statecomm.StateCommittee, error) {
-	const label = "beacon_state_committees"
-	defer latency(ctx, label, false)()
-	defer incRequest(label)
-
-	res, err := provide(ctx, m.clients, m.fallbacks,
-		func(ctx context.Context, args provideArgs) ([]*statecomm.StateCommittee, error) {
-			return args.client.BeaconStateCommittees(ctx, slot)
-		},
-		nil, m.selector,
-	)
-	if err != nil {
-		incError(label)
-		err = wrapError(ctx, err, label)
-	}
-
-	return res, err
-}
-
-func (m multi) NodePeerCount(ctx context.Context) (int, error) {
-	const label = "node_peer_count"
-	defer latency(ctx, label, false)()
-	defer incRequest(label)
-
-	res, err := provide(ctx, m.clients, m.fallbacks,
-		func(ctx context.Context, args provideArgs) (int, error) {
-			return args.client.NodePeerCount(ctx)
-		},
-		nil, m.selector,
-	)
-	if err != nil {
-		incError(label)
-		err = wrapError(ctx, err, label)
-	}
-
-	return res, err
-}
diff --git a/app/eth2wrap/version.go b/app/eth2wrap/version.go
index 4c4de6ff..f93f4a97 100644
--- a/app/eth2wrap/version.go
+++ b/app/eth2wrap/version.go
@@ -81,6 +81,7 @@ func checkBeaconNodeVersionStatus(bnVersion string) (beaconNodeVersionStatus Bea
 func CheckBeaconNodeVersion(ctx context.Context, bnVersion string) {
 	status, currentVersion, minVersion := checkBeaconNodeVersionStatus(bnVersion)
 
+	//nolint:revive // enforce-switch-style: the list is exhaustive and there is no need for default
 	switch status {
 	case VersionFormatError:
 		log.Warn(ctx, "Failed to parse beacon node version string due to unexpected format",
diff --git a/app/featureset/featureset.go b/app/featureset/featureset.go
index fc9cb1da..d0e89561 100644
--- a/app/featureset/featureset.go
+++ b/app/featureset/featureset.go
@@ -60,6 +60,9 @@ const (
 
 	// ProposalTimeout enables a longer first consensus round timeout of 1.5 seconds for proposal duty.
 	ProposalTimeout = "proposal_timeout"
+
+	// QUIC enables the QUIC transport protocol in libp2p.
+	QUIC = "quic"
 )
 
 var (
@@ -75,6 +78,7 @@ var (
 		SSEReorgDuties:       statusAlpha,
 		AttestationInclusion: statusAlpha,
 		ProposalTimeout:      statusAlpha,
+		QUIC:                 statusAlpha,
 		// Add all features and there status here.
 	}
 
diff --git a/app/genssz/genssz.go b/app/genssz/genssz.go
index 15e5e30b..8a1684c5 100644
--- a/app/genssz/genssz.go
+++ b/app/genssz/genssz.go
@@ -40,7 +40,7 @@ var pkgFlag = flag.String("package", "", "The fully qualified go package to gene
 func main() {
 	ctx := context.Background()
 
-	pkg, err := getPkg()
+	pkg, err := getPkg(ctx)
 	if err != nil {
 		log.Error(context.Background(), "Failed to get module", err)
 		os.Exit(1)
@@ -188,12 +188,12 @@ func writeTemplate(types []Type, filename string, pkg string) error {
 	return nil
 }
 
-func getPkg() (string, error) {
+func getPkg(ctx context.Context) (string, error) {
 	if *pkgFlag != "" {
 		return *pkgFlag, nil
 	}
 
-	out, err := exec.Command("go", "list", ".").Output()
+	out, err := exec.CommandContext(ctx, "go", "list", ".").Output()
 	if err != nil {
 		return "", errors.Wrap(err, "get package")
 	}
diff --git a/app/lifecycle/order.go b/app/lifecycle/order.go
index 873ee61e..66faea96 100644
--- a/app/lifecycle/order.go
+++ b/app/lifecycle/order.go
@@ -23,6 +23,7 @@ const (
 	StartP2PPing
 	StartP2PRouters
 	StartForceDirectConns
+	StartForceQUICConns
 	StartP2PConsensus
 	StartSimulator
 	StartScheduler
@@ -42,8 +43,7 @@ const (
 	StopValidatorAPI
 	StopTracing // Low level services...
 	StopP2PPeerDB
-	StopP2PTCPNode
-	StopP2PUDPNode
+	StopP2PNode
 	StopDebugAPI
 	StopMonitoringAPI
 )
diff --git a/app/lifecycle/orderstart_string.go b/app/lifecycle/orderstart_string.go
index 84686e1a..4f183324 100644
--- a/app/lifecycle/orderstart_string.go
+++ b/app/lifecycle/orderstart_string.go
@@ -20,18 +20,19 @@ func _() {
 	_ = x[StartP2PPing-7]
 	_ = x[StartP2PRouters-8]
 	_ = x[StartForceDirectConns-9]
-	_ = x[StartP2PConsensus-10]
-	_ = x[StartSimulator-11]
-	_ = x[StartScheduler-12]
-	_ = x[StartP2PEventCollector-13]
-	_ = x[StartPeerInfo-14]
-	_ = x[StartParSigDB-15]
-	_ = x[StartStackSnipe-16]
+	_ = x[StartForceQUICConns-10]
+	_ = x[StartP2PConsensus-11]
+	_ = x[StartSimulator-12]
+	_ = x[StartScheduler-13]
+	_ = x[StartP2PEventCollector-14]
+	_ = x[StartPeerInfo-15]
+	_ = x[StartParSigDB-16]
+	_ = x[StartStackSnipe-17]
 }
 
-const _OrderStart_name = "TrackerPrivkeyLockAggSigDBRelayMonitoringAPIDebugAPIValidatorAPIP2PPingP2PRoutersForceDirectConnsP2PConsensusSimulatorSchedulerP2PEventCollectorPeerInfoParSigDBStackSnipe"
+const _OrderStart_name = "TrackerPrivkeyLockAggSigDBRelayMonitoringAPIDebugAPIValidatorAPIP2PPingP2PRoutersForceDirectConnsForceQUICConnsP2PConsensusSimulatorSchedulerP2PEventCollectorPeerInfoParSigDBStackSnipe"
 
-var _OrderStart_index = [...]uint8{0, 7, 18, 26, 31, 44, 52, 64, 71, 81, 97, 109, 118, 127, 144, 152, 160, 170}
+var _OrderStart_index = [...]uint8{0, 7, 18, 26, 31, 44, 52, 64, 71, 81, 97, 111, 123, 132, 141, 158, 166, 174, 184}
 
 func (i OrderStart) String() string {
 	if i < 0 || i >= OrderStart(len(_OrderStart_index)-1) {
diff --git a/app/lifecycle/orderstop_string.go b/app/lifecycle/orderstop_string.go
index b9662868..3df77b42 100644
--- a/app/lifecycle/orderstop_string.go
+++ b/app/lifecycle/orderstop_string.go
@@ -18,15 +18,14 @@ func _() {
 	_ = x[StopValidatorAPI-5]
 	_ = x[StopTracing-6]
 	_ = x[StopP2PPeerDB-7]
-	_ = x[StopP2PTCPNode-8]
-	_ = x[StopP2PUDPNode-9]
-	_ = x[StopDebugAPI-10]
-	_ = x[StopMonitoringAPI-11]
+	_ = x[StopP2PNode-8]
+	_ = x[StopDebugAPI-9]
+	_ = x[StopMonitoringAPI-10]
 }
 
-const _OrderStop_name = "SchedulerPrivkeyLockRetryerDutyDBBeaconMockValidatorAPITracingP2PPeerDBP2PTCPNodeP2PUDPNodeDebugAPIMonitoringAPI"
+const _OrderStop_name = "SchedulerPrivkeyLockRetryerDutyDBBeaconMockValidatorAPITracingP2PPeerDBP2PNodeDebugAPIMonitoringAPI"
 
-var _OrderStop_index = [...]uint8{0, 9, 20, 27, 33, 43, 55, 62, 71, 81, 91, 99, 112}
+var _OrderStop_index = [...]uint8{0, 9, 20, 27, 33, 43, 55, 62, 71, 78, 86, 99}
 
 func (i OrderStop) String() string {
 	if i < 0 || i >= OrderStop(len(_OrderStop_index)-1) {
diff --git a/app/log/config.go b/app/log/config.go
index 15d77f24..5a008bbc 100644
--- a/app/log/config.go
+++ b/app/log/config.go
@@ -133,9 +133,9 @@ func (c Config) InferColor() (bool, error) {
 		return true, nil
 	case colorAuto, "":
 		return term.IsTerminal(int(os.Stderr.Fd())), nil
+	default:
+		return false, errors.New("invalid --log-color value", z.Str("value", c.Color))
 	}
-
-	return false, errors.New("invalid --log-color value", z.Str("value", c.Color))
 }
 
 // DefaultConfig returns the default logging config.
diff --git a/app/monitoringapi.go b/app/monitoringapi.go
index 472311f4..cc33e24a 100644
--- a/app/monitoringapi.go
+++ b/app/monitoringapi.go
@@ -45,7 +45,7 @@ var (
 // wireMonitoringAPI constructs the monitoring API and registers it with the life cycle manager.
 // It serves prometheus metrics, pprof profiling and the runtime enr.
 func wireMonitoringAPI(ctx context.Context, life *lifecycle.Manager, promAddr, debugAddr string,
-	tcpNode host.Host, eth2Cl eth2wrap.Client,
+	p2pNode host.Host, eth2Cl eth2wrap.Client,
 	peerIDs []peer.ID, registry *prometheus.Registry, consensusDebugger http.Handler,
 	pubkeys []core.PubKey, seenPubkeys <-chan core.PubKey, vapiCalls <-chan struct{},
 	numValidators int,
@@ -64,7 +64,7 @@ func wireMonitoringAPI(ctx context.Context, life *lifecycle.Manager, promAddr, d
 		writeResponse(w, http.StatusOK, "ok")
 	}))
 
-	readyErrFunc := startReadyChecker(ctx, tcpNode, eth2Cl, peerIDs, clockwork.NewRealClock(),
+	readyErrFunc := startReadyChecker(ctx, p2pNode, eth2Cl, peerIDs, clockwork.NewRealClock(),
 		pubkeys, seenPubkeys, vapiCalls)
 
 	mux.HandleFunc("/readyz", func(w http.ResponseWriter, _ *http.Request) {
@@ -119,7 +119,7 @@ func wireMonitoringAPI(ctx context.Context, life *lifecycle.Manager, promAddr, d
 }
 
 // startReadyChecker returns function which returns an error resulting from ready checks periodically.
-func startReadyChecker(ctx context.Context, tcpNode host.Host, eth2Cl eth2wrap.Client, peerIDs []peer.ID,
+func startReadyChecker(ctx context.Context, p2pNode host.Host, eth2Cl eth2wrap.Client, peerIDs []peer.ID,
 	clock clockwork.Clock, pubkeys []core.PubKey, seenPubkeys <-chan core.PubKey, vapiCalls <-chan struct{},
 ) func() error {
 	const minNotConnected = 6 // Require 6 rounds (1min) of too few connected
@@ -154,7 +154,7 @@ func startReadyChecker(ctx context.Context, tcpNode host.Host, eth2Cl eth2wrap.C
 		ticker := clock.NewTicker(slotDuration)
 		peerCountTicker := clock.NewTicker(1 * time.Minute)
 
-		var bnPeerCount *int // Beacon node peer count value which is queried every minute
+		var bnPeerCount *uint64 // Beacon node peer count value which is queried every minute
 
 		currVAPICount := 0
 		prevVAPICount := 1 // Assume connected.
@@ -167,18 +167,18 @@ func startReadyChecker(ctx context.Context, tcpNode host.Host, eth2Cl eth2wrap.C
 
 		// beaconNodePeerCount queries beacon node peer count and sets the peer count gauge if err is nil.
 		beaconNodePeerCount := func() {
-			peerCount, err := eth2Cl.NodePeerCount(ctx)
+			peerCount, err := eth2Cl.NodePeerCount(ctx, &eth2api.NodePeerCountOpts{})
 			if err != nil {
 				log.Warn(ctx, "Failed to get beacon node peer count", err)
 				return
 			}
 
 			if bnPeerCount == nil {
-				bnPeerCount = new(int)
+				bnPeerCount = new(uint64)
 			}
 
-			bnPeerCount = &peerCount
-			beaconNodePeerCountGauge.Set(float64(peerCount))
+			bnPeerCount = &peerCount.Data.Connected
+			beaconNodePeerCountGauge.Set(float64(peerCount.Data.Connected))
 		}
 
 		for {
@@ -188,7 +188,7 @@ func startReadyChecker(ctx context.Context, tcpNode host.Host, eth2Cl eth2wrap.C
 			case <-peerCountTicker.Chan():
 				beaconNodePeerCount()
 			case <-ticker.Chan():
-				if quorumPeersConnected(peerIDs, tcpNode) {
+				if quorumPeersConnected(peerIDs, p2pNode) {
 					notConnectedRounds = 0
 				} else {
 					notConnectedRounds++
@@ -304,15 +304,15 @@ func beaconNodeVersionMetric(ctx context.Context, eth2Cl eth2wrap.Client, clock
 }
 
 // quorumPeersConnected returns true if quorum peers are currently connected.
-func quorumPeersConnected(peerIDs []peer.ID, tcpNode host.Host) bool {
+func quorumPeersConnected(peerIDs []peer.ID, p2pNode host.Host) bool {
 	var count int
 
 	for _, pID := range peerIDs {
-		if tcpNode.ID() == pID {
+		if p2pNode.ID() == pID {
 			continue // Don't check self
 		}
 
-		if len(tcpNode.Network().ConnsToPeer(pID)) > 0 {
+		if len(p2pNode.Network().ConnsToPeer(pID)) > 0 {
 			count++
 		}
 	}
diff --git a/app/obolapi/api.go b/app/obolapi/api.go
index 1e7391f1..3ab836e5 100644
--- a/app/obolapi/api.go
+++ b/app/obolapi/api.go
@@ -118,8 +118,6 @@ func httpGet(ctx context.Context, url *url.URL, headers map[string]string) (io.R
 		return nil, errors.Wrap(err, "new GET request with ctx")
 	}
 
-	req.Header.Add("Content-Type", "application/json")
-
 	for key, val := range headers {
 		req.Header.Set(key, val)
 	}
@@ -144,3 +142,30 @@ 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 {
+	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url.String(), nil)
+	if err != nil {
+		return errors.Wrap(err, "new DELETE request with ctx")
+	}
+
+	for key, val := range headers {
+		req.Header.Set(key, val)
+	}
+
+	res, err := new(http.Client).Do(req)
+	if err != nil {
+		return errors.Wrap(err, "call DELETE endpoint")
+	}
+	defer res.Body.Close()
+
+	if res.StatusCode/100 != 2 {
+		if res.StatusCode == http.StatusNotFound {
+			return ErrNoExit
+		}
+
+		return errors.New("http DELETE failed", z.Int("status", res.StatusCode))
+	}
+
+	return nil
+}
diff --git a/app/obolapi/exit.go b/app/obolapi/exit.go
index e7c0de10..358a28cd 100644
--- a/app/obolapi/exit.go
+++ b/app/obolapi/exit.go
@@ -23,33 +23,44 @@ import (
 )
 
 const (
-	lockHashPath     = "{lock_hash}"
-	valPubkeyPath    = "{validator_pubkey}"
-	shareIndexPath   = "{share_index}"
-	fullExitBaseTmpl = "/exp/exit"
-	fullExitEndTmp   = "/" + lockHashPath + "/" + shareIndexPath + "/" + valPubkeyPath
-
-	partialExitTmpl = "/exp/partial_exits/" + lockHashPath
-	fullExitTmpl    = fullExitBaseTmpl + fullExitEndTmp
+	lockHashPath   = "{lock_hash}"
+	valPubkeyPath  = "{validator_pubkey}"
+	shareIndexPath = "{share_index}"
+
+	submitPartialExitTmpl = "/exp/partial_exits/" + lockHashPath
+	deletePartialExitTmpl = "/exp/partial_exits/" + lockHashPath + "/" + shareIndexPath + "/" + valPubkeyPath
+	fetchFullExitTmpl     = "/exp/exit/" + lockHashPath + "/" + shareIndexPath + "/" + valPubkeyPath
 )
 
 var ErrNoExit = errors.New("no exit for the given validator public key")
 
-// partialExitURL returns the partial exit Obol API URL for a given lock hash.
-func partialExitURL(lockHash string) string {
+// bearerString returns the bearer token authentication string given a token.
+func bearerString(data []byte) string {
+	return fmt.Sprintf("Bearer %#x", data)
+}
+
+// submitPartialExitURL returns the partial exit Obol API URL for a given lock hash.
+func submitPartialExitURL(lockHash string) string {
 	return strings.NewReplacer(
 		lockHashPath,
 		lockHash,
-	).Replace(partialExitTmpl)
+	).Replace(submitPartialExitTmpl)
 }
 
-// bearerString returns the bearer token authentication string given a token.
-func bearerString(data []byte) string {
-	return fmt.Sprintf("Bearer %#x", data)
+// deletePartialExitURL returns the full exit Obol API URL for a given validator public key.
+func deletePartialExitURL(valPubkey, lockHash string, shareIndex uint64) string {
+	return strings.NewReplacer(
+		valPubkeyPath,
+		valPubkey,
+		lockHashPath,
+		lockHash,
+		shareIndexPath,
+		strconv.FormatUint(shareIndex, 10),
+	).Replace(deletePartialExitTmpl)
 }
 
-// fullExitURL returns the full exit Obol API URL for a given validator public key.
-func fullExitURL(valPubkey, lockHash string, shareIndex uint64) string {
+// fetchFullExitURL returns the full exit Obol API URL for a given validator public key.
+func fetchFullExitURL(valPubkey, lockHash string, shareIndex uint64) string {
 	return strings.NewReplacer(
 		valPubkeyPath,
 		valPubkey,
@@ -57,7 +68,7 @@ func fullExitURL(valPubkey, lockHash string, shareIndex uint64) string {
 		lockHash,
 		shareIndexPath,
 		strconv.FormatUint(shareIndex, 10),
-	).Replace(fullExitTmpl)
+	).Replace(fetchFullExitTmpl)
 }
 
 // PostPartialExits POSTs the set of msg's to the Obol API, for a given lock hash.
@@ -65,7 +76,7 @@ func fullExitURL(valPubkey, lockHash string, shareIndex uint64) string {
 func (c Client) PostPartialExits(ctx context.Context, lockHash []byte, shareIndex uint64, identityKey *k1.PrivateKey, exitBlobs ...ExitBlob) error {
 	lockHashStr := "0x" + hex.EncodeToString(lockHash)
 
-	path := partialExitURL(lockHashStr)
+	path := submitPartialExitURL(lockHashStr)
 
 	u, err := url.ParseRequestURI(c.baseURL)
 	if err != nil {
@@ -121,7 +132,7 @@ func (c Client) GetFullExit(ctx context.Context, valPubkey string, lockHash []by
 		return ExitBlob{}, errors.Wrap(err, "validator pubkey to bytes")
 	}
 
-	path := fullExitURL(valPubkey, "0x"+hex.EncodeToString(lockHash), shareIndex)
+	path := fetchFullExitURL(valPubkey, "0x"+hex.EncodeToString(lockHash), shareIndex)
 
 	u, err := url.ParseRequestURI(c.baseURL)
 	if err != nil {
@@ -209,3 +220,48 @@ func (c Client) GetFullExit(ctx context.Context, valPubkey string, lockHash []by
 		},
 	}, nil
 }
+
+// DeletePartialExit deletes the partial exit message for a given validator public key, lock hash and share index.
+// It respects the timeout specified in the Client instance.
+func (c Client) DeletePartialExit(ctx context.Context, valPubkey string, lockHash []byte, shareIndex uint64, identityKey *k1.PrivateKey) error {
+	valPubkeyBytes, err := from0x(valPubkey, 48) // public key is 48 bytes long
+	if err != nil {
+		return errors.Wrap(err, "validator pubkey to bytes")
+	}
+
+	path := deletePartialExitURL(valPubkey, "0x"+hex.EncodeToString(lockHash), shareIndex)
+
+	u, err := url.ParseRequestURI(c.baseURL)
+	if err != nil {
+		return errors.Wrap(err, "bad Obol API url")
+	}
+
+	u.Path = path
+
+	ctx, cancel := context.WithTimeout(ctx, c.reqTimeout)
+	defer cancel()
+
+	exitAuthData := FullExitAuthBlob{
+		LockHash:        lockHash,
+		ValidatorPubkey: valPubkeyBytes,
+		ShareIndex:      shareIndex,
+	}
+
+	exitAuthDataRoot, err := exitAuthData.HashTreeRoot()
+	if err != nil {
+		return errors.Wrap(err, "exit auth data root")
+	}
+
+	// sign the lockHash *bytes* with identity key
+	lockHashSignature, err := k1util.Sign(identityKey, exitAuthDataRoot[:])
+	if err != nil {
+		return errors.Wrap(err, "k1 sign")
+	}
+
+	err = httpDelete(ctx, u, map[string]string{"Authorization": bearerString(lockHashSignature)})
+	if err != nil {
+		return errors.Wrap(err, "http Obol API DELETE request")
+	}
+
+	return nil
+}
diff --git a/app/peerinfo/adhoc.go b/app/peerinfo/adhoc.go
index d4f55b30..8d0c846f 100644
--- a/app/peerinfo/adhoc.go
+++ b/app/peerinfo/adhoc.go
@@ -16,7 +16,7 @@ import (
 // DoOnce returns the peer info and RTT and true of the given peer,
 // or false if the peer doesn't support the protocol,
 // or an error.
-func DoOnce(ctx context.Context, tcpNode host.Host, peerID peer.ID) (*pbv1.PeerInfo, time.Duration, bool, error) {
+func DoOnce(ctx context.Context, p2pNode host.Host, peerID peer.ID) (*pbv1.PeerInfo, time.Duration, bool, error) {
 	var rtt time.Duration
 
 	rttCallback := func(d time.Duration) {
@@ -26,7 +26,7 @@ func DoOnce(ctx context.Context, tcpNode host.Host, peerID peer.ID) (*pbv1.PeerI
 	req := new(pbv1.PeerInfo)
 	resp := new(pbv1.PeerInfo)
 
-	err := p2p.SendReceive(ctx, tcpNode, peerID, req, resp, protocolID2,
+	err := p2p.SendReceive(ctx, p2pNode, peerID, req, resp, protocolID2,
 		p2p.WithSendReceiveRTT(rttCallback))
 	if err != nil {
 		return nil, 0, false, err
diff --git a/app/peerinfo/peerinfo.go b/app/peerinfo/peerinfo.go
index 49502ed5..6eb87b5f 100644
--- a/app/peerinfo/peerinfo.go
+++ b/app/peerinfo/peerinfo.go
@@ -44,11 +44,11 @@ type (
 )
 
 // New returns a new peer info protocol instance.
-func New(tcpNode host.Host, peers []peer.ID, version version.SemVer, lockHash []byte, gitHash string,
+func New(p2pNode host.Host, peers []peer.ID, version version.SemVer, lockHash []byte, gitHash string,
 	sendFunc p2p.SendReceiveFunc, builderEnabled bool, nickname string,
 ) *PeerInfo {
 	// Set own version, git hash and nickname and start time and metrics.
-	name := p2p.PeerName(tcpNode.ID())
+	name := p2p.PeerName(p2pNode.ID())
 	peerVersion.WithLabelValues(name, version.String()).Set(1)
 	peerGitHash.WithLabelValues(name, gitHash).Set(1)
 	peerNickname.WithLabelValues(name, nickname).Set(1)
@@ -69,22 +69,22 @@ func New(tcpNode host.Host, peers []peer.ID, version version.SemVer, lockHash []
 		return ticker.C, ticker.Stop
 	}
 
-	return newInternal(tcpNode, peers, version, lockHash, gitHash, sendFunc, p2p.RegisterHandler,
+	return newInternal(p2pNode, peers, version, lockHash, gitHash, sendFunc, p2p.RegisterHandler,
 		tickerProvider, time.Now, newMetricsSubmitter(), builderEnabled, nickname)
 }
 
 // NewForT returns a new peer info protocol instance for testing only.
-func NewForT(_ *testing.T, tcpNode host.Host, peers []peer.ID, version version.SemVer, lockHash []byte, gitHash string,
+func NewForT(_ *testing.T, p2pNode host.Host, peers []peer.ID, version version.SemVer, lockHash []byte, gitHash string,
 	sendFunc p2p.SendReceiveFunc, registerHandler p2p.RegisterHandlerFunc,
 	tickerProvider tickerProvider, nowFunc nowFunc, metricSubmitter metricSubmitter,
 	builderAPIEnabled bool, nickname string,
 ) *PeerInfo {
-	return newInternal(tcpNode, peers, version, lockHash, gitHash, sendFunc, registerHandler,
+	return newInternal(p2pNode, peers, version, lockHash, gitHash, sendFunc, registerHandler,
 		tickerProvider, nowFunc, metricSubmitter, builderAPIEnabled, nickname)
 }
 
 // newInternal returns a new instance for New or NewForT.
-func newInternal(tcpNode host.Host, peers []peer.ID, version version.SemVer, lockHash []byte, gitHash string,
+func newInternal(p2pNode host.Host, peers []peer.ID, version version.SemVer, lockHash []byte, gitHash string,
 	sendFunc p2p.SendReceiveFunc, registerHandler p2p.RegisterHandlerFunc,
 	tickerProvider tickerProvider, nowFunc nowFunc, metricSubmitter metricSubmitter,
 	builderAPIEnabled bool, nickname string,
@@ -92,7 +92,7 @@ func newInternal(tcpNode host.Host, peers []peer.ID, version version.SemVer, loc
 	startTime := timestamppb.New(nowFunc())
 
 	// Register a simple handler that returns our info and ignores the request.
-	registerHandler("peerinfo", tcpNode, protocolID2,
+	registerHandler("peerinfo", p2pNode, protocolID2,
 		func() proto.Message { return new(pbv1.PeerInfo) },
 		func(context.Context, peer.ID, proto.Message) (proto.Message, bool, error) {
 			return &pbv1.PeerInfo{
@@ -108,7 +108,7 @@ func newInternal(tcpNode host.Host, peers []peer.ID, version version.SemVer, loc
 	)
 
 	// Maps peers to their nickname
-	nicknames := map[string]string{p2p.PeerName(tcpNode.ID()): nickname}
+	nicknames := map[string]string{p2p.PeerName(p2pNode.ID()): nickname}
 
 	// Create log filters
 	lockHashFilters := make(map[peer.ID]z.Field)
@@ -121,7 +121,7 @@ func newInternal(tcpNode host.Host, peers []peer.ID, version version.SemVer, loc
 
 	return &PeerInfo{
 		sendFunc:          sendFunc,
-		tcpNode:           tcpNode,
+		p2pNode:           p2pNode,
 		peers:             peers,
 		version:           version,
 		lockHash:          lockHash,
@@ -138,7 +138,7 @@ func newInternal(tcpNode host.Host, peers []peer.ID, version version.SemVer, loc
 
 type PeerInfo struct {
 	sendFunc          p2p.SendReceiveFunc
-	tcpNode           host.Host
+	p2pNode           host.Host
 	peers             []peer.ID
 	version           version.SemVer
 	lockHash          []byte
@@ -174,7 +174,7 @@ func (p *PeerInfo) Run(ctx context.Context) {
 // sendOnce sends one peerinfo request/response pair to each peer.
 func (p *PeerInfo) sendOnce(ctx context.Context, now time.Time) {
 	for _, peerID := range p.peers {
-		if peerID == p.tcpNode.ID() {
+		if peerID == p.p2pNode.ID() {
 			continue // Do not send to self.
 		}
 
@@ -185,7 +185,7 @@ func (p *PeerInfo) sendOnce(ctx context.Context, now time.Time) {
 			SentAt:            timestamppb.New(now),
 			StartedAt:         p.startTime,
 			BuilderApiEnabled: p.builderAPIEnabled,
-			Nickname:          p.nicknames[p2p.PeerName(p.tcpNode.ID())],
+			Nickname:          p.nicknames[p2p.PeerName(p.p2pNode.ID())],
 		}
 
 		go func(peerID peer.ID) {
@@ -197,7 +197,7 @@ func (p *PeerInfo) sendOnce(ctx context.Context, now time.Time) {
 
 			resp := new(pbv1.PeerInfo)
 
-			err := p.sendFunc(ctx, p.tcpNode, peerID, req, resp, protocolID2, p2p.WithSendReceiveRTT(rttCallback))
+			err := p.sendFunc(ctx, p.p2pNode, peerID, req, resp, protocolID2, p2p.WithSendReceiveRTT(rttCallback))
 			if err != nil {
 				return // Logging handled by send func.
 			} else if resp.GetSentAt() == nil || resp.GetStartedAt() == nil {
diff --git a/app/version/version.go b/app/version/version.go
index 22adcb36..cc0d7063 100644
--- a/app/version/version.go
+++ b/app/version/version.go
@@ -55,6 +55,7 @@ func GitCommit() (hash string, timestamp string) {
 			hash = s.Value[:hashLen]
 		case "vcs.time":
 			timestamp = s.Value
+		default:
 		}
 	}
 
diff --git a/cmd/addvalidators.go b/cmd/addvalidators.go
deleted file mode 100644
index db69d395..00000000
--- a/cmd/addvalidators.go
+++ /dev/null
@@ -1,601 +0,0 @@
-// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
-
-package cmd
-
-import (
-	"bytes"
-	"context"
-	"crypto/rand"
-	"encoding/hex"
-	"encoding/json"
-	"fmt"
-	"os"
-	"path"
-	"path/filepath"
-	"strings"
-	"time"
-
-	eth2v1 "github.com/attestantio/go-eth2-client/api/v1"
-	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
-	k1 "github.com/decred/dcrd/dcrec/secp256k1/v4"
-	"github.com/spf13/cobra"
-
-	"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/cluster/manifest"
-	manifestpb "github.com/obolnetwork/charon/cluster/manifestpb/v1"
-	"github.com/obolnetwork/charon/core"
-	"github.com/obolnetwork/charon/eth2util"
-	"github.com/obolnetwork/charon/eth2util/deposit"
-	"github.com/obolnetwork/charon/eth2util/enr"
-	"github.com/obolnetwork/charon/eth2util/keystore"
-	"github.com/obolnetwork/charon/eth2util/registration"
-	"github.com/obolnetwork/charon/tbls"
-	"github.com/obolnetwork/charon/tbls/tblsconv"
-)
-
-// addValidatorsConfig is config for the `add-validators` command.
-type addValidatorsConfig struct {
-	NumVals           int        // No of validators to add
-	WithdrawalAddrs   []string   // Withdrawal address of each validator
-	FeeRecipientAddrs []string   // Fee recipient address of each validator
-	Log               log.Config // Config for logging
-
-	Lockfile     string // Path to the legacy cluster lock file
-	ManifestFile string // Path to the cluster manifest file
-	ClusterDir   string // Path to the cluster directory
-
-	TestConfig addValidatorTestConfig
-}
-
-// addValidatorTestConfig defines additional test-only config.
-type addValidatorTestConfig struct {
-	// Lock provides the lock explicitly, skips loading from disk.
-	Lock *cluster.Lock
-	// Manifest provides the cluster manifest explicitly, skips loading from disk.
-	ClusterDAG *manifestpb.SignedMutationList
-	// P2PKeys provides the p2p private keys explicitly, skips loading keystores from disk.
-	P2PKeys []*k1.PrivateKey
-}
-
-func newAddValidatorsCmd(runFunc func(context.Context, addValidatorsConfig) error) *cobra.Command {
-	var config addValidatorsConfig
-
-	cmd := &cobra.Command{
-		Use:   "add-validators-solo",
-		Short: "Creates and adds new validators to a solo distributed validator cluster",
-		Long:  `Creates and adds new validators to a distributed validator cluster. It generates keys for the new validators and also generates a new cluster manifest file with the legacy_lock and add_validators mutations. It is executed by a solo operator cluster.`,
-		Args:  cobra.NoArgs,
-		RunE: func(cmd *cobra.Command, args []string) error { //nolint:revive // keep args variable name for clarity
-			if err := log.InitLogger(config.Log); err != nil {
-				return err
-			}
-
-			return runFunc(cmd.Context(), config)
-		},
-	}
-
-	bindAddValidatorsFlags(cmd, &config)
-	bindLogFlags(cmd.Flags(), &config.Log)
-
-	return cmd
-}
-
-// bindAddValidatorsFlags binds command line flags for the `add-validators` command.
-func bindAddValidatorsFlags(cmd *cobra.Command, config *addValidatorsConfig) {
-	cmd.Flags().IntVar(&config.NumVals, "num-validators", 1, "The count of new distributed validators to add in the cluster.")
-	cmd.Flags().StringSliceVar(&config.FeeRecipientAddrs, "fee-recipient-addresses", nil, "Comma separated list of Ethereum addresses of the fee recipient for each new validator. Either provide a single fee recipient address or fee recipient addresses for each validator.")
-	cmd.Flags().StringSliceVar(&config.WithdrawalAddrs, "withdrawal-addresses", nil, "Comma separated list of Ethereum addresses to receive the returned stake and accrued rewards for each new validator. Either provide a single withdrawal address or withdrawal addresses for each validator.")
-	cmd.Flags().StringVar(&config.Lockfile, "lock-file", "cluster-lock.json", "The path to the legacy cluster lock file defining distributed validator cluster. If both cluster manifest and cluster lock files are provided, the cluster manifest file takes precedence.")
-	cmd.Flags().StringVar(&config.ManifestFile, "manifest-file", "cluster-manifest.pb", "The path to the cluster manifest file. If both cluster manifest and cluster lock files are provided, the cluster manifest file takes precedence.")
-	cmd.Flags().StringVar(&config.ClusterDir, "cluster-dir", "./", "The path to the charon cluster directory. This directory should contain directories for individual charon nodes, ie, node0, node1 and so on.")
-}
-
-func runAddValidatorsSolo(ctx context.Context, conf addValidatorsConfig) (err error) {
-	rawDAG, err := loadDAG(conf)
-	if err != nil {
-		return err
-	}
-
-	cluster, err := manifest.Materialise(rawDAG)
-	if err != nil {
-		return errors.Wrap(err, "materialise cluster dag")
-	}
-
-	nextKeystoreIdx := len(cluster.GetValidators())
-
-	log.Info(ctx, "Cluster manifest loaded",
-		z.Str("cluster_name", cluster.GetName()),
-		z.Str("cluster_hash", hex7(cluster.GetInitialMutationHash())),
-		z.Int("num_validators", len(cluster.GetValidators())))
-
-	if err = validateConf(conf); err != nil {
-		return errors.Wrap(err, "validate config")
-	}
-
-	p2pKeys, err := getP2PKeys(conf.ClusterDir, len(cluster.GetOperators()), conf.TestConfig)
-	if err != nil {
-		return errors.Wrap(err, "load p2p keys")
-	}
-
-	if err := validateP2PKeysOrder(p2pKeys, cluster.GetOperators()); err != nil {
-		return err
-	}
-
-	// If a single address is provided, use the same address for all the validators.
-	if len(conf.FeeRecipientAddrs) == 1 {
-		conf.FeeRecipientAddrs = repeatAddr(conf.FeeRecipientAddrs[0], conf.NumVals)
-		conf.WithdrawalAddrs = repeatAddr(conf.WithdrawalAddrs[0], conf.NumVals)
-	}
-
-	vals, secrets, shareSets, err := genNewVals(ctx, len(cluster.GetOperators()), int(cluster.GetThreshold()), cluster.GetForkVersion(), cluster.GetTargetGasLimit(), conf)
-	if err != nil {
-		return err
-	}
-
-	// Perform a `gen_validators/v0.0.1` mutation using the newly created validators.
-	genVals, err := manifest.NewGenValidators(cluster.GetLatestMutationHash(), vals)
-	if err != nil {
-		return errors.Wrap(err, "generate validators")
-	}
-
-	genValsHash, err := manifest.Hash(genVals)
-	if err != nil {
-		return errors.Wrap(err, "hash gen vals")
-	}
-
-	var approvals []*manifestpb.SignedMutation
-
-	for _, p2pKey := range p2pKeys {
-		// Perform individual `node_approval/v0.0.1` mutation using each operator's enr private key.
-		approval, err := manifest.SignNodeApproval(genValsHash, p2pKey)
-		if err != nil {
-			return err
-		}
-
-		approvals = append(approvals, approval)
-	}
-
-	// Perform a `node_approvals/v0.0.1` parallel composite mutation using above approvals.
-	nodeApprovals, err := manifest.NewNodeApprovalsComposite(approvals)
-	if err != nil {
-		return errors.Wrap(err, "node approvals")
-	}
-
-	// Perform a `add_validators/v0.0.1` linear composite mutation using `gen_validators` and `node_approvals` mutations.
-	addVals, err := manifest.NewAddValidators(genVals, nodeApprovals)
-	if err != nil {
-		return errors.Wrap(err, "add validators")
-	}
-
-	// Append the final `add_validators/v0.0.1` mutation to the raw DAG
-	rawDAG.Mutations = append(rawDAG.Mutations, addVals)
-
-	// Finally, perform a cluster transformation.
-	cluster, err = manifest.Transform(cluster, addVals)
-	if err != nil {
-		return errors.Wrap(err, "transform cluster manifest")
-	}
-
-	err = writeNewKeystores(conf.ClusterDir, len(cluster.GetOperators()), nextKeystoreIdx, shareSets)
-	if err != nil {
-		return err
-	}
-
-	err = writeDepositDatas(ctx, conf.ClusterDir, len(cluster.GetOperators()), secrets, vals, cluster.GetForkVersion())
-	if err != nil {
-		return err
-	}
-
-	// Save cluster manifests to disk
-	err = writeCluster(conf.ClusterDir, len(cluster.GetOperators()), rawDAG)
-	if err != nil {
-		return err
-	}
-
-	log.Debug(ctx, "Saved cluster manifest file to disk")
-
-	printPubkeys(ctx, vals)
-	log.Info(ctx, "Successfully added validators to cluster 🎉")
-
-	return nil
-}
-
-// builderRegistration returns a builder registration object using the provided inputs.
-func builderRegistration(ctx context.Context, secret tbls.PrivateKey, pubkey tbls.PublicKey, feeRecipientAddr string, forkVersion []byte, targetGasLimit uint32) (*eth2v1.SignedValidatorRegistration, error) {
-	timestamp, err := eth2util.ForkVersionToGenesisTime(forkVersion)
-	if err != nil {
-		return nil, errors.Wrap(err, "invalid fork version")
-	}
-
-	if targetGasLimit == 0 {
-		log.Warn(ctx, "", errors.New("custom target gas limit not supported, setting to default", z.Uint("default_gas_limit", registration.DefaultGasLimit)))
-		targetGasLimit = registration.DefaultGasLimit
-	}
-
-	reg, err := registration.NewMessage(
-		eth2p0.BLSPubKey(pubkey),
-		feeRecipientAddr,
-		uint64(targetGasLimit),
-		timestamp,
-	)
-	if err != nil {
-		return nil, errors.Wrap(err, "create registration message")
-	}
-
-	sigRoot, err := registration.GetMessageSigningRoot(reg, eth2p0.Version(forkVersion))
-	if err != nil {
-		return nil, errors.Wrap(err, "registration signing root")
-	}
-
-	sig, err := tbls.Sign(secret, sigRoot[:])
-	if err != nil {
-		return nil, errors.Wrap(err, "sign registration root")
-	}
-
-	return &eth2v1.SignedValidatorRegistration{
-		Message:   reg,
-		Signature: tblsconv.SigToETH2(sig),
-	}, nil
-}
-
-// writeDepositDatas creates deposit data for each validator and writes the deposit data to disk for each node.
-func writeDepositDatas(ctx context.Context, clusterDir string, numOps int, secrets []tbls.PrivateKey, vals []*manifestpb.Validator, forkVersion []byte) error {
-	network, err := eth2util.ForkVersionToNetwork(forkVersion)
-	if err != nil {
-		return errors.Wrap(err, "fork version to network")
-	}
-
-	var depositDatas []eth2p0.DepositData
-	for i, val := range vals {
-		depositMsg, err := deposit.NewMessage(eth2p0.BLSPubKey(val.GetPublicKey()), val.GetWithdrawalAddress(), deposit.DefaultDepositAmount, false)
-		if err != nil {
-			return errors.Wrap(err, "new deposit message")
-		}
-
-		sigRoot, err := deposit.GetMessageSigningRoot(depositMsg, network)
-		if err != nil {
-			return errors.Wrap(err, "get deposit message root")
-		}
-
-		sig, err := tbls.Sign(secrets[i], sigRoot[:])
-		if err != nil {
-			return errors.Wrap(err, "sign deposit message root")
-		}
-
-		depositDatas = append(depositDatas, eth2p0.DepositData{
-			PublicKey:             depositMsg.PublicKey,
-			WithdrawalCredentials: depositMsg.WithdrawalCredentials,
-			Amount:                depositMsg.Amount,
-			Signature:             eth2p0.BLSSignature(sig),
-		})
-	}
-
-	// Serialize the deposit data into bytes
-	bytes, err := deposit.MarshalDepositData(depositDatas, network)
-	if err != nil {
-		return err
-	}
-
-	currTime := time.Now().Format("20060102150405")
-
-	filename := fmt.Sprintf("deposit-data-%s.json", currTime) // Ex: "deposit-data-20060102150405.json"
-	for i := range numOps {
-		depositPath := filepath.Join(nodeDir(clusterDir, i), filename)
-		//nolint:gosec // File needs to be read-only for everybody
-		err = os.WriteFile(depositPath, bytes, 0o444)
-		if err != nil {
-			return errors.Wrap(err, "write deposit data")
-		}
-	}
-
-	log.Debug(ctx, "Saved deposit data file to disk", z.Str("deposit_data", filename))
-
-	return nil
-}
-
-// writeNewKeystores saves keystores for new validators to disk for each node. The new keystore files are saved
-// in ascending order, starting from the next available index after the last saved keystore in the validator_keys/
-// directory. For example, if the directory already contains keystores up to keystore-X.json, the new
-// keystores will be saved as keystore-(X+1).json, keystore-(X+2.json), and so on.
-func writeNewKeystores(clusterDir string, numOps, firstKeystoreIdx int, shareSets [][]tbls.PrivateKey) error {
-	for _, shares := range shareSets {
-		if len(shares) != numOps {
-			return errors.New("insufficient shares", z.Int("num_shares", len(shares)), z.Int("num_operators", numOps))
-		}
-	}
-
-	for i := range numOps {
-		var secrets []tbls.PrivateKey // All partial keys for node<i>
-		for _, shares := range shareSets {
-			secrets = append(secrets, shares[i])
-		}
-
-		for idx, secret := range secrets {
-			keysDir := path.Join(nodeDir(clusterDir, i), "/validator_keys")
-			filename := path.Join(keysDir, fmt.Sprintf("keystore-%d.json", firstKeystoreIdx+idx))
-
-			password, err := randomHex32()
-			if err != nil {
-				return err
-			}
-
-			store, err := keystore.Encrypt(secret, password, rand.Reader)
-			if err != nil {
-				return errors.Wrap(err, "encryption error", z.Str("filename", filename))
-			}
-
-			b, err := json.MarshalIndent(store, "", " ")
-			if err != nil {
-				return errors.Wrap(err, "marshal keystore", z.Str("filename", filename))
-			}
-
-			//nolint:gosec // File needs to be read-only for everybody
-			if err := os.WriteFile(filename, b, 0o444); err != nil {
-				return errors.Wrap(err, "write keystore", z.Str("filename", filename))
-			}
-
-			passwordFile := strings.Replace(filename, ".json", ".txt", 1)
-
-			err = os.WriteFile(passwordFile, []byte(password), 0o400)
-			if err != nil {
-				return errors.Wrap(err, "write password file")
-			}
-		}
-	}
-
-	return nil
-}
-
-// validateConf returns an error if the provided validators config fails validation checks.
-func validateConf(conf addValidatorsConfig) error {
-	if conf.NumVals <= 0 {
-		return errors.New("insufficient validator count", z.Int("validators", conf.NumVals))
-	}
-
-	if len(conf.FeeRecipientAddrs) == 0 {
-		return errors.New("empty fee recipient addresses")
-	}
-
-	if len(conf.WithdrawalAddrs) == 0 {
-		return errors.New("empty withdrawal addresses")
-	}
-
-	if len(conf.FeeRecipientAddrs) != len(conf.WithdrawalAddrs) {
-		return errors.New("fee recipient and withdrawal addresses lengths mismatch",
-			z.Int("fee_recipients", len(conf.FeeRecipientAddrs)),
-			z.Int("withdrawal_addresses", len(conf.WithdrawalAddrs)),
-		)
-	}
-
-	if conf.NumVals > 1 {
-		// There can be a single address for n validators.
-		if len(conf.FeeRecipientAddrs) == 1 {
-			return nil
-		}
-
-		// Or, there can be n addresses for n validators.
-		if conf.NumVals != len(conf.FeeRecipientAddrs) {
-			return errors.New("count of validators and addresses mismatch", z.Int("num_addresses", len(conf.FeeRecipientAddrs)), z.Int("num_validators", conf.NumVals))
-		}
-
-		return nil
-	}
-
-	// There can only be a single address for a single validator.
-	if len(conf.FeeRecipientAddrs) != 1 {
-		return errors.New("count of validators and addresses mismatch", z.Int("num_addresses", len(conf.FeeRecipientAddrs)), z.Int("num_validators", conf.NumVals))
-	}
-
-	return nil
-}
-
-// genNewVals returns a list of new validators, their corresponding private keys and threshold keyshares from the provided config.
-func genNewVals(ctx context.Context, numOps, threshold int, forkVersion []byte, targetGasLimit uint32, conf addValidatorsConfig) ([]*manifestpb.Validator, []tbls.PrivateKey, [][]tbls.PrivateKey, error) {
-	// Generate new validators
-	var (
-		vals      []*manifestpb.Validator
-		secrets   []tbls.PrivateKey
-		shareSets [][]tbls.PrivateKey
-	)
-
-	for i := range conf.NumVals {
-		// Generate private/public keypair
-		secret, err := tbls.GenerateSecretKey()
-		if err != nil {
-			return nil, nil, nil, errors.Wrap(err, "generate secret key")
-		}
-
-		secrets = append(secrets, secret)
-
-		pubkey, err := tbls.SecretToPublicKey(secret)
-		if err != nil {
-			return nil, nil, nil, errors.Wrap(err, "generate public key")
-		}
-
-		// Split private key and generate public keyshares
-		shares, err := tbls.ThresholdSplit(secret, uint(numOps), uint(threshold))
-		if err != nil {
-			return nil, nil, nil, errors.Wrap(err, "threshold split key")
-		}
-
-		// Preserve order when transforming from map of private shares to array of private keys.
-		secretSet := make([]tbls.PrivateKey, len(shares))
-		for j := 1; j <= len(shares); j++ {
-			secretSet[j-1] = shares[j]
-		}
-
-		shareSets = append(shareSets, secretSet)
-
-		var pubshares [][]byte
-
-		for _, share := range shares {
-			pubshare, err := tbls.SecretToPublicKey(share)
-			if err != nil {
-				return nil, nil, nil, errors.Wrap(err, "generate public key")
-			}
-
-			pubshares = append(pubshares, pubshare[:])
-		}
-
-		feeRecipientAddr, err := eth2util.ChecksumAddress(conf.FeeRecipientAddrs[i])
-		if err != nil {
-			return nil, nil, nil, errors.Wrap(err, "invalid fee recipient address")
-		}
-
-		withdrawalAddr, err := eth2util.ChecksumAddress(conf.WithdrawalAddrs[i])
-		if err != nil {
-			return nil, nil, nil, errors.Wrap(err, "invalid withdrawal address")
-		}
-
-		// Generate builder registration
-		builderReg, err := builderRegistration(ctx, secret, pubkey, feeRecipientAddr, forkVersion, targetGasLimit)
-		if err != nil {
-			return nil, nil, nil, err
-		}
-
-		builderRegJSON, err := json.Marshal(builderReg)
-		if err != nil {
-			return nil, nil, nil, errors.Wrap(err, "marshal builder registration")
-		}
-
-		vals = append(vals, &manifestpb.Validator{
-			PublicKey:               pubkey[:],
-			PubShares:               pubshares,
-			FeeRecipientAddress:     feeRecipientAddr,
-			WithdrawalAddress:       withdrawalAddr,
-			BuilderRegistrationJson: builderRegJSON,
-		})
-	}
-
-	return vals, secrets, shareSets, nil
-}
-
-// loadDAG returns the raw DAG from the provided config.
-func loadDAG(conf addValidatorsConfig) (*manifestpb.SignedMutationList, error) {
-	var (
-		rawDAG *manifestpb.SignedMutationList
-		err    error
-	)
-	if conf.TestConfig.ClusterDAG != nil {
-		rawDAG = conf.TestConfig.ClusterDAG
-	} else if conf.TestConfig.Lock != nil {
-		rawDAG, err = manifest.NewDAGFromLockForT(nil, *conf.TestConfig.Lock)
-		if err != nil {
-			return nil, err
-		}
-	} else {
-		rawDAG, err = loadDAGFromDisk(conf.ManifestFile, conf.Lockfile)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	return rawDAG, nil
-}
-
-// getP2PKeys returns a list of p2p private keys either by loading from disk or from test config.
-func getP2PKeys(clusterDir string, numOps int, testConfig addValidatorTestConfig) ([]*k1.PrivateKey, error) {
-	if len(testConfig.P2PKeys) > 0 {
-		return testConfig.P2PKeys, nil
-	}
-
-	var p2pKeys []*k1.PrivateKey
-
-	for i := range numOps {
-		dir := path.Join(clusterDir, fmt.Sprintf("node%d", i))
-		enrKeyFile := path.Join(dir, "charon-enr-private-key")
-
-		p2pKey, err := k1util.Load(enrKeyFile)
-		if err != nil {
-			return nil, errors.Wrap(err, "load enr private key")
-		}
-
-		p2pKeys = append(p2pKeys, p2pKey)
-	}
-
-	return p2pKeys, nil
-}
-
-// validateP2PKeysOrder ensures that the provided p2p private keys are ordered correctly by peer index.
-func validateP2PKeysOrder(p2pKeys []*k1.PrivateKey, ops []*manifestpb.Operator) error {
-	var enrs []string
-	for _, enrStr := range ops {
-		enrs = append(enrs, enrStr.GetEnr())
-	}
-
-	if len(p2pKeys) != len(enrs) {
-		return errors.New("length of p2p keys and enrs don't match", z.Int("p2pkeys", len(p2pKeys)), z.Int("enrs", len(enrs)))
-	}
-
-	for i, enrStr := range enrs {
-		record, err := enr.Parse(enrStr)
-		if err != nil {
-			return err
-		}
-
-		if !bytes.Equal(p2pKeys[i].PubKey().SerializeCompressed(), record.PubKey.SerializeCompressed()) {
-			return errors.New("invalid p2p key order", z.Int("peer_index", i))
-		}
-	}
-
-	return nil
-}
-
-// repeatAddr repeats the same address for all the validators.
-func repeatAddr(addr string, numVals int) []string {
-	var addrs []string
-	for range numVals {
-		addrs = append(addrs, addr)
-	}
-
-	return addrs
-}
-
-// hex7 returns the first 7 (or less) hex chars of the provided bytes.
-func hex7(input []byte) string {
-	resp := hex.EncodeToString(input)
-	if len(resp) <= 7 {
-		return resp
-	}
-
-	return resp[:7]
-}
-
-// printPubkeys prints pubkeys of the newly added validators.
-func printPubkeys(ctx context.Context, vals []*manifestpb.Validator) {
-	for _, val := range vals {
-		pk, err := shortPubkey(val.GetPublicKey())
-		if err != nil {
-			log.Error(ctx, "Cannot log validator public key", err)
-			continue
-		}
-
-		log.Debug(ctx, "Created new validator", z.Str("pubkey", pk))
-	}
-}
-
-// shortPubkey returns the short version of the input public key bytes.
-func shortPubkey(input []byte) (string, error) {
-	pubkey, err := core.PubKeyFromBytes(input)
-	if err != nil {
-		return "", errors.Wrap(err, "pubkey from bytes")
-	}
-
-	return pubkey.String(), nil
-}
-
-// randomHex32 returns a random 32 character hex string. It uses crypto/rand.
-func randomHex32() (string, error) {
-	b := make([]byte, 16)
-
-	_, err := rand.Read(b)
-	if err != nil {
-		return "", errors.Wrap(err, "read random")
-	}
-
-	return hex.EncodeToString(b), nil
-}
diff --git a/cmd/cmd.go b/cmd/cmd.go
index f4767a7e..94b4d0e9 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -47,7 +47,6 @@ func New() *cobra.Command {
 		),
 		newCombineCmd(newCombineFunc),
 		newAlphaCmd(
-			newAddValidatorsCmd(runAddValidatorsSolo),
 			newViewClusterManifestCmd(runViewClusterManifest),
 			newTestCmd(
 				newTestAllCmd(runTestAll),
@@ -63,6 +62,7 @@ func New() *cobra.Command {
 			newSignPartialExitCmd(runSignPartialExit),
 			newBcastFullExitCmd(runBcastFullExit),
 			newFetchExitCmd(runFetchExit),
+			newDeleteExitCmd(runDeleteExit),
 		),
 		newUnsafeCmd(newRunCmd(app.Run, true)),
 	)
diff --git a/cmd/exit.go b/cmd/exit.go
index 02f2f17e..c0e01206 100644
--- a/cmd/exit.go
+++ b/cmd/exit.go
@@ -146,6 +146,7 @@ func bindExitFlags(cmd *cobra.Command, config *exitConfig, flags []exitCLIFlag)
 			return s
 		}
 
+		//nolint:revive // enforce-switch-style: the list is exhaustive and there is no need for default
 		switch flag {
 		case publishAddress:
 			cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech/v1", maybeRequired("The URL of the remote API."))
diff --git a/cmd/exit_delete.go b/cmd/exit_delete.go
new file mode 100644
index 00000000..1d538e4a
--- /dev/null
+++ b/cmd/exit_delete.go
@@ -0,0 +1,139 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package cmd
+
+import (
+	"context"
+	"fmt"
+
+	libp2plog "github.com/ipfs/go-log/v2"
+	"github.com/spf13/cobra"
+
+	"github.com/obolnetwork/charon/app/errors"
+	"github.com/obolnetwork/charon/app/k1util"
+	"github.com/obolnetwork/charon/app/log"
+	"github.com/obolnetwork/charon/app/obolapi"
+	"github.com/obolnetwork/charon/app/z"
+	"github.com/obolnetwork/charon/core"
+	"github.com/obolnetwork/charon/eth2util"
+	"github.com/obolnetwork/charon/eth2util/keystore"
+)
+
+func newDeleteExitCmd(runFunc func(context.Context, exitConfig) error) *cobra.Command {
+	var config exitConfig
+
+	cmd := &cobra.Command{
+		Use:   "delete",
+		Short: "Delete a signed exit message from the remote API",
+		Long:  `Deletes a partially signed exit message for a given validator from the remote API.`,
+		Args:  cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			if err := log.InitLogger(config.Log); err != nil {
+				return err
+			}
+			libp2plog.SetPrimaryCore(log.LoggerCore()) // Set libp2p logger to use charon logger
+
+			printFlags(cmd.Context(), cmd.Flags())
+
+			return runFunc(cmd.Context(), config)
+		},
+	}
+
+	bindExitFlags(cmd, &config, []exitCLIFlag{
+		{publishAddress, false},
+		{privateKeyPath, false},
+		{lockFilePath, false},
+		{validatorPubkey, false},
+		{all, false},
+		{publishTimeout, false},
+		{testnetName, false},
+		{testnetForkVersion, false},
+		{testnetChainID, false},
+		{testnetGenesisTimestamp, false},
+		{testnetCapellaHardFork, false},
+	})
+
+	bindLogFlags(cmd.Flags(), &config.Log)
+
+	wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error {
+		valPubkPresent := cmd.Flags().Lookup(validatorPubkey.String()).Changed
+
+		if !valPubkPresent && !config.All {
+			//nolint:revive,perfsprint // we use our own version of the errors package; keep consistency with other checks.
+			return errors.New(fmt.Sprintf("%s must be specified when exiting single validator.", validatorPubkey.String()))
+		}
+
+		if config.All && valPubkPresent {
+			//nolint:revive // we use our own version of the errors package.
+			return errors.New(fmt.Sprintf("%s should not be specified when %s is, as it is obsolete and misleading.", validatorPubkey.String(), all.String()))
+		}
+
+		return nil
+	})
+
+	return cmd
+}
+
+func runDeleteExit(ctx context.Context, config exitConfig) error {
+	// Check if custom testnet configuration is provided.
+	if config.testnetConfig.IsNonZero() {
+		// Add testnet config to supported networks.
+		eth2util.AddTestNetwork(config.testnetConfig)
+	}
+
+	identityKey, err := k1util.Load(config.PrivateKeyPath)
+	if err != nil {
+		return errors.Wrap(err, "load identity key", z.Str("private_key_path", config.PrivateKeyPath))
+	}
+
+	cl, err := loadClusterManifest("", config.LockFilePath)
+	if err != nil {
+		return errors.Wrap(err, "load cluster lock", z.Str("lock_file_path", config.LockFilePath))
+	}
+
+	oAPI, err := obolapi.New(config.PublishAddress, obolapi.WithTimeout(config.PublishTimeout))
+	if err != nil {
+		return errors.Wrap(err, "create Obol API client", z.Str("publish_address", config.PublishAddress))
+	}
+
+	shareIdx, err := keystore.ShareIdxForCluster(cl, *identityKey.PubKey())
+	if err != nil {
+		return errors.Wrap(err, "determine operator index from cluster lock for supplied identity key")
+	}
+
+	if config.All {
+		for _, validator := range cl.GetValidators() {
+			validatorPubKeyHex := fmt.Sprintf("0x%x", validator.GetPublicKey())
+
+			valCtx := log.WithCtx(ctx, z.Str("validator", validatorPubKeyHex))
+
+			log.Info(ctx, "Deleting partial exit message")
+
+			err := oAPI.DeletePartialExit(valCtx, validatorPubKeyHex, cl.GetInitialMutationHash(), shareIdx, identityKey)
+			if err != nil {
+				if errors.Is(err, obolapi.ErrNoExit) {
+					log.Warn(ctx, fmt.Sprintf("partial exit data from Obol API for validator %v not available (exit may not have been submitted)", validatorPubKeyHex), nil)
+					continue
+				}
+
+				return errors.Wrap(err, "delete partial exits for all validators from public key")
+			}
+		}
+	} else {
+		validator := core.PubKey(config.ValidatorPubkey)
+		if _, err := validator.Bytes(); err != nil {
+			return errors.Wrap(err, "convert validator pubkey to bytes", z.Str("validator_public_key", config.ValidatorPubkey))
+		}
+
+		ctx = log.WithCtx(ctx, z.Str("validator", validator.String()))
+
+		log.Info(ctx, "Deleting partial exit message")
+
+		err := oAPI.DeletePartialExit(ctx, config.ValidatorPubkey, cl.GetInitialMutationHash(), shareIdx, identityKey)
+		if err != nil {
+			return errors.Wrap(err, "delete partial exit data from Obol API", z.Str("validator_public_key", config.ValidatorPubkey))
+		}
+	}
+
+	return nil
+}
diff --git a/cmd/manifest_tools.go b/cmd/manifest_tools.go
index 0045e4db..6ca3b8af 100644
--- a/cmd/manifest_tools.go
+++ b/cmd/manifest_tools.go
@@ -3,12 +3,6 @@
 package cmd
 
 import (
-	"fmt"
-	"os"
-	"path"
-
-	"google.golang.org/protobuf/proto"
-
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/cluster"
 	"github.com/obolnetwork/charon/cluster/manifest"
@@ -36,46 +30,3 @@ func loadClusterManifest(manifestFilePath, lockFilePath string) (*manifestpb.Clu
 
 	return cluster, nil
 }
-
-// loadDAGFromDisk loads cluster DAG from disk.
-func loadDAGFromDisk(manifestFilePath, lockFilePath string) (*manifestpb.SignedMutationList, error) {
-	verifyLock := func(lock cluster.Lock) error {
-		if err := lock.VerifyHashes(); err != nil {
-			return errors.Wrap(err, "cluster lock hash verification failed")
-		}
-
-		if err := lock.VerifySignatures(nil); err != nil {
-			return errors.Wrap(err, "cluster lock signature verification failed")
-		}
-
-		return nil
-	}
-
-	dag, err := manifest.LoadDAG(manifestFilePath, lockFilePath, verifyLock)
-	if err != nil {
-		return nil, errors.Wrap(err, "load cluster dag from disk")
-	}
-
-	return dag, nil
-}
-
-// writeCluster writes the provided cluster DAG as manifest file to node directories on disk.
-func writeCluster(clusterDir string, numOps int, dag *manifestpb.SignedMutationList) error {
-	b, err := proto.Marshal(dag)
-	if err != nil {
-		return errors.Wrap(err, "proto marshal dag")
-	}
-
-	// Write cluster manifest to node directories on disk
-	for i := range numOps {
-		dir := path.Join(clusterDir, fmt.Sprintf("node%d", i))
-		filename := path.Join(dir, "cluster-manifest.pb")
-		//nolint:gosec // File needs to be read-write since the cluster manifest is modified by mutations.
-		err = os.WriteFile(filename, b, 0o644) // Read-write
-		if err != nil {
-			return errors.Wrap(err, "write cluster manifest")
-		}
-	}
-
-	return nil
-}
diff --git a/cmd/relay/p2p.go b/cmd/relay/p2p.go
index a8d8b471..c212c4b7 100644
--- a/cmd/relay/p2p.go
+++ b/cmd/relay/p2p.go
@@ -42,15 +42,15 @@ func startP2P(ctx context.Context, config Config, key *k1.PrivateKey, reporter m
 		}
 	}
 
-	tcpNode, err := p2p.NewTCPNode(ctx, config.P2PConfig, key, p2p.NewOpenGater(), config.FilterPrivAddrs,
+	p2pNode, err := p2p.NewNode(ctx, config.P2PConfig, key, p2p.NewOpenGater(), config.FilterPrivAddrs, p2p.NodeTypeQUIC,
 		libp2p.ResourceManager(new(network.NullResourceManager)), libp2p.BandwidthReporter(reporter))
 	if err != nil {
-		return nil, nil, errors.Wrap(err, "new tcp node")
+		return nil, nil, errors.Wrap(err, "new relay node")
 	}
 
-	p2p.RegisterConnectionLogger(ctx, tcpNode, nil)
+	p2p.RegisterConnectionLogger(ctx, p2pNode, nil)
 
-	labels := map[string]string{"relay_peer": p2p.PeerName(tcpNode.ID())}
+	labels := map[string]string{"relay_peer": p2p.PeerName(p2pNode.ID())}
 	log.SetLokiLabels(labels)
 
 	promRegistry, err := promauto.NewRegistry(labels)
@@ -69,7 +69,7 @@ func startP2P(ctx context.Context, config Config, key *k1.PrivateKey, reporter m
 	// This enables relay metrics: https://github.com/libp2p/go-libp2p/blob/master/p2p/protocol/circuitv2/relay/metrics.go
 	mt := relay.NewMetricsTracer(relay.WithRegisterer(promRegistry))
 
-	relayService, err := relay.New(tcpNode, relay.WithResources(relayResources), relay.WithMetricsTracer(mt))
+	relayService, err := relay.New(p2pNode, relay.WithResources(relayResources), relay.WithMetricsTracer(mt))
 	if err != nil {
 		return nil, nil, errors.Wrap(err, "new relay service")
 	}
@@ -77,17 +77,17 @@ func startP2P(ctx context.Context, config Config, key *k1.PrivateKey, reporter m
 	go func() {
 		<-ctx.Done()
 
-		_ = tcpNode.Close()
+		_ = p2pNode.Close()
 		_ = relayService.Close()
 	}()
 
-	return tcpNode, promRegistry, nil
+	return p2pNode, promRegistry, nil
 }
 
 const unknownCluster = "unknown"
 
 // monitorConnections blocks instrumenting peer connection metrics until the context is closed.
-func monitorConnections(ctx context.Context, tcpNode host.Host, bwTuples <-chan bwTuple) {
+func monitorConnections(ctx context.Context, p2pNode host.Host, bwTuples <-chan bwTuple) {
 	// peerState tracks connection data per peer.
 	type peerState struct {
 		Active      int
@@ -109,7 +109,7 @@ func monitorConnections(ctx context.Context, tcpNode host.Host, bwTuples <-chan
 	)
 
 	// Listen for connection events.
-	tcpNode.Network().Notify(&connLogger{events: events})
+	p2pNode.Network().Notify(&connLogger{events: events})
 
 	// Schedule regular peerinfo requests to all peers.
 	ticker := time.NewTicker(time.Second * 10)
@@ -174,7 +174,7 @@ func monitorConnections(ctx context.Context, tcpNode host.Host, bwTuples <-chan
 				}
 
 				go func(p peer.ID, name string) {
-					hash, ok, err := getPeerInfo(ctx, tcpNode, p, name)
+					hash, ok, err := getPeerInfo(ctx, p2pNode, p, name)
 					if err != nil {
 						log.Warn(ctx, "Peerinfo failed", err, z.Str("peer", name))
 						return
@@ -190,8 +190,8 @@ func monitorConnections(ctx context.Context, tcpNode host.Host, bwTuples <-chan
 }
 
 // getPeerInfo returns the peer's cluster hash and true.
-func getPeerInfo(ctx context.Context, tcpNode host.Host, pID peer.ID, name string) (string, bool, error) {
-	info, rtt, ok, err := peerinfo.DoOnce(ctx, tcpNode, pID)
+func getPeerInfo(ctx context.Context, p2pNode host.Host, pID peer.ID, name string) (string, bool, error) {
+	info, rtt, ok, err := peerinfo.DoOnce(ctx, p2pNode, pID)
 	if p2p.IsRelayError(err) {
 		// Ignore relay errors, since peer probably not connected anymore.
 		return "", false, nil
diff --git a/cmd/relay/relay.go b/cmd/relay/relay.go
index 892e5f4a..f41f147a 100644
--- a/cmd/relay/relay.go
+++ b/cmd/relay/relay.go
@@ -71,12 +71,12 @@ func Run(ctx context.Context, config Config) error {
 	bwTuples := make(chan bwTuple)
 	counter := newBandwidthCounter(ctx, bwTuples)
 
-	tcpNode, promRegistry, err := startP2P(ctx, config, key, counter)
+	p2pNode, promRegistry, err := startP2P(ctx, config, key, counter)
 	if err != nil {
 		return err
 	}
 
-	go monitorConnections(ctx, tcpNode, bwTuples)
+	go monitorConnections(ctx, p2pNode, bwTuples)
 
 	// Start serving HTTP: ENR and monitoring.
 	serverErr := make(chan error, 3) // Buffer for 3 servers.
@@ -87,8 +87,8 @@ func Run(ctx context.Context, config Config) error {
 		}
 
 		mux := http.NewServeMux()
-		mux.HandleFunc("/", wrapHandler(newMultiaddrHandler(tcpNode)))
-		mux.HandleFunc("/enr", wrapHandler(newENRHandler(ctx, tcpNode, key, config.P2PConfig)))
+		mux.HandleFunc("/", wrapHandler(newMultiaddrHandler(p2pNode)))
+		mux.HandleFunc("/enr", wrapHandler(newENRHandler(ctx, p2pNode, key, config.P2PConfig)))
 
 		server := http.Server{Addr: config.HTTPAddr, Handler: mux, ReadHeaderTimeout: time.Second}
 		serverErr <- server.ListenAndServe()
@@ -128,8 +128,9 @@ func Run(ctx context.Context, config Config) error {
 	}
 
 	log.Info(ctx, "Relay started",
-		z.Str("peer_name", p2p.PeerName(tcpNode.ID())),
+		z.Str("peer_name", p2p.PeerName(p2pNode.ID())),
 		z.Any("p2p_tcp_addr", config.P2PConfig.TCPAddrs),
+		z.Any("p2p_udp_addr", config.P2PConfig.UDPAddrs),
 	)
 
 	if config.HTTPAddr != "" {
@@ -152,7 +153,7 @@ func Run(ctx context.Context, config Config) error {
 }
 
 // newENRHandler returns a handler that returns the node's ID and public address encoded as a ENR.
-func newENRHandler(ctx context.Context, tcpNode host.Host, p2pKey *k1.PrivateKey, config p2p.Config) func(ctx context.Context) ([]byte, error) {
+func newENRHandler(ctx context.Context, p2pNode host.Host, p2pKey *k1.PrivateKey, config p2p.Config) func(ctx context.Context) ([]byte, error) {
 	// Resolve external hostname periodically.
 	var (
 		extHostMu sync.Mutex
@@ -165,7 +166,7 @@ func newENRHandler(ctx context.Context, tcpNode host.Host, p2pKey *k1.PrivateKey
 		}
 
 		resolveExtHost := func() {
-			ip, err := net.LookupIP(config.ExternalHost)
+			ip, err := net.DefaultResolver.LookupIPAddr(ctx, config.ExternalHost)
 			if err != nil {
 				log.Warn(ctx, "Failed to resolve external host", err, z.Str("host", config.ExternalHost))
 				return
@@ -173,7 +174,7 @@ func newENRHandler(ctx context.Context, tcpNode host.Host, p2pKey *k1.PrivateKey
 
 			extHostMu.Lock()
 
-			extHostIP = ip[0]
+			extHostIP = ip[0].IP
 
 			extHostMu.Unlock()
 		}
@@ -203,7 +204,7 @@ func newENRHandler(ctx context.Context, tcpNode host.Host, p2pKey *k1.PrivateKey
 
 	return func(context.Context) ([]byte, error) {
 		// Use libp2p configured and detected addresses.
-		addrs := tcpNode.Addrs()
+		addrs := p2pNode.Addrs()
 		if len(addrs) == 0 {
 			return nil, errors.New("no addresses")
 		}
@@ -218,26 +219,83 @@ func newENRHandler(ctx context.Context, tcpNode host.Host, p2pKey *k1.PrivateKey
 			return false
 		})
 
-		// Use first address (ip and port).
-		addr, err := manet.ToNetAddr(addrs[0])
-		if err != nil {
-			return nil, errors.Wrap(err, "failed to convert multiaddr to net addr")
-		}
+		var (
+			tcpNetAddr       *net.TCPAddr
+			udpNetAddr       *net.UDPAddr
+			ip               net.IP
+			tcpPort, udpPort int
+		)
+
+		for _, addr := range addrs {
+			if tcpNetAddr == nil && isTCPAddr(addr) {
+				netAddr, err := manet.ToNetAddr(addr)
+				if err != nil {
+					return nil, errors.Wrap(err, "failed to convert tcp multiaddr to net addr")
+				}
+
+				tcpAddr, ok := netAddr.(*net.TCPAddr)
+				if !ok {
+					return nil, errors.New("invalid tcp address")
+				}
+
+				if config.ExternalIP != "" {
+					tcpAddr.IP = net.ParseIP(config.ExternalIP)
+				} else if extHostIP, ok := getExtHostIP(); ok {
+					tcpAddr.IP = extHostIP
+				}
+
+				tcpNetAddr = tcpAddr
+			}
+
+			if udpNetAddr == nil && isQUICAddr(addr) {
+				// Must strip quic protocol because ToNetAddr only accepts ThinWaist
+				stripped := addr.Decapsulate(ma.StringCast("/quic-v1"))
+
+				netAddr, err := manet.ToNetAddr(stripped)
+				if err != nil {
+					return nil, errors.Wrap(err, "failed to convert udp multiaddr to net addr")
+				}
 
-		tcpAddr, ok := addr.(*net.TCPAddr)
-		if !ok {
-			return nil, errors.New("invalid TCP address")
+				udpAddr, ok := netAddr.(*net.UDPAddr)
+				if !ok {
+					return nil, errors.New("invalid udp address")
+				}
+
+				if config.ExternalIP != "" {
+					udpAddr.IP = net.ParseIP(config.ExternalIP)
+				} else if extHostIP, ok := getExtHostIP(); ok {
+					udpAddr.IP = extHostIP
+				}
+
+				udpNetAddr = udpAddr
+			}
+
+			if tcpNetAddr != nil && udpNetAddr != nil {
+				break
+			}
 		}
 
-		// Override IP with external IP or external hostname if set.
-		if config.ExternalIP != "" {
-			tcpAddr.IP = net.ParseIP(config.ExternalIP)
-		} else if extHostIP, ok := getExtHostIP(); ok {
-			tcpAddr.IP = extHostIP
+		if tcpNetAddr != nil && udpNetAddr != nil {
+			if !tcpNetAddr.IP.Equal(udpNetAddr.IP) {
+				return nil, errors.New("conflicting IP addresses", z.Any("udp IP", udpNetAddr.IP), z.Any("tcp IP", tcpNetAddr.IP))
+			}
+
+			ip = tcpNetAddr.IP
+			tcpPort = tcpNetAddr.Port
+			udpPort = udpNetAddr.Port
+		} else if tcpNetAddr != nil {
+			ip = tcpNetAddr.IP
+			tcpPort = tcpNetAddr.Port
+			udpPort = 9999 // Dummy value
+		} else if udpNetAddr != nil {
+			ip = udpNetAddr.IP
+			tcpPort = 9999 // Dummy value
+			udpPort = udpNetAddr.Port
+		} else {
+			return nil, errors.New("no udp or tcp addresses provided")
 		}
 
-		// Build the ENR
-		r, err := enr.New(p2pKey, enr.WithIP(tcpAddr.IP), enr.WithTCP(tcpAddr.Port), enr.WithUDP(9999)) // Include invalid dummy UDP port so v0.13 can parse the ENR.
+		r, err := enr.New(p2pKey, enr.WithIP(ip), enr.WithTCP(tcpPort), enr.WithUDP(udpPort))
 		if err != nil {
 			return nil, err
 		}
@@ -246,16 +304,42 @@ func newENRHandler(ctx context.Context, tcpNode host.Host, p2pKey *k1.PrivateKey
 	}
 }
 
+// isProtocolAddr return true if the multiaddr has protocol code p
+func isProtocolAddr(a ma.Multiaddr, p int) bool {
+	found := false
+
+	ma.ForEach(a, func(c ma.Component) bool {
+		if c.Protocol().Code == p {
+			found = true
+			return false
+		}
+
+		return true
+	})
+
+	return found
+}
+
+// isTCPAddr returns true if the multiaddr is TCP
+func isTCPAddr(a ma.Multiaddr) bool {
+	return isProtocolAddr(a, ma.P_TCP)
+}
+
+// isQUICAddr returns true if the multiaddr is QUIC
+func isQUICAddr(a ma.Multiaddr) bool {
+	return isProtocolAddr(a, ma.P_QUIC) || isProtocolAddr(a, ma.P_QUIC_V1)
+}
+
 // newMultiaddrHandler returns a handler that returns the nodes multiaddrs (as json array).
-func newMultiaddrHandler(tcpNode host.Host) func(ctx context.Context) ([]byte, error) {
+func newMultiaddrHandler(p2pNode host.Host) func(ctx context.Context) ([]byte, error) {
 	return func(context.Context) ([]byte, error) {
-		p2pAddr, err := ma.NewMultiaddr(fmt.Sprintf("/p2p/%s", tcpNode.ID()))
+		p2pAddr, err := ma.NewMultiaddr(fmt.Sprintf("/p2p/%s", p2pNode.ID()))
 		if err != nil {
 			return nil, errors.Wrap(err, "failed to create p2p multiaddr")
 		}
 
 		var addrs []ma.Multiaddr
-		for _, addr := range tcpNode.Addrs() {
+		for _, addr := range p2pNode.Addrs() {
 			addrs = append(addrs, addr.Encapsulate(p2pAddr))
 		}
 
diff --git a/cmd/run.go b/cmd/run.go
index f0cd88eb..1714ef2f 100644
--- a/cmd/run.go
+++ b/cmd/run.go
@@ -170,6 +170,7 @@ func bindP2PFlags(cmd *cobra.Command, config *p2p.Config) {
 	cmd.Flags().StringVar(&config.ExternalIP, "p2p-external-ip", "", "The IP address advertised by libp2p. This may be used to advertise an external IP.")
 	cmd.Flags().StringVar(&config.ExternalHost, "p2p-external-hostname", "", "The DNS hostname advertised by libp2p. This may be used to advertise an external DNS.")
 	cmd.Flags().StringSliceVar(&config.TCPAddrs, "p2p-tcp-address", nil, "Comma-separated list of listening TCP addresses (ip and port) for libP2P traffic. Empty default doesn't bind to local port therefore only supports outgoing connections.")
+	cmd.Flags().StringSliceVar(&config.UDPAddrs, "p2p-udp-address", nil, "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.")
 	cmd.Flags().BoolVar(&config.DisableReuseport, "p2p-disable-reuseport", false, "Disables TCP port reuse for outgoing libp2p connections.")
 
 	wrapPreRunE(cmd, func(cmd *cobra.Command, args []string) error { //nolint:revive // keep args variable name for clarity
diff --git a/core/consensus/controller.go b/core/consensus/controller.go
index 0686bdc6..a6fa3e8b 100644
--- a/core/consensus/controller.go
+++ b/core/consensus/controller.go
@@ -19,7 +19,7 @@ import (
 type DeadlinerFactory func(name string) core.Deadliner
 
 type consensusController struct {
-	tcpNode          host.Host
+	p2pNode          host.Host
 	sender           *p2p.Sender
 	peers            []p2p.Peer
 	p2pKey           *k1.PrivateKey
@@ -37,19 +37,19 @@ type consensusController struct {
 }
 
 // NewConsensusController creates a new consensus controller with the default consensus protocol.
-func NewConsensusController(ctx context.Context, tcpNode host.Host, sender *p2p.Sender,
+func NewConsensusController(ctx context.Context, p2pNode host.Host, sender *p2p.Sender,
 	peers []p2p.Peer, p2pKey *k1.PrivateKey, deadlineFunc core.DeadlineFunc,
 	gaterFunc core.DutyGaterFunc, debugger Debugger,
 ) (core.ConsensusController, error) {
 	qbftDeadliner := core.NewDeadliner(ctx, "consensus.qbft", deadlineFunc)
 
-	defaultConsensus, err := qbft.NewConsensus(tcpNode, sender, peers, p2pKey, qbftDeadliner, gaterFunc, debugger.AddInstance)
+	defaultConsensus, err := qbft.NewConsensus(p2pNode, sender, peers, p2pKey, qbftDeadliner, gaterFunc, debugger.AddInstance)
 	if err != nil {
 		return nil, err
 	}
 
 	return &consensusController{
-		tcpNode:          tcpNode,
+		p2pNode:          p2pNode,
 		sender:           sender,
 		peers:            peers,
 		p2pKey:           p2pKey,
diff --git a/core/consensus/mocks/debugger.go b/core/consensus/mocks/debugger.go
index a1711c08..02c99235 100644
--- a/core/consensus/mocks/debugger.go
+++ b/core/consensus/mocks/debugger.go
@@ -1,6 +1,6 @@
 // Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
 
-// Code generated by mockery v2.42.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 package mocks
 
diff --git a/core/consensus/qbft/qbft.go b/core/consensus/qbft/qbft.go
index d91e612a..75a3854e 100644
--- a/core/consensus/qbft/qbft.go
+++ b/core/consensus/qbft/qbft.go
@@ -123,7 +123,7 @@ func newDefinition(nodes int, subs func() []subscriber, roundTimer timer.RoundTi
 }
 
 // NewConsensus returns a new consensus QBFT component.
-func NewConsensus(tcpNode host.Host, sender *p2p.Sender, peers []p2p.Peer, p2pKey *k1.PrivateKey,
+func NewConsensus(p2pNode host.Host, sender *p2p.Sender, peers []p2p.Peer, p2pKey *k1.PrivateKey,
 	deadliner core.Deadliner, gaterFunc core.DutyGaterFunc, snifferFunc func(*pbv1.SniffedConsensusInstance),
 ) (*Consensus, error) {
 	// Extract peer pubkeys.
@@ -142,7 +142,7 @@ func NewConsensus(tcpNode host.Host, sender *p2p.Sender, peers []p2p.Peer, p2pKe
 	}
 
 	c := &Consensus{
-		tcpNode:     tcpNode,
+		p2pNode:     p2pNode,
 		sender:      sender,
 		peers:       peers,
 		peerLabels:  labels,
@@ -163,7 +163,7 @@ func NewConsensus(tcpNode host.Host, sender *p2p.Sender, peers []p2p.Peer, p2pKe
 // Consensus implements core.Consensus & priority.coreConsensus.
 type Consensus struct {
 	// Immutable state
-	tcpNode     host.Host
+	p2pNode     host.Host
 	sender      *p2p.Sender
 	peerLabels  []string
 	peers       []p2p.Peer
@@ -228,7 +228,7 @@ func (c *Consensus) SubscribePriority(fn func(ctx context.Context, duty core.Dut
 
 // Start registers libp2p handler and runs internal routines until the context is cancelled.
 func (c *Consensus) Start(ctx context.Context) {
-	p2p.RegisterHandler("qbft", c.tcpNode, protocols.QBFTv2ProtocolID,
+	p2p.RegisterHandler("qbft", c.p2pNode, protocols.QBFTv2ProtocolID,
 		func() proto.Message { return new(pbv1.QBFTConsensusMsg) },
 		c.handle)
 
@@ -345,12 +345,12 @@ func (c *Consensus) Participate(ctx context.Context, duty core.Duty) error {
 // Broadcast implements Broadcaster interface.
 func (c *Consensus) Broadcast(ctx context.Context, msg *pbv1.QBFTConsensusMsg) error {
 	for _, peer := range c.peers {
-		if peer.ID == c.tcpNode.ID() {
+		if peer.ID == c.p2pNode.ID() {
 			// Do not broadcast to self
 			continue
 		}
 
-		if err := c.sender.SendAsync(ctx, c.tcpNode, protocols.QBFTv2ProtocolID, peer.ID, msg); err != nil {
+		if err := c.sender.SendAsync(ctx, c.p2pNode, protocols.QBFTv2ProtocolID, peer.ID, msg); err != nil {
 			return err
 		}
 	}
@@ -582,7 +582,7 @@ func (c *Consensus) getPeerIdx() (int64, error) {
 	peerIdx := int64(-1)
 
 	for i, p := range c.peers {
-		if c.tcpNode.ID() == p.ID {
+		if c.p2pNode.ID() == p.ID {
 			peerIdx = int64(i)
 		}
 	}
diff --git a/core/consensus/qbft/transport.go b/core/consensus/qbft/transport.go
index ca3b9590..a85fd3ca 100644
--- a/core/consensus/qbft/transport.go
+++ b/core/consensus/qbft/transport.go
@@ -4,6 +4,7 @@ package qbft
 
 import (
 	"context"
+	"maps"
 	"sync"
 
 	k1 "github.com/decred/dcrd/dcrec/secp256k1/v4"
@@ -54,9 +55,7 @@ func (t *transport) setValues(msg Msg) {
 	t.valueMu.Lock()
 	defer t.valueMu.Unlock()
 
-	for k, v := range msg.Values() {
-		t.values[k] = v
-	}
+	maps.Copy(t.values, msg.Values())
 }
 
 // getValue returns the value by its hash.
diff --git a/core/consensus/timer/roundtimer.go b/core/consensus/timer/roundtimer.go
index caafa532..2c83aa85 100644
--- a/core/consensus/timer/roundtimer.go
+++ b/core/consensus/timer/roundtimer.go
@@ -65,7 +65,7 @@ func increasingRoundTimeout(round int64) time.Duration {
 	return IncRoundStart + (time.Duration(round) * IncRoundIncrease)
 }
 
-// increasingRoundTimeout returns linearRoundInc*round duration for a round.
+// linearRoundTimeout returns linearRoundInc*round duration for a round.
 func linearRoundTimeout(round int64) time.Duration {
 	return time.Duration(round) * LinearRoundInc
 }
@@ -84,7 +84,7 @@ func proposalTimeoutOptimization(duty core.Duty, round int64) bool {
 	return featureset.Enabled(featureset.ProposalTimeout) && duty.Type == core.DutyProposer && round == 1
 }
 
-// NewTimeoutRoundTimer returns a new increasing round timer type.
+// NewIncreasingRoundTimer returns a new increasing round timer type.
 func NewIncreasingRoundTimer() RoundTimer {
 	return NewIncreasingRoundTimerWithClock(clockwork.NewRealClock())
 }
diff --git a/core/fetcher/fetcher.go b/core/fetcher/fetcher.go
index 6b16d11c..502d34a3 100644
--- a/core/fetcher/fetcher.go
+++ b/core/fetcher/fetcher.go
@@ -205,6 +205,7 @@ func (f *Fetcher) fetchAggregatorData(ctx context.Context, slot uint64, defSet c
 			pt.addNotSelected(pubkey.String())
 			continue
 		}
+
 		pt.addResolved(pubkey.String())
 
 		aggAtt, ok := aggAttByCommIdx[attDef.CommitteeIndex]
@@ -323,7 +324,7 @@ func (f *Fetcher) fetchContributionData(ctx context.Context, slot uint64, defSet
 			return core.UnsignedDataSet{}, errors.New("invalid sync committee selection")
 		}
 
-		subcommIdx := uint64(selection.SubcommitteeIndex)
+		subcommIdx := selection.SubcommitteeIndex
 
 		// Check if the validator is an aggregator for the sync committee.
 		ok, err = eth2exp.IsSyncCommAggregator(ctx, f.eth2Cl, selection.SelectionProof)
@@ -365,6 +366,7 @@ func (f *Fetcher) fetchContributionData(ctx context.Context, slot uint64, defSet
 			// This could happen if the beacon node didn't subscribe to the correct subnet.
 			return core.UnsignedDataSet{}, errors.New("sync committee contribution not found by root (retryable)", z.U64("subcommidx", subcommIdx), z.Hex("root", blockRoot[:]))
 		}
+
 		pt.addResolved(pubkey.String())
 
 		resp[pubkey] = core.SyncContribution{
@@ -442,6 +444,7 @@ func (pt *pubkeysTracker) log(ctx context.Context) {
 		s := strings.Join(pt.notSelectedPubKeys, ",")
 		log.Debug(ctx, pt.title+": not selected pubkeys", z.Str("pubkeys", s))
 	}
+
 	if len(pt.resolvedPubKeys) > 0 {
 		s := strings.Join(pt.resolvedPubKeys, ",")
 		log.Info(ctx, pt.title+": resolved pubkeys", z.Str("pubkeys", s))
diff --git a/core/infosync/infosync.go b/core/infosync/infosync.go
index 769fc903..3b418e73 100644
--- a/core/infosync/infosync.go
+++ b/core/infosync/infosync.go
@@ -61,6 +61,7 @@ func New(prioritiser *priority.Component, versions []version.SemVer, protocols [
 					res.protocols = append(res.protocols, protocol.ID(prio))
 				case topicProposal:
 					res.proposals = append(res.proposals, core.ProposalType(prio))
+				default:
 				}
 			}
 		}
diff --git a/core/mocks/consensus.go b/core/mocks/consensus.go
index 738bef7b..424c90a8 100644
--- a/core/mocks/consensus.go
+++ b/core/mocks/consensus.go
@@ -1,6 +1,6 @@
 // Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
 
-// Code generated by mockery v2.42.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 package mocks
 
@@ -54,7 +54,7 @@ func (_m *Consensus) Propose(_a0 context.Context, _a1 core.Duty, _a2 core.Unsign
 	return r0
 }
 
-// ProtocolID provides a mock function with given fields:
+// ProtocolID provides a mock function with no fields
 func (_m *Consensus) ProtocolID() protocol.ID {
 	ret := _m.Called()
 
diff --git a/core/mocks/deadliner.go b/core/mocks/deadliner.go
index 9a548e14..28923d91 100644
--- a/core/mocks/deadliner.go
+++ b/core/mocks/deadliner.go
@@ -1,6 +1,6 @@
 // Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
 
-// Code generated by mockery v2.42.1. DO NOT EDIT.
+// Code generated by mockery v2.53.3. DO NOT EDIT.
 
 package mocks
 
@@ -32,7 +32,7 @@ func (_m *Deadliner) Add(duty core.Duty) bool {
 	return r0
 }
 
-// C provides a mock function with given fields:
+// C provides a mock function with no fields
 func (_m *Deadliner) C() <-chan core.Duty {
 	ret := _m.Called()
 
diff --git a/core/parsigex/parsigex.go b/core/parsigex/parsigex.go
index aa1f665a..0fe93b43 100644
--- a/core/parsigex/parsigex.go
+++ b/core/parsigex/parsigex.go
@@ -28,12 +28,12 @@ func Protocols() []protocol.ID {
 	return []protocol.ID{protocolID2}
 }
 
-func NewParSigEx(tcpNode host.Host, sendFunc p2p.SendFunc, peerIdx int, peers []peer.ID,
+func NewParSigEx(p2pNode host.Host, sendFunc p2p.SendFunc, peerIdx int, peers []peer.ID,
 	verifyFunc func(context.Context, core.Duty, core.PubKey, core.ParSignedData) error,
 	gaterFunc core.DutyGaterFunc, p2pOpts ...p2p.SendRecvOption,
 ) *ParSigEx {
 	parSigEx := &ParSigEx{
-		tcpNode:    tcpNode,
+		p2pNode:    p2pNode,
 		sendFunc:   sendFunc,
 		peerIdx:    peerIdx,
 		peers:      peers,
@@ -44,7 +44,7 @@ func NewParSigEx(tcpNode host.Host, sendFunc p2p.SendFunc, peerIdx int, peers []
 	newReq := func() proto.Message { return new(pbv1.ParSigExMsg) }
 	p2p.RegisterHandler(
 		"parsigex",
-		tcpNode,
+		p2pNode,
 		protocolID2,
 		newReq,
 		parSigEx.handle,
@@ -57,7 +57,7 @@ func NewParSigEx(tcpNode host.Host, sendFunc p2p.SendFunc, peerIdx int, peers []
 // ParSigEx exchanges partially signed duty data sets.
 // It ensures that all partial signatures are persisted by all peers.
 type ParSigEx struct {
-	tcpNode    host.Host
+	p2pNode    host.Host
 	sendFunc   p2p.SendFunc
 	peerIdx    int
 	peers      []peer.ID
@@ -133,7 +133,7 @@ func (m *ParSigEx) Broadcast(ctx context.Context, duty core.Duty, set core.ParSi
 			continue
 		}
 
-		if err := m.sendFunc(ctx, m.tcpNode, protocolID2, p, &msg); err != nil {
+		if err := m.sendFunc(ctx, m.p2pNode, protocolID2, p, &msg); err != nil {
 			return err
 		}
 	}
diff --git a/core/priority/component.go b/core/priority/component.go
index b6e8c7c7..e6afcf6d 100644
--- a/core/priority/component.go
+++ b/core/priority/component.go
@@ -50,7 +50,7 @@ type ScoredPriority struct {
 }
 
 // NewComponent returns a new priority component.
-func NewComponent(ctx context.Context, tcpNode host.Host, peers []peer.ID, minRequired int, sendFunc p2p.SendReceiveFunc,
+func NewComponent(ctx context.Context, p2pNode host.Host, peers []peer.ID, minRequired int, sendFunc p2p.SendReceiveFunc,
 	registerHandlerFunc p2p.RegisterHandlerFunc, consensus Consensus,
 	exchangeTimeout time.Duration, privkey *k1.PrivateKey, deadlineFunc func(duty core.Duty) (time.Time, bool),
 ) (*Component, error) {
@@ -61,11 +61,11 @@ func NewComponent(ctx context.Context, tcpNode host.Host, peers []peer.ID, minRe
 
 	deadliner := core.NewDeadliner(ctx, "priority", deadlineFunc)
 
-	prioritiser := newInternal(tcpNode, peers, minRequired, sendFunc, registerHandlerFunc,
+	prioritiser := newInternal(p2pNode, peers, minRequired, sendFunc, registerHandlerFunc,
 		consensus, verifier, exchangeTimeout, deadliner)
 
 	return &Component{
-		peerID:       tcpNode.ID(),
+		peerID:       p2pNode.ID(),
 		prioritiser:  prioritiser,
 		privkey:      privkey,
 		deadlineFunc: deadlineFunc,
diff --git a/core/priority/prioritiser.go b/core/priority/prioritiser.go
index dc8f1b06..e0286556 100644
--- a/core/priority/prioritiser.go
+++ b/core/priority/prioritiser.go
@@ -67,17 +67,17 @@ type request struct {
 }
 
 // NewForT exports newInternal for testing and returns a new prioritiser.
-func NewForT(_ *testing.T, tcpNode host.Host, peers []peer.ID, minRequired int,
+func NewForT(_ *testing.T, p2pNode host.Host, peers []peer.ID, minRequired int,
 	sendFunc p2p.SendReceiveFunc, registerHandlerFunc p2p.RegisterHandlerFunc,
 	consensus Consensus, msgValidator msgValidator, exchangeTimeout time.Duration,
 	deadliner core.Deadliner,
 ) *Prioritiser {
-	return newInternal(tcpNode, peers, minRequired, sendFunc, registerHandlerFunc,
+	return newInternal(p2pNode, peers, minRequired, sendFunc, registerHandlerFunc,
 		consensus, msgValidator, exchangeTimeout, deadliner)
 }
 
 // newInternal returns a new prioritiser, it is the constructor.
-func newInternal(tcpNode host.Host, peers []peer.ID, minRequired int,
+func newInternal(p2pNode host.Host, peers []peer.ID, minRequired int,
 	sendFunc p2p.SendReceiveFunc, registerHandlerFunc p2p.RegisterHandlerFunc,
 	consensus Consensus, msgValidator msgValidator,
 	exchangeTimeout time.Duration, deadliner core.Deadliner,
@@ -89,7 +89,7 @@ func newInternal(tcpNode host.Host, peers []peer.ID, minRequired int,
 	}
 
 	p := &Prioritiser{
-		tcpNode:          tcpNode,
+		p2pNode:          p2pNode,
 		sendFunc:         sendFunc,
 		minRequired:      minRequired,
 		peers:            peers,
@@ -115,7 +115,7 @@ func newInternal(tcpNode host.Host, peers []peer.ID, minRequired int,
 	})
 
 	// Register prioritiser protocol handler.
-	registerHandlerFunc("priority", tcpNode, protocolID2,
+	registerHandlerFunc("priority", p2pNode, protocolID2,
 		func() proto.Message { return new(pbv1.PriorityMsg) },
 		func(ctx context.Context, pID peer.ID, msg proto.Message) (proto.Message, bool, error) {
 			prioMsg, ok := msg.(*pbv1.PriorityMsg)
@@ -143,7 +143,7 @@ type Prioritiser struct {
 	deadliner        core.Deadliner
 	minRequired      int
 	exchangeTimeout  time.Duration
-	tcpNode          host.Host
+	p2pNode          host.Host
 	sendFunc         p2p.SendReceiveFunc
 	peers            []peer.ID
 	consensus        Consensus
@@ -192,7 +192,7 @@ func (p *Prioritiser) Prioritise(ctx context.Context, msg *pbv1.PriorityMsg) err
 	}
 
 	return runInstance(ctx, duty, msg, p.getReqBuffer(duty), p.minRequired,
-		p.exchangeTimeout, p.tcpNode, p.sendFunc, p.peers, p.consensus, p.msgValidator)
+		p.exchangeTimeout, p.p2pNode, p.sendFunc, p.peers, p.consensus, p.msgValidator)
 }
 
 // handleRequest handles a priority message exchange initiated by a peer.
@@ -265,7 +265,7 @@ func (p *Prioritiser) deleteRecvBuffer(duty core.Duty) {
 // responds to peer requests, and starts consensus.
 func runInstance(ctx context.Context, duty core.Duty, own *pbv1.PriorityMsg,
 	requests <-chan request, minRequired int, exchangeTimeout time.Duration,
-	tcpNode host.Host, sendFunc p2p.SendReceiveFunc, peers []peer.ID,
+	p2pNode host.Host, sendFunc p2p.SendReceiveFunc, peers []peer.ID,
 	consensus Consensus, msgValidator msgValidator,
 ) error {
 	log.Debug(ctx, "Priority protocol instance started")
@@ -289,7 +289,7 @@ func runInstance(ctx context.Context, duty core.Duty, own *pbv1.PriorityMsg,
 
 	exTimeout := time.After(exchangeTimeout)
 
-	exchange(ctx, tcpNode, peers, msgValidator, sendFunc, responses, own)
+	exchange(ctx, p2pNode, peers, msgValidator, sendFunc, responses, own)
 
 	for {
 		select {
@@ -330,18 +330,18 @@ func runInstance(ctx context.Context, duty core.Duty, own *pbv1.PriorityMsg,
 }
 
 // exchange initiates a priority message exchange with all peers.
-func exchange(ctx context.Context, tcpNode host.Host, peers []peer.ID, msgValidator msgValidator,
+func exchange(ctx context.Context, p2pNode host.Host, peers []peer.ID, msgValidator msgValidator,
 	sendFunc p2p.SendReceiveFunc, responses chan<- *pbv1.PriorityMsg, own *pbv1.PriorityMsg,
 ) {
 	for _, pID := range peers {
-		if pID == tcpNode.ID() {
+		if pID == p2pNode.ID() {
 			continue // Do not send to self
 		}
 
 		go func(pID peer.ID) {
 			response := new(pbv1.PriorityMsg)
 
-			err := sendFunc(ctx, tcpNode, pID, own, response, protocolID2)
+			err := sendFunc(ctx, p2pNode, pID, own, response, protocolID2)
 			if err != nil {
 				// No need to log, since transport will do it.
 				return
diff --git a/core/signeddata.go b/core/signeddata.go
index 99967ebc..9454297f 100644
--- a/core/signeddata.go
+++ b/core/signeddata.go
@@ -26,7 +26,6 @@ import (
 	"github.com/obolnetwork/charon/app/log"
 	"github.com/obolnetwork/charon/app/z"
 	"github.com/obolnetwork/charon/eth2util"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
 	"github.com/obolnetwork/charon/eth2util/signing"
 )
 
@@ -1163,14 +1162,14 @@ func (s SignedRandao) clone() (SignedRandao, error) {
 }
 
 // NewBeaconCommitteeSelection is a convenience function which returns new signed BeaconCommitteeSelection.
-func NewBeaconCommitteeSelection(selection *eth2exp.BeaconCommitteeSelection) BeaconCommitteeSelection {
+func NewBeaconCommitteeSelection(selection *eth2v1.BeaconCommitteeSelection) BeaconCommitteeSelection {
 	return BeaconCommitteeSelection{
 		BeaconCommitteeSelection: *selection,
 	}
 }
 
 // NewPartialSignedBeaconCommitteeSelection is a convenience function which returns new partially signed BeaconCommitteeSelection.
-func NewPartialSignedBeaconCommitteeSelection(selection *eth2exp.BeaconCommitteeSelection, shareIdx int) ParSignedData {
+func NewPartialSignedBeaconCommitteeSelection(selection *eth2v1.BeaconCommitteeSelection, shareIdx int) ParSignedData {
 	return ParSignedData{
 		SignedData: NewBeaconCommitteeSelection(selection),
 		ShareIdx:   shareIdx,
@@ -1179,7 +1178,7 @@ func NewPartialSignedBeaconCommitteeSelection(selection *eth2exp.BeaconCommittee
 
 // BeaconCommitteeSelection wraps a BeaconCommitteeSelection which implements SignedData.
 type BeaconCommitteeSelection struct {
-	eth2exp.BeaconCommitteeSelection
+	eth2v1.BeaconCommitteeSelection
 }
 
 func (s BeaconCommitteeSelection) MessageRoot() ([32]byte, error) {
@@ -1225,14 +1224,14 @@ func (s *BeaconCommitteeSelection) UnmarshalJSON(input []byte) error {
 }
 
 // NewSyncCommitteeSelection is a convenience function which returns new signed SyncCommitteeSelection.
-func NewSyncCommitteeSelection(selection *eth2exp.SyncCommitteeSelection) SyncCommitteeSelection {
+func NewSyncCommitteeSelection(selection *eth2v1.SyncCommitteeSelection) SyncCommitteeSelection {
 	return SyncCommitteeSelection{
 		SyncCommitteeSelection: *selection,
 	}
 }
 
 // NewPartialSignedSyncCommitteeSelection is a convenience function which returns new partially signed SyncCommitteeSelection.
-func NewPartialSignedSyncCommitteeSelection(selection *eth2exp.SyncCommitteeSelection, shareIdx int) ParSignedData {
+func NewPartialSignedSyncCommitteeSelection(selection *eth2v1.SyncCommitteeSelection, shareIdx int) ParSignedData {
 	return ParSignedData{
 		SignedData: NewSyncCommitteeSelection(selection),
 		ShareIdx:   shareIdx,
@@ -1241,7 +1240,7 @@ func NewPartialSignedSyncCommitteeSelection(selection *eth2exp.SyncCommitteeSele
 
 // SyncCommitteeSelection wraps an eth2exp.SyncCommitteeSelection and implements SignedData.
 type SyncCommitteeSelection struct {
-	eth2exp.SyncCommitteeSelection
+	eth2v1.SyncCommitteeSelection
 }
 
 // MessageRoot returns the signing roots for the provided SyncCommitteeSelection.
@@ -1249,7 +1248,7 @@ type SyncCommitteeSelection struct {
 func (s SyncCommitteeSelection) MessageRoot() ([32]byte, error) {
 	data := altair.SyncAggregatorSelectionData{
 		Slot:              s.Slot,
-		SubcommitteeIndex: uint64(s.SubcommitteeIndex),
+		SubcommitteeIndex: s.SubcommitteeIndex,
 	}
 
 	return data.HashTreeRoot()
diff --git a/core/tracker/inclusion.go b/core/tracker/inclusion.go
index a5acb963..ed1bf1bc 100644
--- a/core/tracker/inclusion.go
+++ b/core/tracker/inclusion.go
@@ -23,7 +23,6 @@ import (
 	"github.com/obolnetwork/charon/app/log"
 	"github.com/obolnetwork/charon/app/z"
 	"github.com/obolnetwork/charon/core"
-	"github.com/obolnetwork/charon/eth2util/statecomm"
 )
 
 const (
@@ -59,7 +58,7 @@ type block struct {
 	Slot                   uint64
 	AttDuties              []*eth2v1.AttesterDuty
 	AttestationsByDataRoot map[eth2p0.Root]*eth2spec.VersionedAttestation
-	BeaconCommitees        []*statecomm.StateCommittee
+	BeaconCommitees        []*eth2v1.BeaconCommittee
 }
 
 // attCommittee is a versioned attestation with its aggregation bits mapped to the respective beacon committee
@@ -74,9 +73,9 @@ type trackerInclFunc func(core.Duty, core.PubKey, core.SignedData, error)
 // inclusionCore tracks the inclusion of submitted duties.
 // It has a simplified API to allow for easy testing.
 type inclusionCore struct {
-	mu              sync.Mutex
-	submissions     map[subkey]submission
-	stateCommittees map[eth2p0.Slot][]*statecomm.StateCommittee
+	mu               sync.Mutex
+	submissions      map[subkey]submission
+	beaconCommittees map[eth2p0.Slot][]*eth2v1.BeaconCommittee
 
 	trackerInclFunc trackerInclFunc
 	missedFunc      func(context.Context, submission)
@@ -230,6 +229,15 @@ func (i *inclusionCore) Trim(ctx context.Context, slot uint64) {
 
 		delete(i.submissions, key)
 	}
+
+	// Trim state committees
+	for key := range i.beaconCommittees {
+		if uint64(key) > slot {
+			continue
+		}
+
+		delete(i.beaconCommittees, key)
+	}
 }
 
 // CheckBlock checks whether the block includes any of the submitted duties.
@@ -338,7 +346,7 @@ func (i *inclusionCore) CheckBlockAndAtts(ctx context.Context, block block) {
 
 	// Delete
 	if block.Slot >= InclMissedLag {
-		delete(i.stateCommittees, eth2p0.Slot(block.Slot-InclMissedLag))
+		delete(i.beaconCommittees, eth2p0.Slot(block.Slot-InclMissedLag))
 	}
 }
 
@@ -528,11 +536,11 @@ func NewInclusion(ctx context.Context, eth2Cl eth2wrap.Client, trackerInclFunc t
 	}
 
 	inclCore := &inclusionCore{
-		attIncludedFunc: reportAttInclusion,
-		missedFunc:      reportMissed,
-		trackerInclFunc: trackerInclFunc,
-		submissions:     make(map[subkey]submission),
-		stateCommittees: make(map[eth2p0.Slot][]*statecomm.StateCommittee),
+		attIncludedFunc:  reportAttInclusion,
+		missedFunc:       reportMissed,
+		trackerInclFunc:  trackerInclFunc,
+		submissions:      make(map[subkey]submission),
+		beaconCommittees: make(map[eth2p0.Slot][]*eth2v1.BeaconCommittee),
 	}
 
 	return &InclusionChecker{
@@ -651,11 +659,13 @@ func (a *InclusionChecker) checkBlock(ctx context.Context, slot uint64, attDutie
 		return a.checkBlockAndAtts(ctx, slot, attDuties)
 	}
 
-	block, err := a.eth2Cl.Block(ctx, strconv.FormatUint(slot, 10))
+	eth2Resp, err := a.eth2Cl.SignedBeaconBlock(ctx, &eth2api.SignedBeaconBlockOpts{Block: strconv.FormatUint(slot, 10)})
 	if err != nil {
 		return err
 	}
 
+	block := eth2Resp.Data
+
 	var found bool
 	if block != nil {
 		found = true
@@ -669,15 +679,17 @@ func (a *InclusionChecker) checkBlock(ctx context.Context, slot uint64, attDutie
 }
 
 func (a *InclusionChecker) checkBlockAndAtts(ctx context.Context, slot uint64, attDuties []*eth2v1.AttesterDuty) error {
-	atts, err := a.eth2Cl.BlockAttestations(ctx, strconv.FormatUint(slot, 10))
+	attsResp, err := a.eth2Cl.BeaconBlockAttestations(ctx, &eth2api.BeaconBlockAttestationsOpts{Block: strconv.FormatUint(slot, 10)})
 	if err != nil {
 		return err
-	} else if len(atts) == 0 {
+	} else if len(attsResp.Data) == 0 {
 		return nil // No block for this slot
 	}
 
+	atts := attsResp.Data
+
 	var (
-		committeesForState []*statecomm.StateCommittee
+		committeesForState []*eth2v1.BeaconCommittee
 		checkedSlots       []eth2p0.Slot
 	)
 
@@ -692,18 +704,20 @@ func (a *InclusionChecker) checkBlockAndAtts(ctx context.Context, slot uint64, a
 			continue
 		}
 
-		stateComms, ok := a.core.stateCommittees[attestationData.Slot]
+		beaconComms, ok := a.core.beaconCommittees[attestationData.Slot]
 		if ok {
-			committeesForState = append(committeesForState, stateComms...)
+			committeesForState = append(committeesForState, beaconComms...)
 		} else {
 			// Get the beacon committee for the above mentioned slot.
-			fetchedCommitteesForState, err := a.eth2Cl.BeaconStateCommittees(ctx, uint64(attestationData.Slot))
+			eth2Resp, err := a.eth2Cl.BeaconCommittees(ctx, &eth2api.BeaconCommitteesOpts{State: fmt.Sprintf("%v", attestationData.Slot)})
 			if err != nil {
 				return err
 			}
 
+			fetchedCommitteesForState := eth2Resp.Data
+
 			committeesForState = append(committeesForState, fetchedCommitteesForState...)
-			a.core.stateCommittees[attestationData.Slot] = fetchedCommitteesForState
+			a.core.beaconCommittees[attestationData.Slot] = fetchedCommitteesForState
 		}
 
 		checkedSlots = append(checkedSlots, attestationData.Slot)
@@ -888,7 +902,7 @@ func setAttestationAggregationBits(att eth2spec.VersionedAttestation, bits bitfi
 	}
 }
 
-func conjugateAggregationBits(att *attCommittee, attsMap map[eth2p0.Root]*attCommittee, root eth2p0.Root, committeesForState []*statecomm.StateCommittee) (map[eth2p0.CommitteeIndex]bitfield.Bitlist, error) {
+func conjugateAggregationBits(att *attCommittee, attsMap map[eth2p0.Root]*attCommittee, root eth2p0.Root, committeesForState []*eth2v1.BeaconCommittee) (map[eth2p0.CommitteeIndex]bitfield.Bitlist, error) {
 	switch att.Attestation.Version {
 	case eth2spec.DataVersionPhase0:
 		if att.Attestation.Phase0 == nil {
@@ -957,7 +971,7 @@ func conjugateAggregationBitsPhase0(att *attCommittee, attsMap map[eth2p0.Root]*
 	return nil
 }
 
-func conjugateAggregationBitsElectra(att *attCommittee, attsMap map[eth2p0.Root]*attCommittee, root eth2p0.Root, committeesForState []*statecomm.StateCommittee) (map[eth2p0.CommitteeIndex]bitfield.Bitlist, error) {
+func conjugateAggregationBitsElectra(att *attCommittee, attsMap map[eth2p0.Root]*attCommittee, root eth2p0.Root, committeesForState []*eth2v1.BeaconCommittee) (map[eth2p0.CommitteeIndex]bitfield.Bitlist, error) {
 	fullAttestationAggregationBits, err := att.Attestation.AggregationBits()
 	if err != nil {
 		return nil, err
diff --git a/core/validatorapi/eth2types.go b/core/validatorapi/eth2types.go
index a070ffe9..d4291d64 100644
--- a/core/validatorapi/eth2types.go
+++ b/core/validatorapi/eth2types.go
@@ -13,7 +13,6 @@ import (
 	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
 
 	"github.com/obolnetwork/charon/app/errors"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
 )
 
 // errorResponse an error response from the beacon-node api.
@@ -99,12 +98,12 @@ type aggregateAttestationV2Response struct {
 	Data    any    `json:"data"`
 }
 
-type aggregateBeaconCommitteeSelectionsJSON struct {
-	Data []*eth2exp.BeaconCommitteeSelection `json:"data"`
+type beaconCommitteeSelectionsJSON struct {
+	Data []*eth2v1.BeaconCommitteeSelection `json:"data"`
 }
 
-type aggregateSyncCommitteeSelectionsJSON struct {
-	Data []*eth2exp.SyncCommitteeSelection `json:"data"`
+type syncCommitteeSelectionsJSON struct {
+	Data []*eth2v1.SyncCommitteeSelection `json:"data"`
 }
 
 // root wraps eth2p0 root adding proper json marshalling.
diff --git a/core/validatorapi/mocks/handler.go b/core/validatorapi/mocks/handler.go
index 87b8c026..6d0b7f14 100644
--- a/core/validatorapi/mocks/handler.go
+++ b/core/validatorapi/mocks/handler.go
@@ -10,8 +10,6 @@ import (
 
 	context "context"
 
-	eth2exp "github.com/obolnetwork/charon/eth2util/eth2exp"
-
 	mock "github.com/stretchr/testify/mock"
 
 	phase0 "github.com/attestantio/go-eth2-client/spec/phase0"
@@ -56,66 +54,6 @@ func (_m *Handler) AggregateAttestation(ctx context.Context, opts *api.Aggregate
 	return r0, r1
 }
 
-// AggregateBeaconCommitteeSelections provides a mock function with given fields: ctx, partialSelections
-func (_m *Handler) AggregateBeaconCommitteeSelections(ctx context.Context, partialSelections []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error) {
-	ret := _m.Called(ctx, partialSelections)
-
-	if len(ret) == 0 {
-		panic("no return value specified for AggregateBeaconCommitteeSelections")
-	}
-
-	var r0 []*eth2exp.BeaconCommitteeSelection
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error)); ok {
-		return rf(ctx, partialSelections)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, []*eth2exp.BeaconCommitteeSelection) []*eth2exp.BeaconCommitteeSelection); ok {
-		r0 = rf(ctx, partialSelections)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]*eth2exp.BeaconCommitteeSelection)
-		}
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, []*eth2exp.BeaconCommitteeSelection) error); ok {
-		r1 = rf(ctx, partialSelections)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
-// AggregateSyncCommitteeSelections provides a mock function with given fields: ctx, partialSelections
-func (_m *Handler) AggregateSyncCommitteeSelections(ctx context.Context, partialSelections []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error) {
-	ret := _m.Called(ctx, partialSelections)
-
-	if len(ret) == 0 {
-		panic("no return value specified for AggregateSyncCommitteeSelections")
-	}
-
-	var r0 []*eth2exp.SyncCommitteeSelection
-	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error)); ok {
-		return rf(ctx, partialSelections)
-	}
-	if rf, ok := ret.Get(0).(func(context.Context, []*eth2exp.SyncCommitteeSelection) []*eth2exp.SyncCommitteeSelection); ok {
-		r0 = rf(ctx, partialSelections)
-	} else {
-		if ret.Get(0) != nil {
-			r0 = ret.Get(0).([]*eth2exp.SyncCommitteeSelection)
-		}
-	}
-
-	if rf, ok := ret.Get(1).(func(context.Context, []*eth2exp.SyncCommitteeSelection) error); ok {
-		r1 = rf(ctx, partialSelections)
-	} else {
-		r1 = ret.Error(1)
-	}
-
-	return r0, r1
-}
-
 // AttestationData provides a mock function with given fields: ctx, opts
 func (_m *Handler) AttestationData(ctx context.Context, opts *api.AttestationDataOpts) (*api.Response[*phase0.AttestationData], error) {
 	ret := _m.Called(ctx, opts)
@@ -176,28 +114,28 @@ func (_m *Handler) AttesterDuties(ctx context.Context, opts *api.AttesterDutiesO
 	return r0, r1
 }
 
-// NodeVersion provides a mock function with given fields: ctx, opts
-func (_m *Handler) NodeVersion(ctx context.Context, opts *api.NodeVersionOpts) (*api.Response[string], error) {
+// BeaconCommitteeSelections provides a mock function with given fields: ctx, opts
+func (_m *Handler) BeaconCommitteeSelections(ctx context.Context, opts *api.BeaconCommitteeSelectionsOpts) (*api.Response[[]*v1.BeaconCommitteeSelection], error) {
 	ret := _m.Called(ctx, opts)
 
 	if len(ret) == 0 {
-		panic("no return value specified for NodeVersion")
+		panic("no return value specified for BeaconCommitteeSelections")
 	}
 
-	var r0 *api.Response[string]
+	var r0 *api.Response[[]*v1.BeaconCommitteeSelection]
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *api.NodeVersionOpts) (*api.Response[string], error)); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, *api.BeaconCommitteeSelectionsOpts) (*api.Response[[]*v1.BeaconCommitteeSelection], error)); ok {
 		return rf(ctx, opts)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, *api.NodeVersionOpts) *api.Response[string]); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, *api.BeaconCommitteeSelectionsOpts) *api.Response[[]*v1.BeaconCommitteeSelection]); ok {
 		r0 = rf(ctx, opts)
 	} else {
 		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*api.Response[string])
+			r0 = ret.Get(0).(*api.Response[[]*v1.BeaconCommitteeSelection])
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, *api.NodeVersionOpts) error); ok {
+	if rf, ok := ret.Get(1).(func(context.Context, *api.BeaconCommitteeSelectionsOpts) error); ok {
 		r1 = rf(ctx, opts)
 	} else {
 		r1 = ret.Error(1)
@@ -206,28 +144,28 @@ func (_m *Handler) NodeVersion(ctx context.Context, opts *api.NodeVersionOpts) (
 	return r0, r1
 }
 
-// Proposal provides a mock function with given fields: ctx, opts
-func (_m *Handler) Proposal(ctx context.Context, opts *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) {
+// NodeVersion provides a mock function with given fields: ctx, opts
+func (_m *Handler) NodeVersion(ctx context.Context, opts *api.NodeVersionOpts) (*api.Response[string], error) {
 	ret := _m.Called(ctx, opts)
 
 	if len(ret) == 0 {
-		panic("no return value specified for Proposal")
+		panic("no return value specified for NodeVersion")
 	}
 
-	var r0 *api.Response[*api.VersionedProposal]
+	var r0 *api.Response[string]
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error)); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, *api.NodeVersionOpts) (*api.Response[string], error)); ok {
 		return rf(ctx, opts)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, *api.ProposalOpts) *api.Response[*api.VersionedProposal]); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, *api.NodeVersionOpts) *api.Response[string]); ok {
 		r0 = rf(ctx, opts)
 	} else {
 		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*api.Response[*api.VersionedProposal])
+			r0 = ret.Get(0).(*api.Response[string])
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, *api.ProposalOpts) error); ok {
+	if rf, ok := ret.Get(1).(func(context.Context, *api.NodeVersionOpts) error); ok {
 		r1 = rf(ctx, opts)
 	} else {
 		r1 = ret.Error(1)
@@ -236,29 +174,29 @@ func (_m *Handler) Proposal(ctx context.Context, opts *api.ProposalOpts) (*api.R
 	return r0, r1
 }
 
-// ProposerConfig provides a mock function with given fields: ctx
-func (_m *Handler) ProposerConfig(ctx context.Context) (*eth2exp.ProposerConfigResponse, error) {
-	ret := _m.Called(ctx)
+// Proposal provides a mock function with given fields: ctx, opts
+func (_m *Handler) Proposal(ctx context.Context, opts *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error) {
+	ret := _m.Called(ctx, opts)
 
 	if len(ret) == 0 {
-		panic("no return value specified for ProposerConfig")
+		panic("no return value specified for Proposal")
 	}
 
-	var r0 *eth2exp.ProposerConfigResponse
+	var r0 *api.Response[*api.VersionedProposal]
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context) (*eth2exp.ProposerConfigResponse, error)); ok {
-		return rf(ctx)
+	if rf, ok := ret.Get(0).(func(context.Context, *api.ProposalOpts) (*api.Response[*api.VersionedProposal], error)); ok {
+		return rf(ctx, opts)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context) *eth2exp.ProposerConfigResponse); ok {
-		r0 = rf(ctx)
+	if rf, ok := ret.Get(0).(func(context.Context, *api.ProposalOpts) *api.Response[*api.VersionedProposal]); ok {
+		r0 = rf(ctx, opts)
 	} else {
 		if ret.Get(0) != nil {
-			r0 = ret.Get(0).(*eth2exp.ProposerConfigResponse)
+			r0 = ret.Get(0).(*api.Response[*api.VersionedProposal])
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context) error); ok {
-		r1 = rf(ctx)
+	if rf, ok := ret.Get(1).(func(context.Context, *api.ProposalOpts) error); ok {
+		r1 = rf(ctx, opts)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -500,6 +438,36 @@ func (_m *Handler) SyncCommitteeDuties(ctx context.Context, opts *api.SyncCommit
 	return r0, r1
 }
 
+// SyncCommitteeSelections provides a mock function with given fields: ctx, opts
+func (_m *Handler) SyncCommitteeSelections(ctx context.Context, opts *api.SyncCommitteeSelectionsOpts) (*api.Response[[]*v1.SyncCommitteeSelection], error) {
+	ret := _m.Called(ctx, opts)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SyncCommitteeSelections")
+	}
+
+	var r0 *api.Response[[]*v1.SyncCommitteeSelection]
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, *api.SyncCommitteeSelectionsOpts) (*api.Response[[]*v1.SyncCommitteeSelection], error)); ok {
+		return rf(ctx, opts)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, *api.SyncCommitteeSelectionsOpts) *api.Response[[]*v1.SyncCommitteeSelection]); ok {
+		r0 = rf(ctx, opts)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*api.Response[[]*v1.SyncCommitteeSelection])
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, *api.SyncCommitteeSelectionsOpts) error); ok {
+		r1 = rf(ctx, opts)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
 // Validators provides a mock function with given fields: ctx, opts
 func (_m *Handler) Validators(ctx context.Context, opts *api.ValidatorsOpts) (*api.Response[map[phase0.ValidatorIndex]*v1.Validator], error) {
 	ret := _m.Called(ctx, opts)
diff --git a/core/validatorapi/router.go b/core/validatorapi/router.go
index 04acead2..8a2110cc 100644
--- a/core/validatorapi/router.go
+++ b/core/validatorapi/router.go
@@ -43,7 +43,6 @@ import (
 	"github.com/obolnetwork/charon/app/log"
 	"github.com/obolnetwork/charon/app/z"
 	"github.com/obolnetwork/charon/core"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
 	"github.com/obolnetwork/charon/tbls/tblsconv"
 )
 
@@ -71,7 +70,7 @@ type Handler interface {
 	eth2client.AttesterDutiesProvider
 	eth2client.ProposalProvider
 	eth2client.ProposalSubmitter
-	eth2exp.BeaconCommitteeSelectionAggregator
+	eth2client.BeaconCommitteeSelectionsProvider
 	eth2client.BlindedProposalSubmitter
 	eth2client.NodeVersionProvider
 	eth2client.ProposerDutiesProvider
@@ -79,11 +78,10 @@ type Handler interface {
 	eth2client.SyncCommitteeContributionsSubmitter
 	eth2client.SyncCommitteeDutiesProvider
 	eth2client.SyncCommitteeMessagesSubmitter
-	eth2exp.SyncCommitteeSelectionAggregator
+	eth2client.SyncCommitteeSelectionsProvider
 	eth2client.ValidatorsProvider
 	eth2client.ValidatorRegistrationsSubmitter
 	eth2client.VoluntaryExitSubmitter
-	eth2exp.ProposerConfigProvider
 	// Above sorted alphabetically.
 }
 
@@ -132,7 +130,7 @@ func NewRouter(ctx context.Context, h Handler, eth2Cl eth2wrap.Client, builderEn
 			Path:      "/eth/v1/beacon/pool/attestations",
 			Handler:   respond404("/eth/v1/beacon/pool/attestations"),
 			Methods:   []string{http.MethodPost},
-			Encodings: []contentType{},
+			Encodings: []contentType{contentTypeJSON},
 		},
 		{
 			Name:      "submit_attestations_v2",
@@ -160,14 +158,14 @@ func NewRouter(ctx context.Context, h Handler, eth2Cl eth2wrap.Client, builderEn
 			Path:      "/eth/v2/validator/blocks/{slot}",
 			Handler:   respond404("/eth/v2/validator/blocks/{slot}"),
 			Methods:   []string{http.MethodGet},
-			Encodings: []contentType{},
+			Encodings: []contentType{contentTypeJSON, contentTypeSSZ},
 		},
 		{
 			Name:      "propose_blinded_block",
 			Path:      "/eth/v1/validator/blinded_blocks/{slot}",
 			Handler:   respond404("/eth/v1/validator/blinded_blocks/{slot}"),
 			Methods:   []string{http.MethodGet},
-			Encodings: []contentType{},
+			Encodings: []contentType{contentTypeJSON},
 		},
 		{
 			Name:      "propose_block_v3",
@@ -221,21 +219,21 @@ func NewRouter(ctx context.Context, h Handler, eth2Cl eth2wrap.Client, builderEn
 		{
 			Name:      "teku_proposer_config",
 			Path:      "/teku_proposer_config",
-			Handler:   proposerConfig(h),
+			Handler:   respond404("/teku_proposer_config"),
 			Methods:   []string{http.MethodGet},
 			Encodings: []contentType{contentTypeJSON},
 		},
 		{
 			Name:      "proposer_config",
 			Path:      "/proposer_config",
-			Handler:   proposerConfig(h),
+			Handler:   respond404("/proposer_config"),
 			Methods:   []string{http.MethodGet},
 			Encodings: []contentType{contentTypeJSON},
 		},
 		{
 			Name:      "aggregate_beacon_committee_selections",
 			Path:      "/eth/v1/validator/beacon_committee_selections",
-			Handler:   aggregateBeaconCommitteeSelections(h),
+			Handler:   beaconCommitteeSelections(h),
 			Methods:   []string{http.MethodPost},
 			Encodings: []contentType{contentTypeJSON},
 		},
@@ -244,7 +242,7 @@ func NewRouter(ctx context.Context, h Handler, eth2Cl eth2wrap.Client, builderEn
 			Path:      "/eth/v1/validator/aggregate_attestation",
 			Handler:   respond404("/eth/v1/validator/aggregate_attestation"),
 			Methods:   []string{http.MethodGet},
-			Encodings: []contentType{},
+			Encodings: []contentType{contentTypeJSON},
 		},
 		{
 			Name:      "aggregate_attestation_v2",
@@ -258,7 +256,7 @@ func NewRouter(ctx context.Context, h Handler, eth2Cl eth2wrap.Client, builderEn
 			Path:      "/eth/v1/validator/aggregate_and_proofs",
 			Handler:   respond404("/eth/v1/validator/aggregate_and_proofs"),
 			Methods:   []string{http.MethodPost},
-			Encodings: []contentType{},
+			Encodings: []contentType{contentTypeJSON},
 		},
 		{
 			Name:      "submit_aggregate_and_proofs_v2",
@@ -298,7 +296,7 @@ func NewRouter(ctx context.Context, h Handler, eth2Cl eth2wrap.Client, builderEn
 		{
 			Name:      "aggregate_sync_committee_selections",
 			Path:      "/eth/v1/validator/sync_committee_selections",
-			Handler:   aggregateSyncCommitteeSelections(h),
+			Handler:   syncCommitteeSelections(h),
 			Methods:   []string{http.MethodPost},
 			Encodings: []contentType{contentTypeJSON},
 		},
@@ -1176,37 +1174,37 @@ func submitValidatorRegistrations(r eth2client.ValidatorRegistrationsSubmitter)
 	}
 }
 
-// aggregateBeaconCommitteeSelections receives partial beacon committee selections and returns aggregated selections.
-func aggregateBeaconCommitteeSelections(a eth2exp.BeaconCommitteeSelectionAggregator) handlerFunc {
+// beaconCommitteeSelections receives partial beacon committee selections and returns aggregated selections.
+func beaconCommitteeSelections(a eth2client.BeaconCommitteeSelectionsProvider) handlerFunc {
 	return func(ctx context.Context, _ map[string]string, _ http.Header, _ url.Values, typ contentType, body []byte) (res any, headers http.Header, err error) {
-		var selections []*eth2exp.BeaconCommitteeSelection
+		var selections []*eth2v1.BeaconCommitteeSelection
 		if err := unmarshal(typ, body, &selections); err != nil {
 			return nil, nil, errors.Wrap(err, "unmarshal beacon committee selections")
 		}
 
-		resp, err := a.AggregateBeaconCommitteeSelections(ctx, selections)
+		eth2Resp, err := a.BeaconCommitteeSelections(ctx, &eth2api.BeaconCommitteeSelectionsOpts{Selections: selections})
 		if err != nil {
-			return nil, nil, err
+			return nil, nil, errors.Wrap(err, "beacon committee selections")
 		}
 
-		return aggregateBeaconCommitteeSelectionsJSON{Data: resp}, nil, nil
+		return beaconCommitteeSelectionsJSON{Data: eth2Resp.Data}, nil, nil
 	}
 }
 
-// aggregateSyncCommitteeSelections receives partial sync committee selections and returns aggregated selections.
-func aggregateSyncCommitteeSelections(a eth2exp.SyncCommitteeSelectionAggregator) handlerFunc {
+// syncCommitteeSelections receives partial sync committee selections and returns aggregated selections.
+func syncCommitteeSelections(a eth2client.SyncCommitteeSelectionsProvider) handlerFunc {
 	return func(ctx context.Context, _ map[string]string, _ http.Header, _ url.Values, typ contentType, body []byte) (res any, headers http.Header, err error) {
-		var selections []*eth2exp.SyncCommitteeSelection
+		var selections []*eth2v1.SyncCommitteeSelection
 		if err := unmarshal(typ, body, &selections); err != nil {
 			return nil, nil, errors.Wrap(err, "unmarshal sync committee selections")
 		}
 
-		resp, err := a.AggregateSyncCommitteeSelections(ctx, selections)
+		eth2Resp, err := a.SyncCommitteeSelections(ctx, &eth2api.SyncCommitteeSelectionsOpts{Selections: selections})
 		if err != nil {
-			return nil, nil, err
+			return nil, nil, errors.Wrap(err, "sync committee selections")
 		}
 
-		return aggregateSyncCommitteeSelectionsJSON{Data: resp}, nil, nil
+		return syncCommitteeSelectionsJSON{Data: eth2Resp.Data}, nil, nil
 	}
 }
 
@@ -1222,17 +1220,6 @@ func submitExit(p eth2client.VoluntaryExitSubmitter) handlerFunc {
 	}
 }
 
-func proposerConfig(p eth2exp.ProposerConfigProvider) handlerFunc {
-	return func(ctx context.Context, _ map[string]string, _ http.Header, _ url.Values, _ contentType, _ []byte) (any, http.Header, error) {
-		resp, err := p.ProposerConfig(ctx)
-		if err != nil {
-			return nil, nil, errors.Wrap(err, "proposer config")
-		}
-
-		return resp, nil, nil
-	}
-}
-
 func aggregateAttestation(p eth2client.AggregateAttestationProvider) handlerFunc {
 	return func(ctx context.Context, _ map[string]string, _ http.Header, query url.Values, _ contentType, _ []byte) (any, http.Header, error) {
 		slot, err := uintQuery(query, "slot")
@@ -1637,9 +1624,9 @@ func unmarshal(typ contentType, body []byte, v any) error {
 		}
 
 		return nil
+	default:
+		return errors.New("bug: invalid content type")
 	}
-
-	return errors.New("bug: invalid content type")
 }
 
 // uintParam returns a uint path parameter.
diff --git a/core/validatorapi/validatorapi.go b/core/validatorapi/validatorapi.go
index 8d5dbafc..e146c0ff 100644
--- a/core/validatorapi/validatorapi.go
+++ b/core/validatorapi/validatorapi.go
@@ -5,9 +5,9 @@ package validatorapi
 import (
 	"context"
 	"fmt"
+	"maps"
 	"math/big"
 	"runtime"
-	"strconv"
 	"testing"
 	"time"
 
@@ -26,7 +26,6 @@ import (
 	"github.com/obolnetwork/charon/app/z"
 	"github.com/obolnetwork/charon/core"
 	"github.com/obolnetwork/charon/eth2util"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
 	"github.com/obolnetwork/charon/eth2util/signing"
 	"github.com/obolnetwork/charon/tbls"
 	"github.com/obolnetwork/charon/tbls/tblsconv"
@@ -782,8 +781,8 @@ func (c Component) SubmitVoluntaryExit(ctx context.Context, exit *eth2p0.SignedV
 	return nil
 }
 
-// AggregateBeaconCommitteeSelections returns aggregate beacon committee selection proofs.
-func (c Component) AggregateBeaconCommitteeSelections(ctx context.Context, selections []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error) {
+// BeaconCommitteeSelections returns aggregate beacon committee selection proofs.
+func (c Component) BeaconCommitteeSelections(ctx context.Context, opts *eth2api.BeaconCommitteeSelectionsOpts) (*eth2api.Response[[]*eth2v1.BeaconCommitteeSelection], error) {
 	vals, err := c.eth2Cl.ActiveValidators(ctx)
 	if err != nil {
 		return nil, err
@@ -791,7 +790,7 @@ func (c Component) AggregateBeaconCommitteeSelections(ctx context.Context, selec
 
 	psigsBySlot := make(map[eth2p0.Slot]core.ParSignedDataSet)
 
-	for _, selection := range selections {
+	for _, selection := range opts.Selections {
 		eth2Pubkey, ok := vals[selection.ValidatorIndex]
 		if !ok {
 			return nil, errors.New("validator not found", z.Any("provided", selection.ValidatorIndex), z.Any("expected", vals.Indices()))
@@ -828,7 +827,27 @@ func (c Component) AggregateBeaconCommitteeSelections(ctx context.Context, selec
 		}
 	}
 
-	return c.getAggregateBeaconCommSelection(ctx, psigsBySlot)
+	var resp []*eth2v1.BeaconCommitteeSelection
+
+	for slot, data := range psigsBySlot {
+		duty := core.NewPrepareAggregatorDuty(uint64(slot))
+		for pk := range data {
+			// Query aggregated subscription from aggsigdb for each duty and public key (this is blocking).
+			s, err := c.awaitAggSigDBFunc(ctx, duty, pk)
+			if err != nil {
+				return nil, err
+			}
+
+			sub, ok := s.(core.BeaconCommitteeSelection)
+			if !ok {
+				return nil, errors.New("invalid beacon committee selection")
+			}
+
+			resp = append(resp, &sub.BeaconCommitteeSelection)
+		}
+	}
+
+	return wrapResponse(resp), nil
 }
 
 // AggregateAttestation returns the aggregate attestation for the given attestation root.
@@ -1036,8 +1055,8 @@ func (c Component) SubmitSyncCommitteeContributions(ctx context.Context, contrib
 	return nil
 }
 
-// AggregateSyncCommitteeSelections returns aggregate sync committee selection proofs.
-func (c Component) AggregateSyncCommitteeSelections(ctx context.Context, partialSelections []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error) {
+// SyncCommitteeSelections returns aggregate sync committee selection proofs.
+func (c Component) SyncCommitteeSelections(ctx context.Context, opts *eth2api.SyncCommitteeSelectionsOpts) (*eth2api.Response[[]*eth2v1.SyncCommitteeSelection], error) {
 	vals, err := c.eth2Cl.ActiveValidators(ctx)
 	if err != nil {
 		return nil, err
@@ -1045,7 +1064,7 @@ func (c Component) AggregateSyncCommitteeSelections(ctx context.Context, partial
 
 	psigsBySlot := make(map[eth2p0.Slot]core.ParSignedDataSet)
 
-	for _, selection := range partialSelections {
+	for _, selection := range opts.Selections {
 		eth2Pubkey, ok := vals[selection.ValidatorIndex]
 		if !ok {
 			return nil, errors.New("validator not found")
@@ -1082,7 +1101,27 @@ func (c Component) AggregateSyncCommitteeSelections(ctx context.Context, partial
 		}
 	}
 
-	return c.getAggregateSyncCommSelection(ctx, psigsBySlot)
+	var resp []*eth2v1.SyncCommitteeSelection
+
+	for slot, data := range psigsBySlot {
+		duty := core.NewPrepareSyncContributionDuty(uint64(slot))
+		for pk := range data {
+			// Query aggregated sync committee selection from aggsigdb for each duty and public key (this is blocking).
+			s, err := c.awaitAggSigDBFunc(ctx, duty, pk)
+			if err != nil {
+				return nil, err
+			}
+
+			sub, ok := s.(core.SyncCommitteeSelection)
+			if !ok {
+				return nil, errors.New("invalid sync committee selection")
+			}
+
+			resp = append(resp, &sub.SyncCommitteeSelection)
+		}
+	}
+
+	return wrapResponse(resp), nil
 }
 
 // ProposerDuties obtains proposer duties for the given options.
@@ -1228,9 +1267,7 @@ func (c Component) Validators(ctx context.Context, opts *eth2api.ValidatorsOpts)
 			return nil, errors.Wrap(err, "fetching non-cached validators from BN")
 		}
 
-		for idx, val := range eth2Resp.Data {
-			ret[idx] = val
-		}
+		maps.Copy(ret, eth2Resp.Data)
 	} else {
 		log.Debug(ctx, "All validators requested were cached", z.Int("amount_requested", len(opts.PubKeys)))
 	}
@@ -1317,110 +1354,6 @@ func (c Component) verifyPartialSig(ctx context.Context, parSig core.ParSignedDa
 	return core.VerifyEth2SignedData(ctx, c.eth2Cl, eth2Signed, pubshare)
 }
 
-func (c Component) getAggregateBeaconCommSelection(ctx context.Context, psigsBySlot map[eth2p0.Slot]core.ParSignedDataSet) ([]*eth2exp.BeaconCommitteeSelection, error) {
-	var resp []*eth2exp.BeaconCommitteeSelection
-
-	for slot, data := range psigsBySlot {
-		duty := core.NewPrepareAggregatorDuty(uint64(slot))
-		for pk := range data {
-			// Query aggregated subscription from aggsigdb for each duty and public key (this is blocking).
-			s, err := c.awaitAggSigDBFunc(ctx, duty, pk)
-			if err != nil {
-				return nil, err
-			}
-
-			sub, ok := s.(core.BeaconCommitteeSelection)
-			if !ok {
-				return nil, errors.New("invalid beacon committee selection")
-			}
-
-			resp = append(resp, &sub.BeaconCommitteeSelection)
-		}
-	}
-
-	return resp, nil
-}
-
-func (c Component) getAggregateSyncCommSelection(ctx context.Context, psigsBySlot map[eth2p0.Slot]core.ParSignedDataSet) ([]*eth2exp.SyncCommitteeSelection, error) {
-	var resp []*eth2exp.SyncCommitteeSelection
-
-	for slot, data := range psigsBySlot {
-		duty := core.NewPrepareSyncContributionDuty(uint64(slot))
-		for pk := range data {
-			// Query aggregated sync committee selection from aggsigdb for each duty and public key (this is blocking).
-			s, err := c.awaitAggSigDBFunc(ctx, duty, pk)
-			if err != nil {
-				return nil, err
-			}
-
-			sub, ok := s.(core.SyncCommitteeSelection)
-			if !ok {
-				return nil, errors.New("invalid sync committee selection")
-			}
-
-			resp = append(resp, &sub.SyncCommitteeSelection)
-		}
-	}
-
-	return resp, nil
-}
-
-// ProposerConfig returns the proposer configuration for all validators.
-func (c Component) ProposerConfig(ctx context.Context) (*eth2exp.ProposerConfigResponse, error) {
-	var targetGasLimit uint
-	if c.targetGasLimit == 0 {
-		log.Warn(ctx, "", errors.New("custom target gas limit not supported, setting to default", z.Uint("default_gas_limit", defaultGasLimit)))
-		targetGasLimit = defaultGasLimit
-	} else {
-		targetGasLimit = c.targetGasLimit
-	}
-
-	resp := eth2exp.ProposerConfigResponse{
-		Proposers: make(map[eth2p0.BLSPubKey]eth2exp.ProposerConfig),
-		Default: eth2exp.ProposerConfig{ // Default doesn't make sense, disable for now.
-			FeeRecipient: zeroAddress,
-			Builder: eth2exp.Builder{
-				Enabled:  false,
-				GasLimit: targetGasLimit,
-			},
-		},
-	}
-
-	genesisTime, err := eth2wrap.FetchGenesisTime(ctx, c.eth2Cl)
-	if err != nil {
-		return nil, err
-	}
-
-	slotDuration, _, err := eth2wrap.FetchSlotsConfig(ctx, c.eth2Cl)
-	if err != nil {
-		return nil, err
-	}
-
-	timestamp := genesisTime
-	timestamp = timestamp.Add(slotDuration) // Use slot 1 for timestamp to override pre-generated registrations.
-
-	for pubkey, pubshare := range c.sharesByKey {
-		eth2Share, err := pubshare.ToETH2()
-		if err != nil {
-			return nil, err
-		}
-
-		resp.Proposers[eth2Share] = eth2exp.ProposerConfig{
-			FeeRecipient: c.feeRecipientFunc(pubkey),
-			Builder: eth2exp.Builder{
-				Enabled:  c.builderEnabled,
-				GasLimit: targetGasLimit,
-				Overrides: map[string]string{
-					"timestamp":  strconv.FormatInt(timestamp.Unix(), 10),
-					"public_key": string(pubkey),
-				},
-			},
-		}
-	}
-
-	return &resp, nil
-}
-
 // wrapResponse wraps the provided data into an API Response and returns the response.
 func wrapResponse[T any](data T) *eth2api.Response[T] {
 	return &eth2api.Response[T]{Data: data}
diff --git a/dkg/bcast/client.go b/dkg/bcast/client.go
index 41d9fc67..ba8bfa92 100644
--- a/dkg/bcast/client.go
+++ b/dkg/bcast/client.go
@@ -18,11 +18,11 @@ import (
 )
 
 // newClient creates a new reliable-broadcast client.
-func newClient(tcpNode host.Host, peers []peer.ID, sendRecvFunc p2p.SendReceiveFunc,
+func newClient(p2pNode host.Host, peers []peer.ID, sendRecvFunc p2p.SendReceiveFunc,
 	sendFunc p2p.SendFunc, hashFunc hashFunc, signFunc signFunc, verifyFunc verifyFunc,
 ) *client {
 	return &client{
-		tcpNode:      tcpNode,
+		p2pNode:      p2pNode,
 		peers:        peers,
 		sendRecvFunc: sendRecvFunc,
 		sendFunc:     sendFunc,
@@ -34,7 +34,7 @@ func newClient(tcpNode host.Host, peers []peer.ID, sendRecvFunc p2p.SendReceiveF
 
 // client is a reliable-broadcast client.
 type client struct {
-	tcpNode      host.Host
+	p2pNode      host.Host
 	peers        []peer.ID
 	sendRecvFunc p2p.SendReceiveFunc
 	sendFunc     p2p.SendFunc
@@ -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.tcpNode, pID, sigReq, sigResp, protocolIDSig)
+		err := c.sendRecvFunc(ctx, c.p2pNode, pID, sigReq, sigResp, protocolIDSig)
 
 		return sigResp, err
 	})
@@ -76,7 +76,7 @@ func (c *client) Broadcast(ctx context.Context, msgID string, msg proto.Message)
 	// Fork
 
 	for i, pID := range c.peers {
-		if c.tcpNode.ID() == pID {
+		if c.p2pNode.ID() == pID {
 			// Sign self locally.
 			sig, err := c.signFunc(msgID, hash)
 			if err != nil {
@@ -129,11 +129,11 @@ func (c *client) Broadcast(ctx context.Context, msgID string, msg proto.Message)
 	}
 
 	for _, pID := range c.peers {
-		if c.tcpNode.ID() == pID {
+		if c.p2pNode.ID() == pID {
 			continue // Skip self.
 		}
 
-		err := c.sendFunc(ctx, c.tcpNode, protocolIDMsg, pID, bcastMsg, p2p.WithSendTimeout(sendTimeout))
+		err := c.sendFunc(ctx, c.p2pNode, protocolIDMsg, pID, bcastMsg, p2p.WithSendTimeout(sendTimeout))
 		if err != nil {
 			return errors.Wrap(err, "send message")
 		}
diff --git a/dkg/bcast/impl.go b/dkg/bcast/impl.go
index 1548c75a..267eae56 100644
--- a/dkg/bcast/impl.go
+++ b/dkg/bcast/impl.go
@@ -55,7 +55,7 @@ func (c *Component) Broadcast(ctx context.Context, msgID string, msg proto.Messa
 }
 
 // New registers a new reliable-broadcast server and returns a reliable-broadcast client function.
-func New(tcpNode host.Host, peers []peer.ID, secret *k1.PrivateKey) *Component {
+func New(p2pNode host.Host, peers []peer.ID, secret *k1.PrivateKey) *Component {
 	c := Component{
 		allowedMsgIDs: map[string]struct{}{},
 		secret:        secret,
@@ -65,10 +65,10 @@ func New(tcpNode host.Host, peers []peer.ID, secret *k1.PrivateKey) *Component {
 	signFunc := c.newK1Signer()
 	verifyFunc := c.newPeerK1Verifier()
 
-	cl := newClient(tcpNode, peers, p2p.SendReceive, p2p.Send, hashAny, signFunc, verifyFunc)
+	cl := newClient(p2pNode, peers, p2p.SendReceive, p2p.Send, hashAny, signFunc, verifyFunc)
 
 	c.broadcastFunc = cl.Broadcast
-	c.srv = newServer(tcpNode, signFunc, hashAny, verifyFunc)
+	c.srv = newServer(p2pNode, signFunc, hashAny, verifyFunc)
 
 	return &c
 }
diff --git a/dkg/bcast/server.go b/dkg/bcast/server.go
index a2193249..dc72c681 100644
--- a/dkg/bcast/server.go
+++ b/dkg/bcast/server.go
@@ -18,7 +18,7 @@ import (
 )
 
 // newServer creates a new reliable-broadcast server.
-func newServer(tcpNode host.Host, signFunc signFunc, hashFunc hashFunc, verifyFunc verifyFunc) *server {
+func newServer(p2pNode host.Host, signFunc signFunc, hashFunc hashFunc, verifyFunc verifyFunc) *server {
 	s := &server{
 		msgIDFuncs: map[string]messageIDFuncs{},
 		signFunc:   signFunc,
@@ -27,13 +27,13 @@ func newServer(tcpNode host.Host, signFunc signFunc, hashFunc hashFunc, verifyFu
 		dedup:      make(map[dedupKey][]byte),
 	}
 
-	p2p.RegisterHandler("bcast", tcpNode, protocolIDSig,
+	p2p.RegisterHandler("bcast", p2pNode, protocolIDSig,
 		func() proto.Message { return new(pb.BCastSigRequest) },
 		s.handleSigRequest,
 		p2p.WithReceiveTimeout(receiveTimeout),
 	)
 
-	p2p.RegisterHandler("bcast", tcpNode, protocolIDMsg,
+	p2p.RegisterHandler("bcast", p2pNode, protocolIDMsg,
 		func() proto.Message { return new(pb.BCastMessage) },
 		s.handleMessage,
 		p2p.WithReceiveTimeout(receiveTimeout),
diff --git a/dkg/dkg.go b/dkg/dkg.go
index a6ce1512..ecb27b05 100644
--- a/dkg/dkg.go
+++ b/dkg/dkg.go
@@ -69,14 +69,14 @@ type TestConfig struct {
 	P2PKey           *k1.PrivateKey
 	SyncCallback     func(connected int, id peer.ID)
 	StoreKeysFunc    func(secrets []tbls.PrivateKey, dir string) error
-	TCPNodeCallback  func(host.Host)
+	P2PNodeCallback  func(host.Host)
 	ShutdownCallback func()
 	SyncOpts         []func(*sync.Client)
 }
 
 // HasTestConfig returns true if any of the test config fields are set.
 func (c Config) HasTestConfig() bool {
-	return c.TestConfig.StoreKeysFunc != nil || c.TestConfig.SyncCallback != nil || c.TestConfig.Def != nil || c.TestConfig.TCPNodeCallback != nil
+	return c.TestConfig.StoreKeysFunc != nil || c.TestConfig.SyncCallback != nil || c.TestConfig.Def != nil || c.TestConfig.P2PNodeCallback != nil
 }
 
 // Run executes a dkg ceremony and writes secret share keystore and cluster lock files as output to disk.
@@ -178,13 +178,13 @@ func Run(ctx context.Context, conf Config) (err error) {
 
 	logPeerSummary(ctx, pID, peers, def.Operators)
 
-	tcpNode, shutdown, err := setupP2P(ctx, key, conf, peers, def.DefinitionHash)
+	p2pNode, shutdown, err := setupP2P(ctx, key, conf, peers, def.DefinitionHash)
 	if err != nil {
 		return err
 	}
 	defer shutdown()
 
-	nodeIdx, err := def.NodeIdx(tcpNode.ID())
+	nodeIdx, err := def.NodeIdx(p2pNode.ID())
 	if err != nil {
 		return errors.Wrap(err, "private key not matching definition file")
 	}
@@ -194,7 +194,7 @@ func Run(ctx context.Context, conf Config) (err error) {
 		return errors.Wrap(err, "get peer IDs")
 	}
 
-	ex := newExchanger(tcpNode, nodeIdx.PeerIdx, peerIDs, def.NumValidators, []sigType{
+	ex := newExchanger(p2pNode, nodeIdx.PeerIdx, peerIDs, def.NumValidators, []sigType{
 		sigLock,
 		sigDepositData,
 		sigValidatorRegistration,
@@ -211,10 +211,10 @@ func Run(ctx context.Context, conf Config) (err error) {
 		peerMap[p.ID] = nodeIdx
 	}
 
-	caster := bcast.New(tcpNode, peerIDs, key)
+	caster := bcast.New(p2pNode, peerIDs, key)
 
 	// register bcast callbacks for frostp2p
-	tp, err := newFrostP2P(tcpNode, peerMap, caster, def.Threshold, def.NumValidators)
+	tp, err := newFrostP2P(p2pNode, peerMap, caster, def.Threshold, def.NumValidators)
 	if err != nil {
 		return errors.Wrap(err, "frost error")
 	}
@@ -227,7 +227,7 @@ func Run(ctx context.Context, conf Config) (err error) {
 	// Improve UX of "context cancelled" errors when sync fails.
 	ctx = errors.WithCtxErr(ctx, "p2p connection failed, please retry DKG")
 
-	nextStepSync, stopSync, err := startSyncProtocol(ctx, tcpNode, key, def.DefinitionHash, peerIDs, cancel, conf.TestConfig)
+	nextStepSync, stopSync, err := startSyncProtocol(ctx, p2pNode, key, def.DefinitionHash, peerIDs, cancel, conf.TestConfig)
 	if err != nil {
 		return err
 	}
@@ -418,36 +418,36 @@ func setupP2P(ctx context.Context, key *k1.PrivateKey, conf Config, peers []p2p.
 		return nil, nil, err
 	}
 
-	tcpNode, err := p2p.NewTCPNode(ctx, conf.P2P, key, connGater, false)
+	p2pNode, err := p2p.NewNode(ctx, conf.P2P, key, connGater, false, p2p.NodeTypeTCP)
 	if err != nil {
 		return nil, nil, err
 	}
 
-	if conf.TestConfig.TCPNodeCallback != nil {
-		conf.TestConfig.TCPNodeCallback(tcpNode)
+	if conf.TestConfig.P2PNodeCallback != nil {
+		conf.TestConfig.P2PNodeCallback(p2pNode)
 	}
 
-	p2p.RegisterConnectionLogger(ctx, tcpNode, peerIDs)
+	p2p.RegisterConnectionLogger(ctx, p2pNode, peerIDs)
 
 	for _, relay := range relays {
-		go p2p.NewRelayReserver(tcpNode, relay)(ctx)
+		go p2p.NewRelayReserver(p2pNode, relay)(ctx)
 	}
 
-	go p2p.NewRelayRouter(tcpNode, peerIDs, relays)(ctx)
+	go p2p.NewRelayRouter(p2pNode, peerIDs, relays)(ctx)
 
 	// Register peerinfo server handler for identification to relays (but do not run peerinfo client).
 	gitHash, _ := version.GitCommit()
 
-	_ = peerinfo.New(tcpNode, peerIDs, version.Version, defHash, gitHash, nil, false, "")
+	_ = peerinfo.New(p2pNode, peerIDs, version.Version, defHash, gitHash, nil, false, "")
 
-	return tcpNode, func() {
-		_ = tcpNode.Close()
+	return p2pNode, func() {
+		_ = p2pNode.Close()
 	}, nil
 }
 
 // startSyncProtocol sets up a sync protocol server and clients for each peer and returns a step sync and shutdown functions
 // when all peers are connected.
-func startSyncProtocol(ctx context.Context, tcpNode host.Host, key *k1.PrivateKey, defHash []byte,
+func startSyncProtocol(ctx context.Context, p2pNode host.Host, key *k1.PrivateKey, defHash []byte,
 	peerIDs []peer.ID, onFailure func(), testConfig TestConfig,
 ) (stepSyncFunc func(context.Context) error, shutdownFunc func(context.Context) error, err error) {
 	// Sign definition hash with charon-enr-private-key
@@ -460,19 +460,19 @@ func startSyncProtocol(ctx context.Context, tcpNode host.Host, key *k1.PrivateKe
 	// DKG compatibility is minor version dependent.
 	minorVersion := version.Version.Minor()
 
-	server := sync.NewServer(tcpNode, len(peerIDs)-1, defHash, minorVersion)
+	server := sync.NewServer(p2pNode, len(peerIDs)-1, defHash, minorVersion)
 	server.Start(ctx)
 
 	var clients []*sync.Client
 
 	for _, pID := range peerIDs {
-		if tcpNode.ID() == pID {
+		if p2pNode.ID() == pID {
 			continue
 		}
 
 		ctx := log.WithCtx(ctx, z.Str("peer", p2p.PeerName(pID)))
 
-		client := sync.NewClient(tcpNode, pID, hashSig, minorVersion, testConfig.SyncOpts...)
+		client := sync.NewClient(p2pNode, pID, hashSig, minorVersion, testConfig.SyncOpts...)
 		clients = append(clients, client)
 
 		go func() {
@@ -504,7 +504,7 @@ func startSyncProtocol(ctx context.Context, tcpNode host.Host, key *k1.PrivateKe
 		}
 
 		if testConfig.SyncCallback != nil {
-			testConfig.SyncCallback(connectedCount, tcpNode.ID())
+			testConfig.SyncCallback(connectedCount, p2pNode.ID())
 		}
 
 		// Break if all clients are connected
diff --git a/dkg/exchanger.go b/dkg/exchanger.go
index dc4311d3..1907c715 100644
--- a/dkg/exchanger.go
+++ b/dkg/exchanger.go
@@ -4,6 +4,7 @@ package dkg
 
 import (
 	"context"
+	"maps"
 	"slices"
 	"sync"
 	"time"
@@ -56,7 +57,7 @@ type exchanger struct {
 	sigDatasChan  chan map[core.PubKey][]core.ParSignedData
 }
 
-func newExchanger(tcpNode host.Host, peerIdx int, peers []peer.ID, vals int, sigTypes []sigType, timeout time.Duration) *exchanger {
+func newExchanger(p2pNode host.Host, peerIdx int, peers []peer.ID, vals int, sigTypes []sigType, timeout time.Duration) *exchanger {
 	// Partial signature roots not known yet, so skip verification in parsigex, rather verify before we aggregate.
 	noopVerifier := func(context.Context, core.Duty, core.PubKey, core.ParSignedData) error {
 		return nil
@@ -83,7 +84,7 @@ func newExchanger(tcpNode host.Host, peerIdx int, peers []peer.ID, vals int, sig
 	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{}),
-		sigex:    parsigex.NewParSigEx(tcpNode, p2p.Send, peerIdx, peers, noopVerifier, dutyGaterFunc, p2p.WithSendTimeout(timeout), p2p.WithReceiveTimeout(timeout)),
+		sigex:    parsigex.NewParSigEx(p2pNode, p2p.Send, peerIdx, peers, noopVerifier, dutyGaterFunc, p2p.WithSendTimeout(timeout), p2p.WithReceiveTimeout(timeout)),
 		sigTypes: st,
 		sigData: dataByPubkey{
 			store:   sigTypeStore{},
@@ -153,9 +154,7 @@ func (e *exchanger) pushPsigs(ctx context.Context, duty core.Duty, set map[core.
 	}
 
 	ret := make(map[core.PubKey][]core.ParSignedData)
-	for k, v := range data {
-		ret[k] = v
-	}
+	maps.Copy(ret, data)
 
 	e.sigData.lock.Unlock()
 
diff --git a/dkg/frostp2p.go b/dkg/frostp2p.go
index b71d66ee..a92e4094 100644
--- a/dkg/frostp2p.go
+++ b/dkg/frostp2p.go
@@ -38,7 +38,7 @@ func frostMessageIDs() []string {
 
 // newFrostP2P returns a p2p frost transport implementation.
 // It registers bcast handlers on bcastComp.
-func newFrostP2P(tcpNode host.Host, peers map[peer.ID]cluster.NodeIdx, bcastComp *bcast.Component, threshold, numVals int) (*frostP2P, error) {
+func newFrostP2P(p2pNode host.Host, peers map[peer.ID]cluster.NodeIdx, bcastComp *bcast.Component, threshold, numVals int) (*frostP2P, error) {
 	var (
 		round1CastsRecv = make(chan *pb.FrostRound1Casts, len(peers))
 		round1P2PRecv   = make(chan *pb.FrostRound1P2P, len(peers))
@@ -54,9 +54,9 @@ func newFrostP2P(tcpNode host.Host, peers map[peer.ID]cluster.NodeIdx, bcastComp
 	}
 
 	// Register round 1 p2p protocol handlers.
-	p2p.RegisterHandler("frost", tcpNode, round1P2PID,
+	p2p.RegisterHandler("frost", p2pNode, round1P2PID,
 		func() proto.Message { return new(pb.FrostRound1P2P) },
-		newP2PCallback(tcpNode, peers, round1P2PRecv, numVals),
+		newP2PCallback(p2pNode, peers, round1P2PRecv, numVals),
 	)
 
 	bcastCallback := newBcastCallback(peers, round1CastsRecv, round2CastsRecv, threshold, numVals)
@@ -71,7 +71,7 @@ func newFrostP2P(tcpNode host.Host, peers map[peer.ID]cluster.NodeIdx, bcastComp
 	}
 
 	return &frostP2P{
-		tcpNode:         tcpNode,
+		p2pNode:         p2pNode,
 		peers:           peersByShareIdx,
 		bcastFunc:       bcastComp.Broadcast,
 		round1CastsRecv: round1CastsRecv,
@@ -200,7 +200,7 @@ func newBcastCallback(peers map[peer.ID]cluster.NodeIdx, round1CastsRecv chan *p
 }
 
 // newP2PCallback returns a callback for P2P messages in round 1 of frost protocol.
-func newP2PCallback(tcpNode host.Host, peers map[peer.ID]cluster.NodeIdx, round1P2PRecv chan *pb.FrostRound1P2P, numVals int) p2p.HandlerFunc {
+func newP2PCallback(p2pNode host.Host, peers map[peer.ID]cluster.NodeIdx, round1P2PRecv chan *pb.FrostRound1P2P, numVals int) p2p.HandlerFunc {
 	var (
 		mu             sync.Mutex
 		dedupRound1P2P = make(map[peer.ID]bool)
@@ -218,7 +218,7 @@ func newP2PCallback(tcpNode host.Host, peers map[peer.ID]cluster.NodeIdx, round1
 		for _, share := range msg.GetShares() {
 			if int(share.GetKey().GetSourceId()) != peers[pID].ShareIdx {
 				return nil, false, errors.New("invalid round 1 p2p source ID")
-			} else if int(share.GetKey().GetTargetId()) != peers[tcpNode.ID()].ShareIdx {
+			} else if int(share.GetKey().GetTargetId()) != peers[p2pNode.ID()].ShareIdx {
 				return nil, false, errors.New("invalid round 1 p2p target ID")
 			} else if int(share.GetKey().GetValIdx()) < 0 || int(share.GetKey().GetValIdx()) >= numVals {
 				return nil, false, errors.New("invalid round 1 p2p validator index")
@@ -240,7 +240,7 @@ func newP2PCallback(tcpNode host.Host, peers map[peer.ID]cluster.NodeIdx, round1
 
 // frostP2P implements frost transport.
 type frostP2P struct {
-	tcpNode         host.Host
+	p2pNode         host.Host
 	peers           map[uint32]peer.ID // map[shareIdx)peerID
 	bcastFunc       bcast.BroadcastFunc
 	round1CastsRecv chan *pb.FrostRound1Casts
@@ -287,11 +287,11 @@ func (f *frostP2P) Round1(ctx context.Context, castR1 map[msgKey]frost.Round1Bca
 
 	// Send messages to all peers
 	for pID, p2pMsg := range p2pMsgs {
-		if pID == f.tcpNode.ID() {
+		if pID == f.p2pNode.ID() {
 			return nil, nil, errors.New("bug: unexpected p2p message to self")
 		}
 
-		err := p2p.Send(ctx, f.tcpNode, round1P2PID, pID, p2pMsg)
+		err := p2p.Send(ctx, f.p2pNode, round1P2PID, pID, p2pMsg)
 		if err != nil {
 			return nil, nil, err
 		}
diff --git a/dkg/sync/client.go b/dkg/sync/client.go
index 90bfd0e4..90ad7994 100644
--- a/dkg/sync/client.go
+++ b/dkg/sync/client.go
@@ -29,9 +29,9 @@ func WithPeriod(period time.Duration) func(*Client) {
 }
 
 // NewClient returns a new Client instance.
-func NewClient(tcpNode host.Host, peer peer.ID, hashSig []byte, version version.SemVer, opts ...func(*Client)) *Client {
+func NewClient(p2pNode host.Host, peer peer.ID, hashSig []byte, version version.SemVer, opts ...func(*Client)) *Client {
 	c := &Client{
-		tcpNode:   tcpNode,
+		p2pNode:   p2pNode,
 		peer:      peer,
 		hashSig:   hashSig,
 		shutdown:  make(chan struct{}),
@@ -63,7 +63,7 @@ type Client struct {
 	// Immutable state
 	hashSig []byte
 	version version.SemVer
-	tcpNode host.Host
+	p2pNode host.Host
 	peer    peer.ID
 	period  time.Duration
 }
@@ -189,7 +189,7 @@ func (c *Client) sendMsgs(ctx context.Context, stream network.Stream) (relayBrok
 		}
 
 		rtt := time.Since(resp.GetSyncTimestamp().AsTime())
-		c.tcpNode.Peerstore().RecordLatency(c.peer, rtt)
+		c.p2pNode.Peerstore().RecordLatency(c.peer, rtt)
 	}
 }
 
@@ -224,7 +224,7 @@ func (c *Client) connect(ctx context.Context) (network.Stream, error) {
 	)
 
 	for {
-		s, err := c.tcpNode.NewStream(network.WithAllowLimitedConn(ctx, "sync"), c.peer, protocolID)
+		s, err := c.p2pNode.NewStream(network.WithAllowLimitedConn(ctx, "sync"), c.peer, protocolID)
 		if ctx.Err() != nil {
 			return nil, ctx.Err()
 		} else if err != nil {
diff --git a/dkg/sync/server.go b/dkg/sync/server.go
index 21753b53..a91ff1d2 100644
--- a/dkg/sync/server.go
+++ b/dkg/sync/server.go
@@ -35,10 +35,10 @@ func Protocols() []protocol.ID {
 }
 
 // NewServer returns a new Server instance.
-func NewServer(tcpNode host.Host, allCount int, defHash []byte, version version.SemVer) *Server {
+func NewServer(p2pNode host.Host, allCount int, defHash []byte, version version.SemVer) *Server {
 	return &Server{
 		defHash:   defHash,
-		tcpNode:   tcpNode,
+		p2pNode:   p2pNode,
 		allCount:  allCount,
 		shutdown:  make(map[peer.ID]struct{}),
 		connected: make(map[peer.ID]struct{}),
@@ -51,7 +51,7 @@ func NewServer(tcpNode host.Host, allCount int, defHash []byte, version version.
 // definition hash signatures, and supports waiting for shutdown by all clients.
 type Server struct {
 	// Immutable state
-	tcpNode  host.Host
+	p2pNode  host.Host
 	defHash  []byte
 	version  version.SemVer
 	allCount int // Excluding self
@@ -320,7 +320,7 @@ func (s *Server) validReq(pubkey crypto.PubKey, msg *pb.MsgSync) error {
 
 // Start registers sync protocol with the libp2p host.
 func (s *Server) Start(ctx context.Context) {
-	s.tcpNode.SetStreamHandler(protocolID, func(stream network.Stream) {
+	s.p2pNode.SetStreamHandler(protocolID, func(stream network.Stream) {
 		ctx := log.WithCtx(ctx, z.Str("peer", p2p.PeerName(stream.Conn().RemotePeer())))
 
 		err := s.handleStream(ctx, stream)
diff --git a/docs/configuration.md b/docs/configuration.md
index 2fe121ea..49419283 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -57,7 +57,7 @@ config hash and definition hash are calculated.
 The creator `config_signature` and operator `config_signature` and `enr_signature` are [EIP712](https://eips.ethereum.org/EIPS/eip-712) signatures of **typed structured data** as opposed to just raw bytes. EIP712 enables users to see the object that they are signing in their wallet (ex: Metamask).
 See [eip712sigs.go](../cluster/eip712sigs.go) for details on the EIP712 structure used to create these signatures.
 
-The above `cluster-definition.json` is generated by the [dv-launchpad](https://launchpad.obol.dev/) provided as input to the `charon dkg` command which generates keys and the `cluster-lock.json` file.
+The above `cluster-definition.json` is generated by the [dv-launchpad](https://launchpad.obol.org/) provided as input to the `charon dkg` command which generates keys and the `cluster-lock.json` file.
 
 The `cluster-lock.json` has the following schema:
 ```json
@@ -192,6 +192,7 @@ Flags:
       --p2p-external-ip string                   The IP address advertised by libp2p. This may be used to advertise an external IP.
       --p2p-relays strings                       Comma-separated list of libp2p relay URLs or multiaddrs. (default [https://0.relay.obol.tech,https://2.relay.obol.dev,https://1.relay.obol.tech])
       --p2p-tcp-address strings                  Comma-separated list of listening TCP addresses (ip and port) for libP2P traffic. Empty default doesn't bind to local port therefore only supports outgoing connections.
+      --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.
diff --git a/docs/metrics.md b/docs/metrics.md
index 5065909a..5833a5cd 100644
--- a/docs/metrics.md
+++ b/docs/metrics.md
@@ -81,7 +81,7 @@ when storing metrics from multiple nodes or clusters in one Prometheus instance.
 | `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_peer_connection_total` | Counter | Total number of libp2p connections per peer. | `peer` |
-| `p2p_peer_connection_types` | Gauge | Current number of libp2p connections by peer and type (`direct` or `relay`). Note that peers may have multiple connections. | `peer, type` |
+| `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. | `peer, protocol` |
 | `p2p_peer_network_sent_bytes_total` | Counter | Total number of network bytes sent to the peer by protocol. | `peer, protocol` |
 | `p2p_peer_streams` | Gauge | Current number of libp2p streams by peer, direction (`inbound` or `outbound` or `unknown`) and protocol. | `peer, direction, protocol` |
diff --git a/eth2util/enr/enr.go b/eth2util/enr/enr.go
index 601fc830..23f7b234 100644
--- a/eth2util/enr/enr.go
+++ b/eth2util/enr/enr.go
@@ -86,6 +86,7 @@ func Parse(enrStr string) (Record, error) {
 			if string(val) != valID {
 				return Record{}, errors.New("non-v4 identity scheme not supported")
 			}
+		default:
 		}
 	}
 
@@ -117,7 +118,7 @@ func WithTCP(port int) Option {
 	}
 }
 
-// WithUDP returns an option that sets the TCP port of the record.
+// WithUDP returns an option that sets the UDP port of the record.
 func WithUDP(port int) Option {
 	return func(kvs map[string][]byte) {
 		kvs[keyUDP] = toBigEndian(port)
diff --git a/eth2util/eth2exp/attagg.go b/eth2util/eth2exp/attagg.go
deleted file mode 100644
index 1724ded3..00000000
--- a/eth2util/eth2exp/attagg.go
+++ /dev/null
@@ -1,114 +0,0 @@
-// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
-
-package eth2exp
-
-import (
-	"context"
-	"encoding/hex"
-	"encoding/json"
-	"fmt"
-	"strconv"
-	"strings"
-
-	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
-
-	"github.com/obolnetwork/charon/app/errors"
-)
-
-// BeaconCommitteeSelectionAggregator is the interface for aggregating beacon committee selection proofs in a DVT cluster.
-// NOTE: It should be removed once it is supported by go-eth2-client.
-type BeaconCommitteeSelectionAggregator interface {
-	// AggregateBeaconCommitteeSelections returns DVT aggregated beacon committee selection proofs.
-	// This would call a new BN API endpoint: POST /eth/v1/validator/beacon_committee_selections
-	AggregateBeaconCommitteeSelections(ctx context.Context, partialSelections []*BeaconCommitteeSelection) ([]*BeaconCommitteeSelection, error)
-}
-
-// BeaconCommitteeSelection is the data required for a beacon committee subscription.
-type BeaconCommitteeSelection struct {
-	// ValidatorIdex is the index of the validator making the aggregate request.
-	ValidatorIndex eth2p0.ValidatorIndex
-	// Slot is the slot for which the validator is possibly aggregating.
-	Slot eth2p0.Slot
-	// SelectionProof is the slot signature required to calculate whether the validator is an aggregator.
-	SelectionProof eth2p0.BLSSignature
-}
-
-// beaconCommitteeSelectionJSON is the spec representation of the struct.
-type beaconCommitteeSelectionJSON struct {
-	ValidatorIndex string `json:"validator_index"`
-	Slot           string `json:"slot"`
-	SelectionProof string `json:"selection_proof"`
-}
-
-// MarshalJSON implements json.Marshaler.
-func (b *BeaconCommitteeSelection) MarshalJSON() ([]byte, error) {
-	resp, err := json.Marshal(&beaconCommitteeSelectionJSON{
-		ValidatorIndex: fmt.Sprintf("%d", b.ValidatorIndex),
-		Slot:           fmt.Sprintf("%d", b.Slot),
-		SelectionProof: fmt.Sprintf("%#x", b.SelectionProof),
-	})
-	if err != nil {
-		return nil, errors.Wrap(err, "marshal beacon committee subscription")
-	}
-
-	return resp, nil
-}
-
-// UnmarshalJSON implements json.Unmarshaler.
-func (b *BeaconCommitteeSelection) UnmarshalJSON(input []byte) error {
-	var err error
-
-	var beaconCommitteeSelectionJSON beaconCommitteeSelectionJSON
-	if err = json.Unmarshal(input, &beaconCommitteeSelectionJSON); err != nil {
-		return errors.Wrap(err, "invalid JSON")
-	}
-
-	if beaconCommitteeSelectionJSON.ValidatorIndex == "" {
-		return errors.New("validator index missing")
-	}
-
-	validatorIndex, err := strconv.ParseUint(beaconCommitteeSelectionJSON.ValidatorIndex, 10, 64)
-	if err != nil {
-		return errors.Wrap(err, "invalid value for validator index")
-	}
-
-	b.ValidatorIndex = eth2p0.ValidatorIndex(validatorIndex)
-
-	if beaconCommitteeSelectionJSON.Slot == "" {
-		return errors.New("slot missing")
-	}
-
-	slot, err := strconv.ParseUint(beaconCommitteeSelectionJSON.Slot, 10, 64)
-	if err != nil {
-		return errors.Wrap(err, "invalid value for slot")
-	}
-
-	b.Slot = eth2p0.Slot(slot)
-
-	if beaconCommitteeSelectionJSON.SelectionProof == "" {
-		return errors.New("selection proof missing")
-	}
-
-	signature, err := hex.DecodeString(strings.TrimPrefix(beaconCommitteeSelectionJSON.SelectionProof, "0x"))
-	if err != nil {
-		return errors.Wrap(err, "invalid value for signature")
-	}
-
-	if len(signature) != eth2p0.SignatureLength {
-		return errors.New("incorrect length for signature")
-	}
-
-	copy(b.SelectionProof[:], signature)
-
-	return nil
-}
-
-// String returns a string version of the structure.
-func (b *BeaconCommitteeSelection) String() (string, error) {
-	data, err := json.Marshal(b)
-	if err != nil {
-		return "", errors.Wrap(err, "marshal BeaconCommitteeSelection")
-	}
-
-	return string(data), nil
-}
diff --git a/eth2util/eth2exp/proposeconf.go b/eth2util/eth2exp/proposeconf.go
deleted file mode 100644
index a77983e8..00000000
--- a/eth2util/eth2exp/proposeconf.go
+++ /dev/null
@@ -1,126 +0,0 @@
-// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
-
-package eth2exp
-
-import (
-	"context"
-	"encoding/hex"
-	"encoding/json"
-	"fmt"
-	"strconv"
-	"strings"
-	"time"
-
-	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
-
-	"github.com/obolnetwork/charon/app/errors"
-)
-
-const (
-	builderOverrideTimestamp = "timestamp"
-	builderOverridePublicKey = "public_key"
-)
-
-// ProposerConfigProvider is the interface for providing proposer configuration.
-type ProposerConfigProvider interface {
-	ProposerConfig(ctx context.Context) (*ProposerConfigResponse, error)
-}
-
-// ProposerConfigResponse is the response from the proposer config endpoint.
-type ProposerConfigResponse struct {
-	Proposers map[eth2p0.BLSPubKey]ProposerConfig
-	Default   ProposerConfig
-}
-
-func (p ProposerConfigResponse) MarshalJSON() ([]byte, error) {
-	resp := &proposerConfigResponseJSON{
-		Proposers: make(map[string]ProposerConfig),
-		Default:   p.Default,
-	}
-	for k, v := range p.Proposers {
-		resp.Proposers[fmt.Sprintf("%#x", k)] = v
-	}
-
-	b, err := json.Marshal(resp)
-	if err != nil {
-		return nil, errors.Wrap(err, "marshal proposer config response")
-	}
-
-	return b, nil
-}
-
-func (p *ProposerConfigResponse) UnmarshalJSON(input []byte) error {
-	resp := new(proposerConfigResponseJSON)
-	if err := json.Unmarshal(input, resp); err != nil {
-		return errors.Wrap(err, "unmarshal proposer config response")
-	}
-
-	p.Proposers = make(map[eth2p0.BLSPubKey]ProposerConfig)
-
-	for k, v := range resp.Proposers {
-		pkBytes, err := hex.DecodeString(strings.TrimPrefix(k, "0x"))
-		if err != nil {
-			return errors.Wrap(err, "decode proposer public key")
-		}
-
-		if len(pkBytes) != 48 {
-			return errors.New("invalid proposer public key")
-		}
-
-		p.Proposers[eth2p0.BLSPubKey(pkBytes)] = v
-	}
-
-	p.Default = resp.Default
-
-	return nil
-}
-
-// ProposerConfig is the configuration for a proposer.
-type ProposerConfig struct {
-	FeeRecipient string  `json:"fee_recipient"`
-	Builder      Builder `json:"builder"`
-}
-
-// Builder is the build-API configuration for a proposer.
-type Builder struct {
-	Enabled   bool              `json:"enabled"`
-	GasLimit  uint              `json:"gas_limit"`
-	Overrides map[string]string `json:"registration_overrides,omitempty"`
-}
-
-// TimestampOverride returns the timestamp override and true if it exists.
-func (b Builder) TimestampOverride() (time.Time, bool, error) {
-	ts, ok := b.Overrides[builderOverrideTimestamp]
-	if !ok {
-		return time.Time{}, false, nil
-	}
-
-	tsUnix, err := strconv.ParseInt(ts, 10, 64)
-	if err != nil {
-		return time.Time{}, false, errors.Wrap(err, "parse timestamp override")
-	}
-
-	return time.Unix(tsUnix, 0), true, nil
-}
-
-// PublicKeyOverride returns the public key override and true if it exists.
-func (b Builder) PublicKeyOverride() (eth2p0.BLSPubKey, bool, error) {
-	pk, ok := b.Overrides[builderOverridePublicKey]
-	if !ok {
-		return eth2p0.BLSPubKey{}, false, nil
-	}
-
-	pkBytes, err := hex.DecodeString(strings.TrimPrefix(pk, "0x"))
-	if err != nil {
-		return eth2p0.BLSPubKey{}, false, errors.Wrap(err, "parse public key override")
-	} else if len(pkBytes) != 48 {
-		return eth2p0.BLSPubKey{}, false, errors.New("invalid public key override")
-	}
-
-	return eth2p0.BLSPubKey(pkBytes), true, nil
-}
-
-type proposerConfigResponseJSON struct {
-	Proposers map[string]ProposerConfig `json:"proposer_config"`
-	Default   ProposerConfig            `json:"default_config"`
-}
diff --git a/eth2util/eth2exp/synccomm_agg.go b/eth2util/eth2exp/synccomm_agg.go
deleted file mode 100644
index f4c09836..00000000
--- a/eth2util/eth2exp/synccomm_agg.go
+++ /dev/null
@@ -1,133 +0,0 @@
-// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
-
-package eth2exp
-
-import (
-	"context"
-	"encoding/hex"
-	"encoding/json"
-	"fmt"
-	"strconv"
-	"strings"
-
-	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
-
-	"github.com/obolnetwork/charon/app/errors"
-	"github.com/obolnetwork/charon/app/z"
-)
-
-// SyncCommitteeSelectionAggregator is the interface for aggregating sync committee selection proofs.
-type SyncCommitteeSelectionAggregator interface {
-	// AggregateSyncCommitteeSelections returns the threshold aggregated sync committee selection proofs.
-	// This would call a new BN API endpoint: POST /eth/v1/validator/sync_committee_selections
-	AggregateSyncCommitteeSelections(ctx context.Context, partialSelections []*SyncCommitteeSelection) ([]*SyncCommitteeSelection, error)
-}
-
-// SyncCommitteeSelection is the data required for a sync committee subscription.
-type SyncCommitteeSelection struct {
-	// ValidatorIndex is the index of the validator making the selection request.
-	ValidatorIndex eth2p0.ValidatorIndex
-	// Slot is the slot for which the selection request is made.
-	Slot eth2p0.Slot
-	// SubcommitteeIndex is the subcommittee to which the validator is assigned.
-	SubcommitteeIndex eth2p0.CommitteeIndex
-	// SelectionProof is a partial or an aggregated signature.
-	SelectionProof eth2p0.BLSSignature
-}
-
-// syncCommitteeSelectionJSON is the spec representation of the struct.
-type syncCommitteeSelectionJSON struct {
-	ValidatorIndex    string `json:"validator_index"`
-	Slot              string `json:"slot"`
-	SubcommitteeIndex string `json:"subcommittee_index"`
-	SelectionProof    string `json:"selection_proof"`
-}
-
-// MarshalJSON implements json.Marshaler.
-func (s *SyncCommitteeSelection) MarshalJSON() ([]byte, error) {
-	resp, err := json.Marshal(&syncCommitteeSelectionJSON{
-		ValidatorIndex:    fmt.Sprintf("%d", s.ValidatorIndex),
-		Slot:              fmt.Sprintf("%d", s.Slot),
-		SubcommitteeIndex: fmt.Sprintf("%d", s.SubcommitteeIndex),
-		SelectionProof:    fmt.Sprintf("%#x", s.SelectionProof),
-	})
-	if err != nil {
-		return nil, errors.Wrap(err, "marshal sync committee selection")
-	}
-
-	return resp, nil
-}
-
-// UnmarshalJSON implements json.Unmarshaler.
-func (s *SyncCommitteeSelection) UnmarshalJSON(input []byte) error {
-	var err error
-
-	var syncCommitteeSelectionJSON syncCommitteeSelectionJSON
-	if err = json.Unmarshal(input, &syncCommitteeSelectionJSON); err != nil {
-		return errors.Wrap(err, "invalid JSON")
-	}
-
-	// verify and unmarshal ValidatorIndex.
-	if syncCommitteeSelectionJSON.ValidatorIndex == "" {
-		return errors.New("validator index missing")
-	}
-
-	validatorIndex, err := strconv.ParseUint(syncCommitteeSelectionJSON.ValidatorIndex, 10, 64)
-	if err != nil {
-		return errors.Wrap(err, "invalid value for validator index", z.Str("vIdx", syncCommitteeSelectionJSON.ValidatorIndex))
-	}
-
-	s.ValidatorIndex = eth2p0.ValidatorIndex(validatorIndex)
-
-	// verify and unmarshal Slot.
-	if syncCommitteeSelectionJSON.Slot == "" {
-		return errors.New("slot missing")
-	}
-
-	slot, err := strconv.ParseUint(syncCommitteeSelectionJSON.Slot, 10, 64)
-	if err != nil {
-		return errors.Wrap(err, "invalid value for slot", z.Str("slot", syncCommitteeSelectionJSON.Slot))
-	}
-
-	s.Slot = eth2p0.Slot(slot)
-
-	// verify and unmarshal SubcommitteeIndex.
-	if syncCommitteeSelectionJSON.SubcommitteeIndex == "" {
-		return errors.New("subcommittee index missing")
-	}
-
-	subcommIdx, err := strconv.ParseUint(syncCommitteeSelectionJSON.SubcommitteeIndex, 10, 64)
-	if err != nil {
-		return errors.Wrap(err, "invalid value for subcommittee index", z.Str("subcommIdx", syncCommitteeSelectionJSON.SubcommitteeIndex))
-	}
-
-	s.SubcommitteeIndex = eth2p0.CommitteeIndex(subcommIdx)
-
-	// verify and unmarshal SelectionProof.
-	if syncCommitteeSelectionJSON.SelectionProof == "" {
-		return errors.New("selection proof missing")
-	}
-
-	signature, err := hex.DecodeString(strings.TrimPrefix(syncCommitteeSelectionJSON.SelectionProof, "0x"))
-	if err != nil {
-		return errors.Wrap(err, "invalid value for signature", z.Str("sig", syncCommitteeSelectionJSON.SelectionProof))
-	}
-
-	if len(signature) != eth2p0.SignatureLength {
-		return errors.New("invalid signature length")
-	}
-
-	copy(s.SelectionProof[:], signature)
-
-	return nil
-}
-
-// String returns a string version of SyncCommitteeSelection.
-func (s *SyncCommitteeSelection) String() (string, error) {
-	data, err := json.Marshal(s)
-	if err != nil {
-		return "", errors.Wrap(err, "marshal SyncCommitteeSelection")
-	}
-
-	return string(data), nil
-}
diff --git a/eth2util/statecomm/statecomm.go b/eth2util/statecomm/statecomm.go
deleted file mode 100644
index f1e6f3f8..00000000
--- a/eth2util/statecomm/statecomm.go
+++ /dev/null
@@ -1,109 +0,0 @@
-// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
-
-package statecomm
-
-import (
-	"encoding/json"
-	"fmt"
-	"strconv"
-
-	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
-
-	"github.com/obolnetwork/charon/app/errors"
-)
-
-// StateCommitteesResponse is simplified response structure for fetching the beacon node committees for a given state.
-// This is the response from the BN API endpoint: /eth/v1/beacon/states/{state_id}/validators.
-type StateCommitteesResponse struct {
-	Data []*StateCommittee `json:"data"`
-}
-
-// StateCommittee is the Data field from the BN API endpoint /eth/v1/beacon/states/{state_id}/validators response.
-type StateCommittee struct {
-	Index      eth2p0.CommitteeIndex   `json:"index"`
-	Slot       eth2p0.Slot             `json:"slot"`
-	Validators []eth2p0.ValidatorIndex `json:"validators"`
-}
-
-// beaconCommitteeSelectionJSON is the spec representation of the struct.
-type stateCommitteeJSON struct {
-	Index      string   `json:"index"`
-	Slot       string   `json:"slot"`
-	Validators []string `json:"validators"`
-}
-
-// MarshalJSON implements json.Marshaler.
-func (b *StateCommittee) MarshalJSON() ([]byte, error) {
-	var validators []string
-	for _, v := range b.Validators {
-		validators = append(validators, fmt.Sprintf("%d", v))
-	}
-
-	resp, err := json.Marshal(&stateCommitteeJSON{
-		Index:      fmt.Sprintf("%d", b.Index),
-		Slot:       fmt.Sprintf("%d", b.Slot),
-		Validators: validators,
-	})
-	if err != nil {
-		return nil, errors.Wrap(err, "marshal state committee subscription")
-	}
-
-	return resp, nil
-}
-
-// UnmarshalJSON implements json.Unmarshaler.
-func (b *StateCommittee) UnmarshalJSON(input []byte) error {
-	var err error
-
-	var stateCommitteeJSON stateCommitteeJSON
-	if err = json.Unmarshal(input, &stateCommitteeJSON); err != nil {
-		return errors.Wrap(err, "invalid JSON")
-	}
-
-	if stateCommitteeJSON.Index == "" {
-		return errors.New("index missing")
-	}
-
-	index, err := strconv.ParseUint(stateCommitteeJSON.Index, 10, 64)
-	if err != nil {
-		return errors.Wrap(err, "invalid value for index")
-	}
-
-	b.Index = eth2p0.CommitteeIndex(index)
-
-	if stateCommitteeJSON.Slot == "" {
-		return errors.New("slot missing")
-	}
-
-	slot, err := strconv.ParseUint(stateCommitteeJSON.Slot, 10, 64)
-	if err != nil {
-		return errors.Wrap(err, "invalid value for slot")
-	}
-
-	b.Slot = eth2p0.Slot(slot)
-
-	var validators []eth2p0.ValidatorIndex
-
-	for _, v := range stateCommitteeJSON.Validators {
-		validator, err := strconv.ParseUint(v, 10, 64)
-		if err != nil {
-			return errors.Wrap(err, "invalid value for validator")
-		}
-
-		validators = append(validators, eth2p0.ValidatorIndex(validator))
-	}
-
-	b.Validators = validators
-
-	return nil
-}
-
-// String returns a string version of the structure.
-func (b *StateCommittee) String() (string, error) {
-	data, err := json.Marshal(b)
-	if err != nil {
-		return "", errors.Wrap(err, "marshal StateCommittee")
-	}
-
-	return string(data), nil
-}
diff --git a/go.mod b/go.mod
index 86f7f7d0..0beed2f1 100644
--- a/go.mod
+++ b/go.mod
@@ -7,20 +7,20 @@ require (
 	github.com/attestantio/go-eth2-client v0.26.0
 	github.com/coinbase/kryptology v1.5.6-0.20220316191335-269410e1b06b
 	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
-	github.com/ethereum/go-ethereum v1.16.1
+	github.com/ethereum/go-ethereum v1.16.2
 	github.com/ferranbt/fastssz v0.1.4
 	github.com/golang/snappy v1.0.0
 	github.com/google/gofuzz v1.2.0
 	github.com/gorilla/mux v1.8.1
 	github.com/herumi/bls-eth-go-binary v1.36.4
 	github.com/holiman/uint256 v1.3.2
-	github.com/ipfs/go-log/v2 v2.6.0
+	github.com/ipfs/go-log/v2 v2.8.0
 	github.com/jonboulle/clockwork v0.5.0
 	github.com/jsternberg/zap-logfmt v1.3.0
 	github.com/libp2p/go-libp2p v0.41.1
 	github.com/libp2p/go-msgio v0.3.0
 	github.com/multiformats/go-multiaddr v0.16.0
-	github.com/prometheus/client_golang v1.22.0
+	github.com/prometheus/client_golang v1.23.0
 	github.com/prometheus/client_model v0.6.2
 	github.com/protolambda/eth2-shuffle v1.1.0
 	github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15
@@ -28,7 +28,7 @@ require (
 	github.com/rs/zerolog v1.34.0
 	github.com/showwin/speedtest-go v1.7.10
 	github.com/spf13/cobra v1.9.1
-	github.com/spf13/pflag v1.0.6
+	github.com/spf13/pflag v1.0.7
 	github.com/spf13/viper v1.20.1
 	github.com/stretchr/testify v1.10.0
 	github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4 v1.4.1
@@ -41,12 +41,12 @@ require (
 	go.uber.org/automaxprocs v1.6.0
 	go.uber.org/goleak v1.3.0
 	go.uber.org/zap v1.27.0
-	golang.org/x/crypto v0.39.0
+	golang.org/x/crypto v0.40.0
 	golang.org/x/sync v0.16.0
 	golang.org/x/term v0.33.0
 	golang.org/x/text v0.27.0
 	golang.org/x/time v0.12.0
-	golang.org/x/tools v0.34.0
+	golang.org/x/tools v0.35.0
 	google.golang.org/protobuf v1.36.6
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 )
@@ -229,8 +229,8 @@ require (
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/pkg/profile v1.7.0 // indirect
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
-	github.com/prometheus/common v0.63.0 // indirect
-	github.com/prometheus/procfs v0.16.0 // indirect
+	github.com/prometheus/common v0.65.0 // indirect
+	github.com/prometheus/procfs v0.16.1 // indirect
 	github.com/quic-go/qpack v0.5.1 // indirect
 	github.com/quic-go/quic-go v0.50.1 // indirect
 	github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 // indirect
@@ -276,8 +276,8 @@ require (
 	go.uber.org/multierr v1.11.0 // indirect
 	go.uber.org/zap/exp v0.3.0 // indirect
 	golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
-	golang.org/x/mod v0.25.0 // indirect
-	golang.org/x/net v0.41.0 // indirect
+	golang.org/x/mod v0.26.0 // indirect
+	golang.org/x/net v0.42.0 // indirect
 	golang.org/x/sys v0.34.0 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
@@ -293,9 +293,8 @@ require (
 // We're replacing kryptology with our own fork, which fixes dependencies' security vulnerabilities.
 replace github.com/coinbase/kryptology => github.com/ObolNetwork/kryptology v0.1.0
 
-// We're replacing go-eth2-client with a branch off our fork, at version v0.21.10.
-// This is needed to ensure Gnosis compatibility.
-// replace github.com/attestantio/go-eth2-client => github.com/ObolNetwork/go-eth2-client v0.21.11-0.20240822135044-f0a5b21e02c6
+// We're replacing go-eth2-client with a branch off our fork. The branch is kept up to date with the latest attestantio versions.
+replace github.com/attestantio/go-eth2-client => github.com/ObolNetwork/go-eth2-client v0.26.0-obol.2 //nolint
 
 tool (
 	github.com/bufbuild/buf/cmd/buf
diff --git a/go.sum b/go.sum
index af591821..c7180666 100644
--- a/go.sum
+++ b/go.sum
@@ -38,6 +38,8 @@ 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/ObolNetwork/go-eth2-client v0.26.0-obol.2 h1:IwvyBXUPwMFizl9TZeQaG1TwB67ScS1LdXgUrHwD//o=
+github.com/ObolNetwork/go-eth2-client v0.26.0-obol.2/go.mod h1:fvULSL9WtNskkOB4i+Yyr6BKpNHXvmpGZj9969fCrfY=
 github.com/ObolNetwork/kryptology v0.1.0 h1:AhoG4My70+xMhEJSpVaJay/t+T/vIUNHQYLjsDJHulI=
 github.com/ObolNetwork/kryptology v0.1.0/go.mod h1:/Wl7Js2f676GyXZDTaojf/O+l0fxFPWudbyjdFhkpSA=
 github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
@@ -52,8 +54,6 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7D
 github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
 github.com/attestantio/go-builder-client v0.6.4 h1:D0gBdQO4vJLhoFLxdR2TbGdfsWBQZ5YDZBNdU2Ap3uk=
 github.com/attestantio/go-builder-client v0.6.4/go.mod h1:f8wi3HzuPxfJoi2PirpJK3yZhte4SavDgKJbRrKoB1Q=
-github.com/attestantio/go-eth2-client v0.26.0 h1:oDWKvIUJfvr1EBi/w9L6mawYZHOCymjHkml7fZplT20=
-github.com/attestantio/go-eth2-client v0.26.0/go.mod h1:fvULSL9WtNskkOB4i+Yyr6BKpNHXvmpGZj9969fCrfY=
 github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
 github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
 github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@@ -172,8 +172,8 @@ github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfU
 github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
 github.com/ethereum/c-kzg-4844/v2 v2.1.0 h1:gQropX9YFBhl3g4HYhwE70zq3IHFRgbbNPw0Shwzf5w=
 github.com/ethereum/c-kzg-4844/v2 v2.1.0/go.mod h1:TC48kOKjJKPbN7C++qIgt0TJzZ70QznYR7Ob+WXl57E=
-github.com/ethereum/go-ethereum v1.16.1 h1:7684NfKCb1+IChudzdKyZJ12l1Tq4ybPZOITiCDXqCk=
-github.com/ethereum/go-ethereum v1.16.1/go.mod h1:ngYIvmMAYdo4sGW9cGzLvSsPGhDOOzL0jK5S5iXpj0g=
+github.com/ethereum/go-ethereum v1.16.2 h1:VDHqj86DaQiMpnMgc7l0rwZTg0FRmlz74yupSG5SnzI=
+github.com/ethereum/go-ethereum v1.16.2/go.mod h1:X5CIOyo8SuK1Q5GnaEizQVLHT/DfsiGWuNeVdQcEMNA=
 github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
 github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
 github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
@@ -327,8 +327,8 @@ github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf h1:7JTmne
 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-log/v2 v2.6.0 h1:2Nu1KKQQ2ayonKp4MPo6pXCjqw1ULc9iohRqWV5EYqg=
-github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8=
+github.com/ipfs/go-log/v2 v2.8.0 h1:SptNTPJQV3s5EF4FdrTu/yVdOKfGbDgn1EBZx4til2o=
+github.com/ipfs/go-log/v2 v2.8.0/go.mod h1:2LEEhdv8BGubPeSFTyzbqhCqrwqxCbuTNTLWqgNAipo=
 github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus=
 github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
 github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk=
@@ -580,17 +580,17 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
 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 v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
-github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
+github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
+github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 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.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
-github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
-github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
+github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
+github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
 github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
-github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
+github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
 github.com/protolambda/eth2-shuffle v1.1.0 h1:gixIBI84IeugTwwHXm8vej1bSSEhueBCSryA4lAKRLU=
 github.com/protolambda/eth2-shuffle v1.1.0/go.mod h1:FhA2c0tN15LTC+4T9DNVm+55S7uXTTjQ8TQnBuXlkF8=
 github.com/prysmaticlabs/go-bitfield v0.0.0-20240618144021-706c95b2dd15 h1:lC8kiphgdOBTcbTvo8MwkvpKjO0SlAgjv4xIK5FGJ94=
@@ -671,8 +671,9 @@ github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
 github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
 github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
 github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
-github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
 github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
+github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
 github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
 github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
@@ -797,8 +798,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
 golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
 golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
-golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
-golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
+golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
+golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
@@ -811,8 +812,8 @@ 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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
-golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
+golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -836,8 +837,8 @@ golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
 golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
-golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
-golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
+golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -930,8 +931,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
-golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
+golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/p2p/bootnode.go b/p2p/bootnode.go
index 5be0ac7a..cd5f41f8 100644
--- a/p2p/bootnode.go
+++ b/p2p/bootnode.go
@@ -177,13 +177,13 @@ func queryRelayAddrs(ctx context.Context, relayURL string, backoff func(), lockH
 		}
 
 		if strings.HasPrefix(string(b), "enr:") {
-			addr, err := multiAddrFromENRStr(string(b))
+			addrs, err := multiAddrFromENRStr(string(b))
 			if err != nil {
 				log.Warn(ctx, "Failure parsing relay address from ENR (will try again)", err)
 				continue
 			}
 
-			return []ma.Multiaddr{addr}, nil
+			return addrs, nil
 		}
 
 		var addrs []string
@@ -210,7 +210,7 @@ func queryRelayAddrs(ctx context.Context, relayURL string, backoff func(), lockH
 }
 
 // multiAddrFromENRStr returns the multiaddr from the ENR string.
-func multiAddrFromENRStr(enrStr string) (ma.Multiaddr, error) {
+func multiAddrFromENRStr(enrStr string) ([]ma.Multiaddr, error) {
 	r, err := enr.Parse(enrStr)
 	if err != nil {
 		return nil, errors.Wrap(err, "parse ENR")
@@ -221,20 +221,36 @@ func multiAddrFromENRStr(enrStr string) (ma.Multiaddr, error) {
 		return nil, errors.New("enr does not have an IP")
 	}
 
-	port, ok := r.TCP()
-	if !ok {
-		return nil, errors.New("enr does not have a TCP port")
-	}
-
 	id, err := PeerIDFromKey(r.PubKey)
 	if err != nil {
 		return nil, errors.Wrap(err, "get peer ID from ENR key")
 	}
 
-	addr, err := ma.NewMultiaddr(fmt.Sprintf("/ip4/%s/tcp/%d/p2p/%s", ip.String(), port, id))
-	if err != nil {
-		return nil, errors.Wrap(err, "create multiaddr")
+	var addrs []ma.Multiaddr
+
+	port, ok := r.UDP()
+	if ok {
+		addr, err := ma.NewMultiaddr(fmt.Sprintf("/ip4/%s/udp/%d/quic-v1/p2p/%s", ip.String(), port, id))
+		if err != nil {
+			return nil, errors.Wrap(err, "create quic-v1 multiaddr")
+		}
+
+		addrs = append(addrs, addr)
+	}
+
+	port, ok = r.TCP()
+	if ok {
+		addr, err := ma.NewMultiaddr(fmt.Sprintf("/ip4/%s/tcp/%d/p2p/%s", ip.String(), port, id))
+		if err != nil {
+			return nil, errors.Wrap(err, "create tcp multiaddr")
+		}
+
+		addrs = append(addrs, addr)
+	}
+
+	if len(addrs) == 0 {
+		return nil, errors.New("enr does not have TCP nor UDP port")
 	}
 
-	return addr, nil
+	return addrs, nil
 }
diff --git a/p2p/config.go b/p2p/config.go
index 0392e207..7b5d6b8f 100644
--- a/p2p/config.go
+++ b/p2p/config.go
@@ -20,7 +20,9 @@ type Config struct {
 	ExternalHost string
 	// TCPAddrs defines the lib-p2p tcp listen addresses.
 	TCPAddrs []string
-	// DisableReuseport disables TCP port reuse for libp2p.
+	// UDPAddrs defines the lib-p2p udp listen addresses.
+	UDPAddrs []string
+	// DisableReuseport disables port reuse for libp2p.
 	DisableReuseport bool
 }
 
@@ -29,7 +31,7 @@ func (c Config) ParseTCPAddrs() ([]*net.TCPAddr, error) {
 	res := make([]*net.TCPAddr, 0, len(c.TCPAddrs))
 
 	for _, addr := range c.TCPAddrs {
-		tcpAddr, err := resolveListenAddr(addr)
+		tcpAddr, err := resolveListenTCPAddr(addr)
 		if err != nil {
 			return nil, err
 		}
@@ -40,8 +42,45 @@ func (c Config) ParseTCPAddrs() ([]*net.TCPAddr, error) {
 	return res, nil
 }
 
-// Multiaddrs returns the configured addresses as libp2p multiaddrs.
-func (c Config) Multiaddrs() ([]ma.Multiaddr, error) {
+// ParseUDPAddrs returns the configured udp addresses as typed net udp addresses.
+func (c Config) ParseUDPAddrs() ([]*net.UDPAddr, error) {
+	res := make([]*net.UDPAddr, 0, len(c.UDPAddrs))
+
+	for _, addr := range c.UDPAddrs {
+		udpAddr, err := resolveListenUDPAddr(addr)
+		if err != nil {
+			return nil, err
+		}
+
+		res = append(res, udpAddr)
+	}
+
+	return res, nil
+}
+
+// UDPMultiaddrs returns the udp configured addresses as libp2p multiaddrs.
+func (c Config) UDPMultiaddrs() ([]ma.Multiaddr, error) {
+	udpAddrs, err := c.ParseUDPAddrs()
+	if err != nil {
+		return nil, err
+	}
+
+	res := make([]ma.Multiaddr, 0, len(udpAddrs))
+
+	for _, addr := range udpAddrs {
+		maddr, err := multiAddrFromIPUDPPort(addr.IP, addr.Port)
+		if err != nil {
+			return nil, err
+		}
+
+		res = append(res, maddr)
+	}
+
+	return res, nil
+}
+
+// TCPMultiaddrs returns the tcp configured addresses as libp2p multiaddrs.
+func (c Config) TCPMultiaddrs() ([]ma.Multiaddr, error) {
 	tcpAddrs, err := c.ParseTCPAddrs()
 	if err != nil {
 		return nil, err
@@ -50,7 +89,7 @@ func (c Config) Multiaddrs() ([]ma.Multiaddr, error) {
 	res := make([]ma.Multiaddr, 0, len(tcpAddrs))
 
 	for _, addr := range tcpAddrs {
-		maddr, err := multiAddrFromIPPort(addr.IP, addr.Port)
+		maddr, err := multiAddrFromIPTCPPort(addr.IP, addr.Port)
 		if err != nil {
 			return nil, err
 		}
@@ -61,23 +100,57 @@ func (c Config) Multiaddrs() ([]ma.Multiaddr, error) {
 	return res, nil
 }
 
-func resolveListenAddr(addr string) (*net.TCPAddr, error) {
+func resolveListenTCPAddr(addr string) (*net.TCPAddr, error) {
 	tcpAddr, err := net.ResolveTCPAddr("tcp", addr)
 	if err != nil {
-		return nil, errors.Wrap(err, "resolve P2P bind addr")
+		return nil, errors.Wrap(err, "resolve P2P TCP bind addr")
 	}
 
 	if tcpAddr.IP == nil {
-		return nil, errors.New("p2p bind IP not specified")
+		return nil, errors.New("p2p bind TCP IP not specified")
 	}
 
 	return tcpAddr, nil
 }
 
-// multiAddrFromIPPort returns a multiaddr composed of the provided ip (v4 or v6) and tcp port.
-func multiAddrFromIPPort(ip net.IP, port int) (ma.Multiaddr, error) {
+func resolveListenUDPAddr(addr string) (*net.UDPAddr, error) {
+	udpAddr, err := net.ResolveUDPAddr("udp", addr)
+	if err != nil {
+		return nil, errors.Wrap(err, "resolve P2P UDP bind addr")
+	}
+
+	if udpAddr.IP == nil {
+		return nil, errors.New("p2p bind UDP IP not specified")
+	}
+
+	return udpAddr, nil
+}
+
+// multiAddrFromIPUDPPort returns a multiaddr composed of the provided ip (v4 or v6) and udp port.
+func multiAddrFromIPUDPPort(ip net.IP, port int) (ma.Multiaddr, error) {
+	if ip.To4() == nil && ip.To16() == nil {
+		return nil, errors.New("invalid UDP ip address")
+	}
+
+	var typ string
+	if ip.To4() != nil {
+		typ = "ip4"
+	} else if ip.To16() != nil {
+		typ = "ip6"
+	}
+
+	maddr, err := ma.NewMultiaddr(fmt.Sprintf("/%s/%s/udp/%d/quic-v1", typ, ip.String(), port))
+	if err != nil {
+		return nil, errors.Wrap(err, "invalid quic-v1 multiaddr")
+	}
+
+	return maddr, nil
+}
+
+// multiAddrFromIPTCPPort returns a multiaddr composed of the provided ip (v4 or v6) and tcp port.
+func multiAddrFromIPTCPPort(ip net.IP, port int) (ma.Multiaddr, error) {
 	if ip.To4() == nil && ip.To16() == nil {
-		return nil, errors.New("invalid ip address")
+		return nil, errors.New("invalid TCP ip address")
 	}
 
 	var typ string
@@ -89,7 +162,7 @@ func multiAddrFromIPPort(ip net.IP, port int) (ma.Multiaddr, error) {
 
 	maddr, err := ma.NewMultiaddr(fmt.Sprintf("/%s/%s/tcp/%d", typ, ip.String(), port))
 	if err != nil {
-		return nil, errors.Wrap(err, "invalid multiaddr")
+		return nil, errors.Wrap(err, "invalid tcp multiaddr")
 	}
 
 	return maddr, nil
diff --git a/p2p/metrics.go b/p2p/metrics.go
index 115dea9b..49497a77 100644
--- a/p2p/metrics.go
+++ b/p2p/metrics.go
@@ -17,6 +17,11 @@ import (
 const (
 	addrTypeRelay  = "relay"
 	addrTypeDirect = "direct"
+
+	protocolTCP     = "tcp"
+	protocolQUIC    = "quic"
+	protocolUnknown = "unknown"
+	protocolNone    = "none"
 )
 
 var (
@@ -53,8 +58,8 @@ var (
 	peerConnGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{
 		Namespace: "p2p",
 		Name:      "peer_connection_types",
-		Help:      "Current number of libp2p connections by peer and type ('direct' or 'relay'). Note that peers may have multiple connections.",
-	}, []string{"peer", "type"})
+		Help:      "Current number of libp2p connections by peer, type (`direct` or `relay`), and protocol (`tcp`, `quic`). Note that peers may have multiple connections.",
+	}, []string{"peer", "type", "protocol"})
 
 	peerStreamGauge = promauto.NewResetGaugeVec(prometheus.GaugeOpts{
 		Namespace: "p2p",
diff --git a/p2p/p2p.go b/p2p/p2p.go
index b2079a6e..5ada56cb 100644
--- a/p2p/p2p.go
+++ b/p2p/p2p.go
@@ -5,7 +5,9 @@ package p2p
 import (
 	"context"
 	"fmt"
+	"math"
 	"net"
+	"slices"
 	"sync"
 	"time"
 
@@ -16,9 +18,10 @@ import (
 	"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/routing"
-	"github.com/libp2p/go-libp2p/p2p/net/swarm"
 	"github.com/libp2p/go-libp2p/p2p/protocol/identify"
+	quic "github.com/libp2p/go-libp2p/p2p/transport/quic" //nolint:revive // Must be imported with alias
 	"github.com/libp2p/go-libp2p/p2p/transport/tcp"
 	ma "github.com/multiformats/go-multiaddr"
 	manet "github.com/multiformats/go-multiaddr/net"
@@ -32,9 +35,16 @@ import (
 
 var activationThreshOnce = sync.Once{}
 
-// NewTCPNode returns a started tcp-based libp2p host.
-func NewTCPNode(ctx context.Context, cfg Config, key *k1.PrivateKey, connGater ConnGater,
-	filterPrivateAddrs bool, opts ...libp2p.Option,
+type NodeType int
+
+const (
+	NodeTypeTCP NodeType = iota
+	NodeTypeQUIC
+)
+
+// NewP2PNode returns a started libp2p host.
+func NewNode(ctx context.Context, cfg Config, key *k1.PrivateKey, connGater ConnGater,
+	filterPrivateAddrs bool, nodeType NodeType, opts ...libp2p.Option,
 ) (host.Host, error) {
 	activationThreshOnce.Do(func() {
 		// Use own observed addresses as soon as a single relay reports it.
@@ -42,30 +52,61 @@ func NewTCPNode(ctx context.Context, cfg Config, key *k1.PrivateKey, connGater C
 		identify.ActivationThresh = 1
 	})
 
-	addrs, err := cfg.Multiaddrs()
+	var libP2POpts []any // libp2p.Transport requires empty interface options.
+	if cfg.DisableReuseport {
+		libP2POpts = append(libP2POpts, tcp.DisableReuseport())
+	}
+
+	addrs, err := cfg.TCPMultiaddrs()
 	if err != nil {
 		return nil, err
 	}
 
 	if len(addrs) == 0 {
-		log.Info(ctx, "LibP2P not accepting incoming connections since --p2p-tcp-addresses empty")
+		log.Info(ctx, "LibP2P not accepting incoming connections since --p2p-tcp-addresses is empty")
 	}
 
-	externalAddrs, err := externalMultiAddrs(cfg)
+	externalAddrs, err := externalTCPMultiAddrs(cfg)
 	if err != nil {
 		return nil, err
 	}
 
-	var tcpOpts []any // libp2p.Transport requires empty interface options.
-	if cfg.DisableReuseport {
-		tcpOpts = append(tcpOpts, tcp.DisableReuseport())
+	transport := libp2p.Transport(tcp.NewTCPTransport, libP2POpts...)
+
+	// Use both QUIC and TCP transport when QUIC is enabled, as we are accommodating both connections in the cases where QUIC is enabled.
+	if nodeType == NodeTypeQUIC {
+		udpAddrs, err := cfg.UDPMultiaddrs()
+		if err != nil {
+			return nil, err
+		}
+
+		if len(udpAddrs) == 0 {
+			log.Warn(ctx, "LibP2P QUIC enabled but --p2p-udp-addresses is empty", nil)
+		}
+
+		addrs = append(addrs, udpAddrs...)
+		if len(addrs) == 0 {
+			log.Warn(ctx, "LibP2P not accepting incoming connections since --p2p-udp-addresses and --p2p-tcp-addresses are empty", nil)
+		}
+
+		externalUDPAddrs, err := externalUDPMultiAddrs(cfg)
+		if err != nil {
+			return nil, err
+		}
+
+		externalAddrs = append(externalAddrs, externalUDPAddrs...)
+
+		transport = libp2p.ChainOptions(
+			transport,
+			libp2p.Transport(quic.NewTransport),
+		)
 	}
 
 	// Init options.
 	defaultOpts := []libp2p.Option{
 		// Set P2P identity key.
 		libp2p.Identity((*crypto.Secp256k1PrivateKey)(key)),
-		// Set TCP listen addresses.
+		// Set UDP listen addresses.
 		libp2p.ListenAddrs(addrs...),
 		// Set up user-agent.
 		libp2p.UserAgent("obolnetwork-charon/" + version.Version.String()),
@@ -76,18 +117,17 @@ func NewTCPNode(ctx context.Context, cfg Config, key *k1.PrivateKey, connGater C
 		libp2p.AddrsFactory(func(internalAddrs []ma.Multiaddr) []ma.Multiaddr {
 			return filterAdvertisedAddrs(externalAddrs, internalAddrs, filterPrivateAddrs)
 		}),
-		libp2p.Transport(tcp.NewTCPTransport, tcpOpts...),
-		libp2p.SwarmOpts(swarm.WithDialRanker(swarm.NoDelayDialRanker)),
+		transport,
 	}
 
 	defaultOpts = append(defaultOpts, opts...)
 
-	tcpNode, err := libp2p.New(defaultOpts...)
+	p2pNode, err := libp2p.New(defaultOpts...)
 	if err != nil {
 		return nil, errors.Wrap(err, "new libp2p node")
 	}
 
-	return tcpNode, nil
+	return p2pNode, nil
 }
 
 // filterAdvertisedAddrs returns a unique set of external and internal addresses optionally excluding internal private addresses.
@@ -120,15 +160,55 @@ func filterAdvertisedAddrs(externalAddrs, internalAddrs []ma.Multiaddr, excludeI
 	return resp
 }
 
-// externalMultiAddrs returns the external IP and Hostname fields as multiaddrs using the listen TCP address ports.
-func externalMultiAddrs(cfg Config) ([]ma.Multiaddr, error) {
-	tcpAddrs, err := cfg.ParseTCPAddrs()
+// externalUDPMultiAddrs returns the external IP and Hostname fields as multiaddrs using the listen UDP address ports.
+func externalUDPMultiAddrs(cfg Config) ([]ma.Multiaddr, error) {
+	addrs, err := cfg.ParseUDPAddrs()
+	if err != nil {
+		return nil, err
+	}
+
+	var ports []int
+	for _, addr := range addrs {
+		ports = append(ports, addr.Port)
+	}
+
+	var resp []ma.Multiaddr
+
+	if cfg.ExternalIP != "" {
+		ip := net.ParseIP(cfg.ExternalIP)
+		for _, port := range ports {
+			maddr, err := multiAddrFromIPUDPPort(ip, port)
+			if err != nil {
+				return nil, err
+			}
+
+			resp = append(resp, maddr)
+		}
+	}
+
+	if cfg.ExternalHost != "" {
+		for _, port := range ports {
+			maddr, err := ma.NewMultiaddr(fmt.Sprintf("/dns/%s/udp/%d/quic-v1", cfg.ExternalHost, port))
+			if err != nil {
+				return nil, errors.Wrap(err, "invalid dns quic-v1 multiaddr")
+			}
+
+			resp = append(resp, maddr)
+		}
+	}
+
+	return resp, nil
+}
+
+// externalTCPMultiAddrs returns the external IP and Hostname fields as multiaddrs using the listen TCP address ports.
+func externalTCPMultiAddrs(cfg Config) ([]ma.Multiaddr, error) {
+	addrs, err := cfg.ParseTCPAddrs()
 	if err != nil {
 		return nil, err
 	}
 
 	var ports []int
-	for _, addr := range tcpAddrs {
+	for _, addr := range addrs {
 		ports = append(ports, addr.Port)
 	}
 
@@ -137,7 +217,7 @@ func externalMultiAddrs(cfg Config) ([]ma.Multiaddr, error) {
 	if cfg.ExternalIP != "" {
 		ip := net.ParseIP(cfg.ExternalIP)
 		for _, port := range ports {
-			maddr, err := multiAddrFromIPPort(ip, port)
+			maddr, err := multiAddrFromIPTCPPort(ip, port)
 			if err != nil {
 				return nil, err
 			}
@@ -150,7 +230,7 @@ func externalMultiAddrs(cfg Config) ([]ma.Multiaddr, error) {
 		for _, port := range ports {
 			maddr, err := ma.NewMultiaddr(fmt.Sprintf("/dns/%s/tcp/%d", cfg.ExternalHost, port))
 			if err != nil {
-				return nil, errors.Wrap(err, "invalid dns multiaddr")
+				return nil, errors.Wrap(err, "invalid dns tcp multiaddr")
 			}
 
 			resp = append(resp, maddr)
@@ -163,7 +243,7 @@ func externalMultiAddrs(cfg Config) ([]ma.Multiaddr, error) {
 // multiAddrsViaRelay returns multiaddrs to the peer via the relay.
 // See https://github.com/libp2p/go-libp2p/blob/master/examples/relay/main.go.
 func multiAddrsViaRelay(relayPeer Peer, peerID peer.ID) ([]ma.Multiaddr, error) {
-	var resp []ma.Multiaddr
+	var addrs []ma.Multiaddr
 
 	for _, addr := range relayPeer.Addrs {
 		transportAddr, _ := peer.SplitAddr(addr)
@@ -175,16 +255,16 @@ func multiAddrsViaRelay(relayPeer Peer, peerID peer.ID) ([]ma.Multiaddr, error)
 			return nil, errors.Wrap(err, "new multiaddr")
 		}
 
-		resp = append(resp, transportAddr.Encapsulate(relayAddr))
+		addrs = append(addrs, transportAddr.Encapsulate(relayAddr))
 	}
 
-	return resp, nil
+	return addrs, nil
 }
 
 // NewEventCollector returns a lifecycle hook that instruments libp2p events.
-func NewEventCollector(tcpNode host.Host) lifecycle.HookFuncCtx {
+func NewEventCollector(p2pNode host.Host) lifecycle.HookFuncCtx {
 	return func(ctx context.Context) {
-		sub, err := tcpNode.EventBus().Subscribe(new(event.EvtLocalReachabilityChanged))
+		sub, err := p2pNode.EventBus().Subscribe(new(event.EvtLocalReachabilityChanged))
 		if err != nil {
 			log.Error(ctx, "Subscribe libp2p events", err)
 			return
@@ -220,14 +300,14 @@ func (f peerRoutingFunc) FindPeer(ctx context.Context, p peer.ID) (peer.AddrInfo
 
 // ForceDirectConnections attempts to establish a direct connection if there is an existing relay connection to the peer.
 // The idea is to enable switching to a direct connection as soon as the host has a connection to the peer.
-func ForceDirectConnections(tcpNode host.Host, peerIDs []peer.ID) lifecycle.HookFuncCtx {
+func ForceDirectConnections(p2pNode host.Host, peerIDs []peer.ID) lifecycle.HookFuncCtx {
 	forceDirectConn := func(ctx context.Context) {
 		for _, p := range peerIDs {
-			if tcpNode.ID() == p {
+			if p2pNode.ID() == p {
 				continue // Skip self
 			}
 
-			conns := tcpNode.Network().ConnsToPeer(p)
+			conns := p2pNode.Network().ConnsToPeer(p)
 			if len(conns) == 0 {
 				// Skip if there isn't any existing connection to peer. Note that we only force direct connection
 				// if there is already an existing relay connection between the host and peer.
@@ -239,7 +319,7 @@ func ForceDirectConnections(tcpNode host.Host, peerIDs []peer.ID) lifecycle.Hook
 			}
 
 			// All existing connections are through relays, so we can try force dialing a direct connection.
-			err := tcpNode.Connect(network.WithForceDirectDial(ctx, "relay_to_direct"), peer.AddrInfo{ID: p})
+			err := p2pNode.Connect(network.WithForceDirectDial(ctx, "relay_to_direct"), peer.AddrInfo{ID: p})
 			if err == nil {
 				log.Debug(ctx, "Forced direct connection to peer successful", z.Str("peer", PeerName(p)))
 			}
@@ -261,6 +341,21 @@ func ForceDirectConnections(tcpNode host.Host, peerIDs []peer.ID) lifecycle.Hook
 	}
 }
 
+// IsQUICEnabled returns true if the host has an address or listenning address on QUIC
+func isQUICEnabled(h host.Host) bool {
+	if slices.ContainsFunc(h.Network().ListenAddresses(), isQUICAddr) {
+		return true
+	}
+
+	if addrs := h.Addrs(); len(addrs) > 0 {
+		if slices.ContainsFunc(addrs, isQUICAddr) {
+			return true
+		}
+	}
+
+	return false
+}
+
 // isDirectConnAvailable returns true if direct connection is available in the given set of connections.
 func isDirectConnAvailable(conns []network.Conn) bool {
 	for _, conn := range conns {
@@ -274,15 +369,269 @@ func isDirectConnAvailable(conns []network.Conn) bool {
 	return false
 }
 
+// UpgradeToQUICConnections tries to upgrade a direct TCP connection to a direct QUIC connection
+// if there is known QUIC addresses from the peerstore.
+func UpgradeToQUICConnections(p2pNode host.Host, peerIDs []peer.ID) lifecycle.HookFuncCtx {
+	type quicUpgradeBackoff struct {
+		tickersRemaining int
+		backoffDuration  int // in minutes/tickers
+	}
+
+	failedUpgradeAttempts := make(map[peer.ID]*quicUpgradeBackoff)
+
+	var backoffMutex sync.Mutex
+
+	recordUpgradeFailure := func(peerID peer.ID) {
+		backoffMutex.Lock()
+		defer backoffMutex.Unlock()
+
+		if currentBackoff, exists := failedUpgradeAttempts[peerID]; exists {
+			currentBackoff.backoffDuration = int(math.Min(float64(currentBackoff.backoffDuration*2), 512)) // max 8 hours
+			currentBackoff.tickersRemaining = currentBackoff.backoffDuration
+		} else {
+			failedUpgradeAttempts[peerID] = &quicUpgradeBackoff{
+				tickersRemaining: 2,
+				backoffDuration:  2,
+			}
+		}
+	}
+
+	shouldSkipUpgradeAttempt := func(ctx context.Context, peerID peer.ID) bool {
+		backoffMutex.Lock()
+		defer backoffMutex.Unlock()
+
+		if currentBackoff, exists := failedUpgradeAttempts[peerID]; exists {
+			if currentBackoff.tickersRemaining > 0 {
+				currentBackoff.tickersRemaining--
+				log.Debug(ctx, "Skipping QUIC upgrade due to backoff", z.Str("peer", PeerName(peerID)), z.Int("tickers_remaining", currentBackoff.tickersRemaining), z.Int("backoff_duration_minutes", currentBackoff.backoffDuration))
+
+				return true
+			}
+
+			return false
+		}
+
+		return false
+	}
+
+	clearUpgradeBackoff := func(peerID peer.ID) {
+		backoffMutex.Lock()
+		defer backoffMutex.Unlock()
+
+		delete(failedUpgradeAttempts, peerID)
+	}
+
+	forceQUICConn := func(ctx context.Context) {
+		if !isQUICEnabled(p2pNode) {
+			log.Debug(ctx, "Node doesn't have feature QUIC enabled")
+			return // doesn't support QUIC
+		}
+
+		for _, p := range peerIDs {
+			if p2pNode.ID() == p {
+				continue // skip self
+			}
+
+			if shouldSkipUpgradeAttempt(ctx, p) {
+				continue // skip this peer due to active backoff
+			}
+
+			conns := p2pNode.Network().ConnsToPeer(p)
+			if len(conns) == 0 {
+				log.Debug(ctx, "No connection to peer", z.Str("peer", PeerName(p)))
+				continue // nothing to upgrade
+			}
+
+			if hasDirectQUICConn(conns) {
+				log.Debug(ctx, "Already has direct QUIC connection to peer", z.Str("peer", PeerName(p)), z.Any("conns", conns))
+
+				// Remove unwanted TCP connections
+				for _, conn := range conns {
+					addr := conn.RemoteMultiaddr()
+					if isTCPAddr(addr) {
+						err := conn.Close()
+						if err != nil {
+							log.Debug(ctx, "Failed to closed redundant TCP connection", z.Str("peer", PeerName(p)), z.Any("addr", addr))
+						} else {
+							log.Debug(ctx, "Closed redundant TCP connection", z.Str("peer", PeerName(p)), z.Any("addr", addr))
+						}
+					}
+				}
+
+				continue // no need to upgrade
+			}
+
+			if !hasDirectTCPConn(conns) {
+				log.Debug(ctx, "No direct connection via TCP to peer", z.Str("peer", PeerName(p)), z.Any("conns", conns))
+				continue // no direct TPC connection to upgrade to QUIC, ForceDirectConnections shall upgrade to direct
+			}
+
+			// Get known QUIC addrs from peerstore
+			var quicAddrs []ma.Multiaddr
+
+			for _, addr := range p2pNode.Peerstore().Addrs(p) {
+				if isQUICAddr(addr) && !isRelayAddr(addr) {
+					quicAddrs = append(quicAddrs, addr)
+				}
+			}
+
+			if len(quicAddrs) == 0 {
+				log.Debug(ctx, "No knonw QUIC addresses to peer", z.Str("peer", PeerName(p)), z.Any("conns", conns))
+				continue // no known QUIC addresses
+			}
+
+			log.Debug(ctx, "Trying to upgrade to QUIC connection with peer", z.Str("peer", PeerName(p)))
+
+			// To maximize change of connectia via QUIC clear peerstore of TCP addresses
+			originalPeerstore := slices.Clone(p2pNode.Peerstore().Addrs(p))
+			p2pNode.Peerstore().ClearAddrs(p)
+			p2pNode.Peerstore().AddAddrs(p, quicAddrs, peerstore.PermanentAddrTTL)
+
+			t0 := time.Now() // t0: Start closing connections.
+			// Close previous direct connections to ensure that we can establish a QUIC connection
+			for _, conn := range conns {
+				addr := conn.RemoteMultiaddr()
+				if !isRelayAddr(addr) {
+					log.Debug(ctx, "Closing connection during QUIC upgrade", z.Str("peer", PeerName(p)), z.Any("conn", conn))
+
+					err := conn.Close()
+					if err != nil {
+						log.Debug(ctx, "Failed to close connections before upgrading to QUIC", z.Err(err), z.Any("addr", addr), z.Any("connection", conn))
+					}
+				}
+			}
+
+			t1 := time.Now() // t1: Finished closing. Start connecting. From t1 to t2 node will be disconnected.
+			// Attempt to connect over QUIC
+			// In case of QUIC failure due to infrastructure error (e.g. UDP port closed) the Connect call
+			// may fail or succeed. It can succeed if another peer calls Connect to this peer with a working
+			// TCP address which is misleading to us
+			// If it fails, we try to reconnect via any available address
+			// If it succeeds, we check whether we are actually connected via QUIC
+			err := p2pNode.Connect(network.WithForceDirectDial(ctx, ""), peer.AddrInfo{ID: p})
+			t2 := time.Now() // t2: Finished connecting.
+
+			if err != nil {
+				recordUpgradeFailure(p)
+				log.Debug(ctx, "Failed to connect to peer during QUIC upgrade. Reconnecting via any address", z.Str("peer", PeerName(p)), z.Any("peerstore", p2pNode.Peerstore().Addrs(p)), z.Err(err))
+
+				// Restore original peerstore
+				p2pNode.Peerstore().AddAddrs(p, originalPeerstore, peerstore.PermanentAddrTTL)
+				// Reconnect (probably through TCP)
+				err := p2pNode.Connect(network.WithForceDirectDial(ctx, ""), peer.AddrInfo{ID: p})
+				if err != nil {
+					log.Debug(ctx, "Failed to establish connection after failed QUIC upgrade", z.Str("peer", PeerName(p)), z.Any("peerstore", p2pNode.Peerstore().Addrs(p)))
+				}
+
+				continue
+			}
+
+			// Restore original peerstore
+			p2pNode.Peerstore().AddAddrs(p, originalPeerstore, peerstore.PermanentAddrTTL)
+
+			// Confirm connection
+			connectedViaQUIC := false
+
+			for _, conn := range p2pNode.Network().ConnsToPeer(p) {
+				addr := conn.RemoteMultiaddr()
+				if !isRelayAddr(addr) {
+					if isQUICAddr(addr) {
+						connectedViaQUIC = true
+
+						log.Debug(ctx, "Upgraded connection to QUIC", z.Str("peer", PeerName(p)), z.Any("closing_period", t1.Sub(t0)), z.Any("connecting_period", t2.Sub(t1)), z.Any("addr", addr), z.Any("direction", conn.Stat().Direction))
+					} else if isTCPAddr(addr) {
+						log.Debug(ctx, "Connected via TCP after upgrade to QUIC connection", z.Str("peer", PeerName(p)), z.Any("addr", addr), z.Any("direction", conn.Stat().Direction))
+					}
+				}
+			}
+
+			if !connectedViaQUIC {
+				log.Debug(ctx, "Failed to establish direct connection via QUIC to peer", z.Str("peer", PeerName(p)), z.Any("closing_period", t1.Sub(t0)), z.Any("connecting_period", t2.Sub(t1)), z.Any("conns", p2pNode.Network().ConnsToPeer(p)), z.Any("peerstore", p2pNode.Peerstore().Addrs(p)))
+				recordUpgradeFailure(p)
+			} else {
+				clearUpgradeBackoff(p)
+			}
+		}
+	}
+
+	return func(ctx context.Context) {
+		ticker := time.NewTicker(1 * time.Minute)
+		defer ticker.Stop()
+
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case <-ticker.C:
+				forceQUICConn(ctx)
+			}
+		}
+	}
+}
+
+// hasDirectTCPConn returns true if there's already a direct TCP connection among the given conns.
+func hasDirectTCPConn(conns []network.Conn) bool {
+	for _, conn := range conns {
+		if isTCPAddr(conn.RemoteMultiaddr()) && !isRelayAddr(conn.RemoteMultiaddr()) {
+			return true
+		}
+	}
+
+	return false
+}
+
+// hasDirectQUICConn returns true if there's already a direct QUIC connection among the given conns.
+func hasDirectQUICConn(conns []network.Conn) bool {
+	for _, conn := range conns {
+		if isQUICAddr(conn.RemoteMultiaddr()) && !isRelayAddr(conn.RemoteMultiaddr()) {
+			return true
+		}
+	}
+
+	return false
+}
+
+// isProtocolAddr return true if the multiaddr has protocol code p
+func isProtocolAddr(a ma.Multiaddr, p int) bool {
+	found := false
+
+	ma.ForEach(a, func(c ma.Component) bool {
+		if c.Protocol().Code == p {
+			found = true
+			return false
+		}
+
+		return true
+	})
+
+	return found
+}
+
+// isTCPAddr returns true if the multiaddr is TCP
+func isTCPAddr(a ma.Multiaddr) bool {
+	return isProtocolAddr(a, ma.P_TCP)
+}
+
+// isQUICAddr returns true if the multiaddr is QUIC
+func isQUICAddr(a ma.Multiaddr) bool {
+	return isProtocolAddr(a, ma.P_QUIC) || isProtocolAddr(a, ma.P_QUIC_V1)
+}
+
+// isRelayAddr returns true if the multiaddr is p2p-circuit
+func isRelayAddr(addr ma.Multiaddr) bool {
+	return isProtocolAddr(addr, ma.P_CIRCUIT)
+}
+
 // RegisterConnectionLogger registers a connection logger with the host.
 // This is pretty weird and hacky, but that is because libp2p uses the network.Notifiee interface as a map key,
 // so the implementation can only contain fields that are hashable. So we use a channel and do the logic externally. :(.
-func RegisterConnectionLogger(ctx context.Context, tcpNode host.Host, peerIDs []peer.ID) {
+func RegisterConnectionLogger(ctx context.Context, p2pNode host.Host, peerIDs []peer.ID) {
 	ctx = log.WithTopic(ctx, "p2p")
 
 	type connKey struct {
 		PeerName string
 		Type     string
+		Protocol string
 	}
 
 	type streamKey struct {
@@ -302,7 +651,7 @@ func RegisterConnectionLogger(ctx context.Context, tcpNode host.Host, peerIDs []
 		peers[p] = true
 	}
 
-	tcpNode.Network().Notify(connLogger{
+	p2pNode.Network().Notify(connLogger{
 		events: events,
 		quit:   quit,
 	})
@@ -320,11 +669,12 @@ func RegisterConnectionLogger(ctx context.Context, tcpNode host.Host, peerIDs []
 				counts := make(map[connKey]int)
 				streams := make(map[streamKey]int)
 
-				for _, conn := range tcpNode.Network().Conns() {
+				for _, conn := range p2pNode.Network().Conns() {
 					p := PeerName(conn.RemotePeer())
 					cKey := connKey{
 						PeerName: p,
 						Type:     addrType(conn.RemoteMultiaddr()),
+						Protocol: addrProtocol(conn.RemoteMultiaddr()),
 					}
 					counts[cKey]++
 
@@ -340,10 +690,20 @@ func RegisterConnectionLogger(ctx context.Context, tcpNode host.Host, peerIDs []
 
 				peerStreamGauge.Reset() // Reset stream gauge to clear previously set protocols.
 
+				existing := make(map[string]bool)
+
+				for cKey, count := range counts {
+					peerConnGauge.WithLabelValues(cKey.PeerName, cKey.Type, cKey.Protocol).Set(float64(count))
+					existing[cKey.PeerName+":"+cKey.Type] = true
+				}
+
+				// Ensure zero values for peer/type combinations that have no connections
 				for _, pID := range peerIDs {
+					peerName := PeerName(pID)
 					for _, typ := range []string{addrTypeRelay, addrTypeDirect} {
-						cKey := connKey{PeerName: PeerName(pID), Type: typ}
-						peerConnGauge.WithLabelValues(cKey.PeerName, cKey.Type).Set(float64(counts[cKey]))
+						if !existing[peerName+":"+typ] {
+							peerConnGauge.WithLabelValues(peerName, typ, protocolNone).Set(0)
+						}
 					}
 				}
 
@@ -443,6 +803,19 @@ var (
 	_ network.Notifiee    = connLogger{}
 )
 
+// addrProtocol returns the transport protocol name from a multiaddr
+func addrProtocol(addr ma.Multiaddr) string {
+	if isQUICAddr(addr) {
+		return protocolQUIC
+	}
+
+	if isTCPAddr(addr) {
+		return protocolTCP
+	}
+
+	return protocolUnknown
+}
+
 // addrType returns 'direct' or 'relay' based on whether the address contains a relay.
 func addrType(a ma.Multiaddr) string {
 	if IsRelayAddr(a) {
diff --git a/p2p/ping.go b/p2p/ping.go
index 22e5cb83..8e771e0e 100644
--- a/p2p/ping.go
+++ b/p2p/ping.go
@@ -135,7 +135,7 @@ func IsRelayError(err error) bool {
 
 // newPingLogger returns stateful logging function that logs "real" dial errors when they change or every 10min.
 // This is the main logger of "why are we not connected to peer X".
-func newPingLogger(tcpNode host.Host, p peer.ID) func(context.Context, ping.Result) {
+func newPingLogger(p2pNode host.Host, p peer.ID) func(context.Context, ping.Result) {
 	var (
 		prevMsgs         = make(map[string]string)
 		prevResolvedMsgs = make(map[string]string)
@@ -189,7 +189,7 @@ func newPingLogger(tcpNode host.Host, p peer.ID) func(context.Context, ping.Resu
 		// Log when failing after success or failing for different reasons
 
 		if hasErrDialBackoff(result.Error) {
-			msgs = resolveBackoffMsgs(ctx, tcpNode, p) // Best effort resolving of dial backoff errors.
+			msgs = resolveBackoffMsgs(ctx, p2pNode, p) // Best effort resolving of dial backoff errors.
 			if len(msgs) == 0 || sameMsgs(msgs) {
 				// No more errors, or same messages, ok well...
 				return
@@ -207,8 +207,8 @@ func newPingLogger(tcpNode host.Host, p peer.ID) func(context.Context, ping.Resu
 	}
 }
 
-func resolveBackoffMsgs(ctx context.Context, tcpNode host.Host, p peer.ID) map[string]string {
-	net, ok := tcpNode.Network().(*swarm.Swarm)
+func resolveBackoffMsgs(ctx context.Context, p2pNode host.Host, p peer.ID) map[string]string {
+	net, ok := p2pNode.Network().(*swarm.Swarm)
 	if !ok {
 		log.Error(ctx, "Not a swarm network", nil)
 		return nil
diff --git a/p2p/receive.go b/p2p/receive.go
index af0e26ab..0b2df297 100644
--- a/p2p/receive.go
+++ b/p2p/receive.go
@@ -25,7 +25,7 @@ type HandlerFunc func(ctx context.Context, peerID peer.ID, req proto.Message) (p
 
 // RegisterHandlerFunc abstracts a function that registers a libp2p stream handler
 // that reads a single protobuf request and returns an optional response.
-type RegisterHandlerFunc func(logTopic string, tcpNode host.Host, protocol protocol.ID,
+type RegisterHandlerFunc func(logTopic string, p2pNode host.Host, protocol protocol.ID,
 	zeroReq func() proto.Message, handlerFunc HandlerFunc, opts ...SendRecvOption,
 )
 
@@ -37,7 +37,7 @@ var _ RegisterHandlerFunc = RegisterHandler
 // - The handlerFunc is called with the unmarshalled request and returns either a response or false or an error.
 // - The marshalled response is sent back if present.
 // - The stream is always closed before returning.
-func RegisterHandler(logTopic string, tcpNode host.Host, pID protocol.ID,
+func RegisterHandler(logTopic string, p2pNode host.Host, pID protocol.ID,
 	zeroReq func() proto.Message, handlerFunc HandlerFunc, opts ...SendRecvOption,
 ) {
 	o := defaultSendRecvOpts(pID)
@@ -49,7 +49,7 @@ func RegisterHandler(logTopic string, tcpNode host.Host, pID protocol.ID,
 		return o.readersByProtocol[pID] != nil
 	}
 
-	tcpNode.SetStreamHandlerMatch(protocolPrefix(o.protocols...), matchProtocol, func(s network.Stream) {
+	p2pNode.SetStreamHandlerMatch(protocolPrefix(o.protocols...), matchProtocol, func(s network.Stream) {
 		t0 := time.Now()
 		name := PeerName(s.Conn().RemotePeer())
 
diff --git a/p2p/relay.go b/p2p/relay.go
index 004af5c7..43d875f6 100644
--- a/p2p/relay.go
+++ b/p2p/relay.go
@@ -25,7 +25,7 @@ var routedAddrTTL = peerstore.TempAddrTTL + 1
 
 // NewRelayReserver returns a life cycle hook function that continuously
 // reserves a relay circuit until the context is closed.
-func NewRelayReserver(tcpNode host.Host, relay *MutablePeer) lifecycle.HookFuncCtx {
+func NewRelayReserver(p2pNode host.Host, relay *MutablePeer) lifecycle.HookFuncCtx {
 	return func(ctx context.Context) {
 		ctx = log.WithTopic(ctx, "relay")
 		backoff, resetBackoff := expbackoff.NewWithReset(ctx)
@@ -41,7 +41,7 @@ func NewRelayReserver(tcpNode host.Host, relay *MutablePeer) lifecycle.HookFuncC
 
 			relayConnGauge.WithLabelValues(name).Set(0)
 
-			resv, err := circuit.Reserve(ctx, tcpNode, relayPeer.AddrInfo())
+			resv, err := circuit.Reserve(ctx, p2pNode, relayPeer.AddrInfo())
 			if err != nil {
 				log.Warn(ctx, "Reserve relay circuit", err, z.Str("relay_peer", name))
 				backoff()
@@ -75,7 +75,7 @@ func NewRelayReserver(tcpNode host.Host, relay *MutablePeer) lifecycle.HookFuncC
 			for {
 				select {
 				case <-checkConnTicker.C:
-					if len(tcpNode.Network().ConnsToPeer(relayPeer.ID)) > 0 {
+					if len(p2pNode.Network().ConnsToPeer(relayPeer.ID)) > 0 {
 						continue // Still connected, continue for loop
 					}
 
@@ -105,7 +105,7 @@ func NewRelayReserver(tcpNode host.Host, relay *MutablePeer) lifecycle.HookFuncC
 
 // NewRelayRouter returns a life cycle hook that routes peers via relays in libp2p by
 // continuously adding peer relay addresses to libp2p peer store.
-func NewRelayRouter(tcpNode host.Host, peers []peer.ID, relays []*MutablePeer) lifecycle.HookFuncCtx {
+func NewRelayRouter(p2pNode host.Host, peers []peer.ID, relays []*MutablePeer) lifecycle.HookFuncCtx {
 	return func(ctx context.Context) {
 		if len(relays) == 0 {
 			return
@@ -115,7 +115,7 @@ func NewRelayRouter(tcpNode host.Host, peers []peer.ID, relays []*MutablePeer) l
 
 		for ctx.Err() == nil {
 			for _, pID := range peers {
-				if pID == tcpNode.ID() {
+				if pID == p2pNode.ID() {
 					// Skip self
 					continue
 				}
@@ -132,7 +132,7 @@ func NewRelayRouter(tcpNode host.Host, peers []peer.ID, relays []*MutablePeer) l
 						continue
 					}
 
-					tcpNode.Peerstore().AddAddrs(pID, relayAddrs, routedAddrTTL)
+					p2pNode.Peerstore().AddAddrs(pID, relayAddrs, routedAddrTTL)
 				}
 			}
 
diff --git a/p2p/sender.go b/p2p/sender.go
index 15388c04..64505bf1 100644
--- a/p2p/sender.go
+++ b/p2p/sender.go
@@ -38,7 +38,7 @@ type SendFunc func(context.Context, host.Host, protocol.ID, peer.ID, proto.Messa
 
 // SendReceiveFunc is an abstract function responsible for sending a libp2p request and returning
 // (populating) a libp2p response.
-type SendReceiveFunc func(ctx context.Context, tcpNode host.Host, peerID peer.ID,
+type SendReceiveFunc func(ctx context.Context, p2pNode host.Host, peerID peer.ID,
 	req, resp proto.Message, protocol protocol.ID, opts ...SendRecvOption) error
 
 var (
@@ -148,7 +148,7 @@ func (s *Sender) addResult(ctx context.Context, peerID peer.ID, err error) {
 // SendAsync returns nil and sends a libp2p message asynchronously.
 // It logs results on state change (success to/from failure).
 // It implements SendFunc.
-func (s *Sender) SendAsync(parent context.Context, tcpNode host.Host, protoID protocol.ID, peerID peer.ID,
+func (s *Sender) SendAsync(parent context.Context, p2pNode host.Host, protoID protocol.ID, peerID peer.ID,
 	msg proto.Message, opts ...SendRecvOption,
 ) error {
 	go func() {
@@ -156,7 +156,7 @@ func (s *Sender) SendAsync(parent context.Context, tcpNode host.Host, protoID pr
 		ctx := log.CopyFields(context.Background(), parent)
 
 		err := withRelayRetry(func() error {
-			return Send(ctx, tcpNode, protoID, peerID, msg, opts...)
+			return Send(ctx, p2pNode, protoID, peerID, msg, opts...)
 		})
 		s.addResult(ctx, peerID, err)
 	}()
@@ -168,11 +168,11 @@ func (s *Sender) SendAsync(parent context.Context, tcpNode host.Host, protoID pr
 // The provided response proto will be populated if err is nil.
 // It logs results on state change (success to/from failure).
 // It implements SendReceiveFunc.
-func (s *Sender) SendReceive(ctx context.Context, tcpNode host.Host, peerID peer.ID, req, resp proto.Message,
+func (s *Sender) SendReceive(ctx context.Context, p2pNode host.Host, peerID peer.ID, req, resp proto.Message,
 	protocol protocol.ID, opts ...SendRecvOption,
 ) error {
 	err := withRelayRetry(func() error {
-		return SendReceive(ctx, tcpNode, peerID, req, resp, protocol, opts...)
+		return SendReceive(ctx, p2pNode, peerID, req, resp, protocol, opts...)
 	})
 	s.addResult(ctx, peerID, err)
 
@@ -266,7 +266,7 @@ func defaultSendRecvOpts(pID protocol.ID) sendRecvOpts {
 // pair synchronously and then closes the stream.
 // The provided response proto will be populated if err is nil.
 // It implements SendReceiveFunc.
-func SendReceive(ctx context.Context, tcpNode host.Host, peerID peer.ID,
+func SendReceive(ctx context.Context, p2pNode host.Host, peerID peer.ID,
 	req, resp proto.Message, pID protocol.ID, opts ...SendRecvOption,
 ) error {
 	if !isZeroProto(resp) {
@@ -279,7 +279,7 @@ func SendReceive(ctx context.Context, tcpNode host.Host, peerID peer.ID,
 	}
 
 	// Circuit relay connections are transient
-	s, err := tcpNode.NewStream(network.WithAllowLimitedConn(ctx, ""), peerID, o.protocols...)
+	s, err := p2pNode.NewStream(network.WithAllowLimitedConn(ctx, ""), peerID, o.protocols...)
 	if err != nil {
 		return errors.Wrap(err, "new stream", z.Any("protocols", o.protocols))
 	}
@@ -325,7 +325,7 @@ func SendReceive(ctx context.Context, tcpNode host.Host, peerID peer.ID,
 }
 
 // Send sends a libp2p message synchronously. It implements SendFunc.
-func Send(ctx context.Context, tcpNode host.Host, protoID protocol.ID, peerID peer.ID, msg proto.Message,
+func Send(ctx context.Context, p2pNode host.Host, protoID protocol.ID, peerID peer.ID, msg proto.Message,
 	opts ...SendRecvOption,
 ) error {
 	o := defaultSendRecvOpts(protoID)
@@ -333,9 +333,9 @@ func Send(ctx context.Context, tcpNode host.Host, protoID protocol.ID, peerID pe
 		opt(&o)
 	}
 	// Circuit relay connections are transient
-	s, err := tcpNode.NewStream(network.WithAllowLimitedConn(ctx, ""), peerID, o.protocols...)
+	s, err := p2pNode.NewStream(network.WithAllowLimitedConn(ctx, ""), peerID, o.protocols...)
 	if err != nil {
-		return errors.Wrap(err, "tcpNode stream")
+		return errors.Wrap(err, "p2pNode stream")
 	}
 
 	if err := s.SetDeadline(time.Now().Add(o.sendTimeout)); err != nil {
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 00000000..61109bdd
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,50 @@
+# Charon Scripts
+
+This directory contains helper scripts for managing and merging Charon cluster and node configurations.
+
+## `node_merge.sh`
+
+This script merges node-level configuration and key files from a source node folder into a destination node folder.
+It is useful for combining validator keys and configuration for single pair of nodes, ensuring all keys are present in the destination.
+
+### Usage
+
+```bash
+./node_merge.sh <dst_node_folder> <src_node_folder>
+```
+
+- *<dst_node_folder>*: Path to the destination node's top-level folder (e.g., `cluster1/node0`).
+- *<src_node_folder>*: Path to the source node's top-level folder (e.g., `cluster2/node0`).
+
+The script will:
+- Copy all `keystore-N.json` and `keystore-N.txt` pairs from the source to the destination.
+- Renumber files sequentially in the destination folder, sorted numerically.
+- Preserve other files in the destination folder.
+
+## `cluster_merge.sh`
+
+This script merges two clusters by running `node_merge.sh` for each `nodeX` subfolder found in the source cluster.
+It helps expand an existing cluster with new validators, given the same operators set and cluster configuration.
+
+### Usage
+
+```bash
+./cluster_merge.sh <dst_cluster_folder> <src_cluster_folder>
+```
+
+- *<dst_cluster_folder>*: Path to the destination cluster folder (e.g., `cluster1`).
+- *<src_cluster_folder>*: Path to the source cluster folder (e.g., `cluster2`).
+
+The script will execute `node_merge.sh` for each `nodeX` subfolder found in the source cluster.
+
+## Requirements
+
+Both scripts require **bash** (standard on Linux/macOS) and **jq** (version 1.5+).
+Install via `sudo apt-get install jq` (Debian/Ubuntu) or `brew install jq` (macOS).
+
+## Important Warnings
+
+- Always back up your `cluster-lock.json`, node folders, and `validator_keys` folders before use.
+- Make sure the source and destination folders have correct permissions and are accessible by the script.
+- The scripts do not update the integrity hash. You must use `CHARON_NO_VERIFY=true` or `--no-verify=true` with `charon run`.
+- Shut down the source cluster or node before running the merged cluster.
diff --git a/scripts/cluster_merge.sh b/scripts/cluster_merge.sh
new file mode 100755
index 00000000..d8b2ebac
--- /dev/null
+++ b/scripts/cluster_merge.sh
@@ -0,0 +1,91 @@
+#!/bin/bash
+
+set -euo pipefail
+
+#
+# This script orchestrates the merging of multiple DV node configurations
+# from a source cluster into a destination cluster. It calls node_merge.sh
+# for each corresponding nodeX subfolder found.
+# For single node merges, consider using node_merge.sh directly.
+#
+# Please make backups before running this script.
+# Please read the README.md instructions carefully.
+#
+
+# --- Function to display script usage ---
+function usage() {
+    echo "Usage: $0 dst_cluster_folder src_cluster_folder"
+    echo ""
+    echo "  dst_cluster_folder: Path to the destination cluster folder"
+    echo "                      (e.g., ~/dst_cluster)"
+    echo "  src_cluster_folder: Path to the source cluster folder"
+    echo "                      (e.g., ~/src_cluster)"
+    exit 1
+}
+
+# --- Main script starts here ---
+
+# Check for correct number of arguments
+if [ "$#" -ne 2 ]; then
+    usage
+fi
+
+DST_CLUSTER_ROOT="$1"
+SRC_CLUSTER_ROOT="$2"
+
+# Get the directory where this script is located
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
+NODE_MERGE_SCRIPT="$SCRIPT_DIR/node_merge.sh"
+
+# Check if node_merge.sh exists and is executable
+if [ ! -f "$NODE_MERGE_SCRIPT" ]; then
+    echo "Error: '$NODE_MERGE_SCRIPT' not found. Please ensure 'node_merge.sh' is in the same directory as 'cluster_merge.sh'."
+    exit 1
+fi
+if [ ! -x "$NODE_MERGE_SCRIPT" ]; then
+    echo "Error: '$NODE_MERGE_SCRIPT' is not executable. Please run 'chmod +x $NODE_MERGE_SCRIPT'."
+    exit 1
+fi
+
+echo "--- Starting cluster merge from '$SRC_CLUSTER_ROOT' to '$DST_CLUSTER_ROOT' ---"
+echo ""
+
+# Find all nodeX folders in the source cluster
+# Using find with -maxdepth 1 to only look in the immediate subdirectories
+# and regex to match 'node' followed by one or more digits.
+# Sorting numerically to ensure consistent processing order (node0, node1, node2, ..., node10)
+NODE_FOLDERS=$(find "$SRC_CLUSTER_ROOT" -maxdepth 1 -type d -name 'node[0-9]*' | sort -V)
+
+if [ -z "$NODE_FOLDERS" ]; then
+    echo "No 'nodeX' subfolders found in the source cluster '$SRC_CLUSTER_ROOT'."
+    echo "Cluster merge finished (nothing to merge)."
+    exit 0
+fi
+
+for SRC_NODE_FOLDER_PATH in $NODE_FOLDERS; do
+    NODE_NAME=$(basename "$SRC_NODE_FOLDER_PATH") # e.g., node0, node1
+    DST_NODE_FOLDER_PATH="$DST_CLUSTER_ROOT/$NODE_NAME"
+
+    echo "Attempting to merge node '$NODE_NAME':"
+    echo "  Source: $SRC_NODE_FOLDER_PATH"
+    echo "  Destination: $DST_NODE_FOLDER_PATH"
+    echo ""
+
+    # Check if the corresponding destination node folder exists
+    if [ ! -d "$DST_NODE_FOLDER_PATH" ]; then
+        echo "Error: Destination node folder '$DST_NODE_FOLDER_PATH' not found. Script will be terminated."
+        exit 1
+    fi
+
+    # Call node_merge.sh for the current node pair
+    "$NODE_MERGE_SCRIPT" "$DST_NODE_FOLDER_PATH" "$SRC_NODE_FOLDER_PATH"
+    NODE_MERGE_EXIT_CODE=$?
+
+    if [ $NODE_MERGE_EXIT_CODE -ne 0 ]; then
+        echo "Error: node_merge.sh failed for '$NODE_NAME'. See above output for details."
+        exit 1
+    fi
+    echo ""
+done
+
+echo "--- Cluster merge process complete ---"
diff --git a/scripts/node_merge.sh b/scripts/node_merge.sh
new file mode 100755
index 00000000..a59fde24
--- /dev/null
+++ b/scripts/node_merge.sh
@@ -0,0 +1,218 @@
+#!/bin/bash
+
+set -euo pipefail
+
+#
+# This script merges cluster-lock.json and key files for one node of a cluster.
+# To merge full cluster, consider running cluster_merge.sh.
+#
+# Please make backups before running this script.
+# Please read the README.md instructions carefully.
+#
+
+# --- Configuration ---
+CLUSTER_LOCK_FILENAME="cluster-lock.json"
+VALIDATOR_KEYS_DIRNAME="validator_keys"
+
+# --- Function to display script usage ---
+function usage() {
+    echo "Usage: $0 dst_node_folder src_node_folder"
+    echo ""
+    echo "  dst_node_folder: Path to the destination node's folder"
+    echo "                   (e.g., path/to/cluster1/node0)"
+    echo "  src_node_folder: Path to the source node's folder"
+    echo "                   (e.g., path/to/cluster2/node0)"
+    exit 1
+}
+
+# Check for correct number of arguments
+if [ "$#" -ne 2 ]; then
+    usage
+fi
+
+DST_ROOT_FOLDER="$1"
+SRC_ROOT_FOLDER="$2"
+
+DST_CLUSTER_LOCK_FILE="$DST_ROOT_FOLDER/$CLUSTER_LOCK_FILENAME"
+SRC_CLUSTER_LOCK_FILE="$SRC_ROOT_FOLDER/$CLUSTER_LOCK_FILENAME"
+
+DST_KEYS_FOLDER="$DST_ROOT_FOLDER/$VALIDATOR_KEYS_DIRNAME"
+SRC_KEYS_FOLDER="$SRC_ROOT_FOLDER/$VALIDATOR_KEYS_DIRNAME"
+
+# --- Part 1: Merge cluster-lock.json ---
+
+# Check if required JSON files exist
+if [ ! -f "$DST_CLUSTER_LOCK_FILE" ]; then
+    echo "Error: Destination file '$DST_CLUSTER_LOCK_FILE' not found."
+    exit 1
+fi
+if [ ! -f "$SRC_CLUSTER_LOCK_FILE" ]; then
+    echo "Error: Source file '$SRC_CLUSTER_LOCK_FILE' not found."
+    exit 1
+fi
+
+# Check file permissions for cluster-lock.json
+if [ ! -r "$SRC_CLUSTER_LOCK_FILE" ]; then
+    echo "Error: Source file '$SRC_CLUSTER_LOCK_FILE' is not readable. Please check permissions."
+    exit 1
+fi
+if [ ! -r "$DST_CLUSTER_LOCK_FILE" ]; then
+    echo "Error: Destination file '$DST_CLUSTER_LOCK_FILE' is not readable. Please check permissions."
+    exit 1
+fi
+if [ ! -w "$DST_CLUSTER_LOCK_FILE" ]; then
+    echo "Error: Destination file '$DST_CLUSTER_LOCK_FILE' is not writable. Please check permissions."
+    exit 1
+fi
+
+# Check if jq is installed
+if ! command -v jq &>/dev/null; then
+    echo "Error: 'jq' is not installed. Please install it to use this script."
+    echo "  On Debian/Ubuntu: sudo apt-get install jq"
+    echo "  On macOS (Homebrew): brew install jq"
+    exit 1
+fi
+
+echo "Merging '$CLUSTER_LOCK_FILENAME' from '$SRC_CLUSTER_LOCK_FILE' into '$DST_CLUSTER_LOCK_FILE'..."
+
+# Use jq to perform the merge
+# We will stream the destination JSON into jq's stdin, and load the source JSON
+# as a separate argument using --slurpfile.
+# Since --slurpfile reads the file into an array, we'll access the first element ([0])
+# of the $src_data variable.
+MERGED_JSON=$(
+    jq \
+        --slurpfile src_data "$SRC_CLUSTER_LOCK_FILE" \
+        '
+    .cluster_definition.num_validators = (.cluster_definition.num_validators + ($src_data[0].cluster_definition.num_validators)) |
+    .cluster_definition.validators = (.cluster_definition.validators + $src_data[0].cluster_definition.validators) |
+    .distributed_validators = (.distributed_validators + $src_data[0].distributed_validators)
+    ' "$DST_CLUSTER_LOCK_FILE"
+)
+
+# Write the merged JSON back to the destination file
+# It's safer to write to a temporary file first and then move it,
+# to prevent data loss if the write operation is interrupted.
+TEMP_DST_JSON_FILE=$(mktemp "${DST_CLUSTER_LOCK_FILE}.$(date +%s%N).tmp")
+echo "$MERGED_JSON" >"$TEMP_DST_JSON_FILE"
+
+# Check if the temporary file was written successfully
+if [ $? -ne 0 ]; then
+    echo "Error: Failed to write to temporary file '$TEMP_DST_JSON_FILE'."
+    rm -f "$TEMP_DST_JSON_FILE" # Clean up temp file
+    exit 1
+fi
+
+# Move the temporary file to overwrite the original destination file
+mv "$TEMP_DST_JSON_FILE" "$DST_CLUSTER_LOCK_FILE"
+
+# Check if mv command was successful
+if [ $? -ne 0 ]; then
+    echo "Error: Failed to move temporary file to '$DST_CLUSTER_LOCK_FILE'. Original file might be intact."
+    exit 1
+fi
+
+# Get the count of validators from the source file for the summary message
+SRC_VALIDATOR_COUNT_CLUSTER_LOCK=$(jq '.cluster_definition.num_validators' "$SRC_CLUSTER_LOCK_FILE")
+
+echo "---"
+echo "Merged $SRC_VALIDATOR_COUNT_CLUSTER_LOCK validators from '$SRC_CLUSTER_LOCK_FILE' into '$DST_CLUSTER_LOCK_FILE'."
+echo "---"
+
+# --- Part 2: Merge validator_keys folder ---
+
+# Check if source validator_keys folder exists
+if [ ! -d "$SRC_KEYS_FOLDER" ]; then
+    echo "Error: Source folder '$SRC_KEYS_FOLDER' not found."
+    echo "Skipping validator_keys merge as source folder does not exist."
+else
+    # Create destination validator_keys folder if it doesn't exist
+    mkdir -p "$DST_KEYS_FOLDER"
+
+    # Find the next available key index in the destination folder
+    LAST_INDEX_DST=-1
+    if compgen -G "${DST_KEYS_FOLDER}/keystore-*.json" >/dev/null; then
+        for file in "$DST_KEYS_FOLDER"/keystore-*.json; do
+            filename=$(basename "$file")
+            # Extract the number after "keystore-" and before ".json"
+            if [[ "$filename" =~ keystore-([0-9]+)\.json ]]; then
+                current_index="${BASH_REMATCH[1]}"
+                if ((current_index > LAST_INDEX_DST)); then
+                    LAST_INDEX_DST="$current_index"
+                fi
+            fi
+        done
+    fi
+
+    NEXT_INDEX=$((LAST_INDEX_DST + 1))
+    MERGED_KEY_FILES_COUNT=0
+
+    echo "Merging keys from '$SRC_KEYS_FOLDER' into '$DST_KEYS_FOLDER'..."
+
+    # Process JSON files first to get the base index for each key
+    # Then find and copy the corresponding TXT file with the same new index
+    while IFS= read -r src_json_file; do
+        if [ -f "$src_json_file" ]; then
+            json_filename=$(basename "$src_json_file")
+
+            # Extract the original index of the JSON file
+            if [[ "$json_filename" =~ keystore-([0-9]+)\.json ]]; then
+                original_index="${BASH_REMATCH[1]}"
+
+                # Define the new filenames for both JSON and TXT
+                new_json_filename="keystore-${NEXT_INDEX}.json"
+                new_txt_filename="keystore-${NEXT_INDEX}.txt"
+
+                # Check read permission for source JSON file
+                if [ ! -r "$src_json_file" ]; then
+                    echo "Error: Source JSON key file '$src_json_file' is not readable. Skipping."
+                    continue # Skip to the next file
+                fi
+
+                # Copy the JSON file
+                cp "$src_json_file" "$DST_KEYS_FOLDER/$new_json_filename"
+                if [ $? -ne 0 ]; then
+                    echo "Error: Failed to copy '$src_json_file' to '$DST_KEYS_FOLDER/$new_json_filename'. Skipping this key pair."
+                    # Do not increment NEXT_INDEX or MERGED_KEY_FILES_COUNT as copy failed
+                    continue
+                fi
+                echo "Copied '$json_filename' as '$new_json_filename'"
+
+                # Check for and copy the corresponding TXT file
+                src_txt_file="${SRC_KEYS_FOLDER}/keystore-${original_index}.txt"
+                if [ -f "$src_txt_file" ]; then
+                    if [ ! -r "$src_txt_file" ]; then
+                        echo "Error: Corresponding TXT file '$src_txt_file' is not readable. Skipping TXT file for this key."
+                    else
+                        cp "$src_txt_file" "$DST_KEYS_FOLDER/$new_txt_filename"
+                        if [ $? -ne 0 ]; then
+                            echo "Error: Failed to copy '$src_txt_file' to '$DST_KEYS_FOLDER/$new_txt_filename'. This key might be partially merged."
+                        else
+                            echo "Copied 'keystore-${original_index}.txt' as '$new_txt_filename'"
+                            MERGED_KEY_FILES_COUNT=$((MERGED_KEY_FILES_COUNT + 1)) # Increment count only if both files are processed successfully
+                        fi
+                    fi
+                else
+                    echo "Error: Corresponding TXT file 'keystore-${original_index}.txt' not found for '$json_filename'. Script will be terminated."
+                    exit 1
+                fi
+
+                # Increment the index for the next *key pair*
+                NEXT_INDEX=$((NEXT_INDEX + 1))
+            else
+                echo "Error: Could not parse index from '$json_filename'. Script will be terminated."
+                exit 1
+            fi
+        fi
+    done < <(find "$SRC_KEYS_FOLDER" -maxdepth 1 -name 'keystore-*.json' | sort -V)
+
+    echo "---"
+    echo "Merged $MERGED_KEY_FILES_COUNT keys from '$SRC_KEYS_FOLDER' to '$DST_KEYS_FOLDER'."
+fi
+
+echo ""
+echo "WARNING: This script does not update the integrity hash of the merged cluster-lock.json file."
+echo "         Please add CHARON_NO_VERIFY=true or --no-verify=true to 'charon run' command when starting your node."
+echo "WARNING: Make sure to shutdown the other cluster before using the merged cluster-lock.json."
+echo "         Failing to do so may result in slashing!"
+echo ""
diff --git a/testutil/beaconmock/static.json b/testutil/beaconmock/static.json
index 627ebafb..c8cc99c7 100644
--- a/testutil/beaconmock/static.json
+++ b/testutil/beaconmock/static.json
@@ -205,7 +205,8 @@
       "DOMAIN_AGGREGATE_AND_PROOF": "0x06000000",
       "CHURN_LIMIT_QUOTIENT": "65536",
       "BLS_WITHDRAWAL_PREFIX": "0x00",
-      "MIN_ACTIVATION_BALANCE": "32000000000"
+      "MIN_ACTIVATION_BALANCE": "32000000000",
+      "GOSSIP_MAX_SIZE": "1048576"
     }
   },
   "/eth/v2/beacon/blocks/0": {
diff --git a/testutil/compose/docker-compose.template b/testutil/compose/docker-compose.template
index 0dbe3301..b201d387 100644
--- a/testutil/compose/docker-compose.template
+++ b/testutil/compose/docker-compose.template
@@ -11,6 +11,7 @@ services:
   {{- range $i, $node := .Nodes}}
   node{{$i}}:
     <<: *node-base
+    container_name: node{{$i}}
     {{if .ImageTag}}image: obolnetwork/charon:{{.ImageTag}}
     {{end -}}
     {{if .Entrypoint}}entrypoint: {{.Entrypoint}}
@@ -34,6 +35,7 @@ services:
   {{- if .Relay }}
   relay:
     <<: *node-base
+    container_name: relay
     command: relay
     depends_on: []
     environment:
@@ -51,6 +53,7 @@ services:
   {{- range $i, $vc := .VCs}}
   {{- if $vc.Label}}
   vc{{$i}}-{{$vc.Label}}:
+    container_name: vc{{$i}}-{{$vc.Label}}
     {{if $vc.Build}}build: {{$vc.Build}}
     {{end -}}
     {{if $vc.Image}}image: {{$vc.Image}}
@@ -68,12 +71,14 @@ services:
 
   {{if .Alerting}}
   curl:
+    container_name: curl
     # Can be used to curl services; e.g. docker compose exec curl curl http://prometheus:9090/api/v1/rules\?type\=alert
     image: curlimages/curl:latest
     command: sleep 1d
     networks: [compose]
 
   prometheus:
+    container_name: prometheus
     image: prom/prometheus:${PROMETHEUS_VERSION:-v2.50.1}
     {{if .MonitoringPorts}}ports:
       - "9090:9090"
@@ -86,6 +91,7 @@ services:
 
   {{if .Monitoring}}
   grafana:
+    container_name: grafana
     image: grafana/grafana:${GRAFANA_VERSION:-10.4.2}
     {{if .MonitoringPorts}}ports:
       - "3000:3000"
@@ -101,6 +107,7 @@ services:
       - ./grafana/dash_alerts.json:/etc/dashboards/dash_alerts.json
 
   tempo:
+    container_name: tempo
     image: grafana/tempo:${TEMPO_VERSION:-2.7.1}
     networks: [compose]
     user: ":"
@@ -109,6 +116,7 @@ services:
       - ./tempo:/opt/tempo
 
   loki:
+    container_name: loki
     image: grafana/loki:${LOKI_VERSION:-2.8.2}
     networks: [compose]
     user: ":"
diff --git a/testutil/compose/static/lodestar/Dockerfile b/testutil/compose/static/lodestar/Dockerfile
index f0966899..7f04927b 100644
--- a/testutil/compose/static/lodestar/Dockerfile
+++ b/testutil/compose/static/lodestar/Dockerfile
@@ -1,4 +1,4 @@
-FROM chainsafe/lodestar:v1.31.0
+FROM chainsafe/lodestar:v1.33.0
 
 RUN apt-get update && apt-get install -y curl jq wget
 



Test patch:
diff --git a/app/disk_internal_test.go b/app/disk_internal_test.go
index 003097b7..549b63fd 100644
--- a/app/disk_internal_test.go
+++ b/app/disk_internal_test.go
@@ -42,6 +42,7 @@ func TestCalculateTrackerDelay(t *testing.T) {
 			genesis := now.Add(-test.slotDuration * currentSlot)
 
 			bmock, err := beaconmock.New(
+				t.Context(),
 				beaconmock.WithSlotDuration(test.slotDuration),
 				beaconmock.WithGenesisTime(genesis),
 			)
@@ -73,7 +74,7 @@ func TestSetFeeRecipient(t *testing.T) {
 			}
 		}
 
-		bmock, err := beaconmock.New(beaconmock.WithValidatorSet(clone))
+		bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(clone))
 		require.NoError(t, err)
 
 		// Only expect preparations for active validators.
diff --git a/app/eth2wrap/eth2wrap_test.go b/app/eth2wrap/eth2wrap_test.go
index 641c7a1e..0ff91c2e 100644
--- a/app/eth2wrap/eth2wrap_test.go
+++ b/app/eth2wrap/eth2wrap_test.go
@@ -5,7 +5,6 @@ package eth2wrap_test
 import (
 	"context"
 	"encoding/hex"
-	"encoding/json"
 	"fmt"
 	"net"
 	"net/http"
@@ -19,7 +18,6 @@ import (
 	eth2api "github.com/attestantio/go-eth2-client/api"
 	eth2v1 "github.com/attestantio/go-eth2-client/api/v1"
 	eth2spec "github.com/attestantio/go-eth2-client/spec"
-	eth2e "github.com/attestantio/go-eth2-client/spec/electra"
 	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -37,42 +35,42 @@ func TestMulti(t *testing.T) {
 
 	tests := []struct {
 		name   string
-		handle func(cl1Resp, cl2Resp chan int, ctxCancel context.CancelFunc)
+		handle func(cl1Resp, cl2Resp chan *eth2v1.PeerCount, ctxCancel context.CancelFunc)
 		expErr error
-		expRes int
+		expRes *eth2api.Response[*eth2v1.PeerCount]
 	}{
 		{
 			name: "cl1 only",
-			handle: func(cl1Resp, _ chan int, _ context.CancelFunc) {
-				cl1Resp <- 99
+			handle: func(cl1Resp, _ chan *eth2v1.PeerCount, _ context.CancelFunc) {
+				cl1Resp <- &eth2v1.PeerCount{Connected: 99}
 			},
-			expRes: 99,
+			expRes: &eth2api.Response[*eth2v1.PeerCount]{Data: &eth2v1.PeerCount{Connected: 99}},
 		},
 		{
 			name: "cl2 only",
-			handle: func(_, cl2Resp chan int, _ context.CancelFunc) {
-				cl2Resp <- 99
+			handle: func(_, cl2Resp chan *eth2v1.PeerCount, _ context.CancelFunc) {
+				cl2Resp <- &eth2v1.PeerCount{Connected: 99}
 			},
-			expRes: 99,
+			expRes: &eth2api.Response[*eth2v1.PeerCount]{Data: &eth2v1.PeerCount{Connected: 99}},
 		},
 		{
 			name: "ctx cancel",
-			handle: func(_, _ chan int, ctxCancel context.CancelFunc) {
+			handle: func(_, _ chan *eth2v1.PeerCount, ctxCancel context.CancelFunc) {
 				ctxCancel()
 			},
 			expErr: context.Canceled,
 		},
 		{
 			name: "cl1 error, cl2 ok",
-			handle: func(cl1, cl2 chan int, _ context.CancelFunc) {
+			handle: func(cl1, cl2 chan *eth2v1.PeerCount, _ context.CancelFunc) {
 				close(cl1)
-				cl2 <- 99
+				cl2 <- &eth2v1.PeerCount{Connected: 99}
 			},
-			expRes: 99,
+			expRes: &eth2api.Response[*eth2v1.PeerCount]{Data: &eth2v1.PeerCount{Connected: 99}},
 		},
 		{
 			name: "all error",
-			handle: func(cl1, cl2 chan int, _ context.CancelFunc) {
+			handle: func(cl1, cl2 chan *eth2v1.PeerCount, _ context.CancelFunc) {
 				close(cl1)
 				close(cl2)
 			},
@@ -80,7 +78,7 @@ func TestMulti(t *testing.T) {
 		},
 		{
 			name: "cl1 error, ctx cancel",
-			handle: func(cl1, _ chan int, cancel context.CancelFunc) {
+			handle: func(cl1, _ chan *eth2v1.PeerCount, cancel context.CancelFunc) {
 				close(cl1)
 				cancel()
 			},
@@ -88,13 +86,13 @@ func TestMulti(t *testing.T) {
 		},
 		{
 			name: "cl2 before cl1",
-			handle: func(cl1, cl2 chan int, cancel context.CancelFunc) {
-				cl2 <- 99
+			handle: func(cl1, cl2 chan *eth2v1.PeerCount, cancel context.CancelFunc) {
+				cl2 <- &eth2v1.PeerCount{Connected: 99}
 
 				time.Sleep(time.Millisecond)
-				cl1 <- 98 // This might flap?
+				cl1 <- &eth2v1.PeerCount{Connected: 98} // This might flap?
 			},
-			expRes: 99,
+			expRes: &eth2api.Response[*eth2v1.PeerCount]{Data: &eth2v1.PeerCount{Connected: 99}},
 		},
 	}
 	for _, test := range tests {
@@ -102,33 +100,33 @@ func TestMulti(t *testing.T) {
 			ctx, cancel := context.WithCancel(context.Background())
 			defer cancel()
 
-			cl1, err := beaconmock.New()
+			cl1, err := beaconmock.New(t.Context())
 			require.NoError(t, err)
-			cl2, err := beaconmock.New()
+			cl2, err := beaconmock.New(t.Context())
 			require.NoError(t, err)
 
-			cl1Resp := make(chan int)
-			cl2Resp := make(chan int)
+			cl1Resp := make(chan *eth2v1.PeerCount)
+			cl2Resp := make(chan *eth2v1.PeerCount)
 
-			cl1.NodePeerCountFunc = func(ctx context.Context) (int, error) {
+			cl1.NodePeerCountFunc = func(ctx context.Context, opts *eth2api.NodePeerCountOpts) (*eth2v1.PeerCount, error) {
 				select {
 				case <-ctx.Done():
-					return 0, ctx.Err()
+					return &eth2v1.PeerCount{Connected: 0}, ctx.Err()
 				case resp, ok := <-cl1Resp:
 					if !ok {
-						return 0, closedErr
+						return &eth2v1.PeerCount{Connected: 0}, closedErr
 					}
 
 					return resp, nil
 				}
 			}
-			cl2.NodePeerCountFunc = func(ctx context.Context) (int, error) {
+			cl2.NodePeerCountFunc = func(ctx context.Context, opts *eth2api.NodePeerCountOpts) (*eth2v1.PeerCount, error) {
 				select {
 				case <-ctx.Done():
-					return 0, ctx.Err()
+					return &eth2v1.PeerCount{Connected: 0}, ctx.Err()
 				case resp, ok := <-cl2Resp:
 					if !ok {
-						return 0, closedErr
+						return &eth2v1.PeerCount{Connected: 0}, closedErr
 					}
 
 					return resp, nil
@@ -140,7 +138,7 @@ func TestMulti(t *testing.T) {
 
 			go test.handle(cl1Resp, cl2Resp, cancel)
 
-			resp, err := eth2Cl.NodePeerCount(ctx)
+			resp, err := eth2Cl.NodePeerCount(ctx, &eth2api.NodePeerCountOpts{})
 			require.ErrorIs(t, err, test.expErr)
 			require.Equal(t, test.expRes, resp)
 		})
@@ -148,8 +146,8 @@ func TestMulti(t *testing.T) {
 }
 
 func TestFallback(t *testing.T) {
-	returnValue := 42
-	closedErr := errors.New("error")
+	returnValue := &eth2v1.PeerCount{Connected: 42}
+	closedErr := errors.New("context deadline exceeded")
 
 	tests := []struct {
 		name         string
@@ -194,10 +192,10 @@ func TestFallback(t *testing.T) {
 					allPrimariesFail = false
 				}
 
-				cl, err := beaconmock.New()
+				cl, err := beaconmock.New(t.Context())
 				require.NoError(t, err)
 
-				cl.NodePeerCountFunc = func(context.Context) (int, error) {
+				cl.NodePeerCountFunc = func(context.Context, *eth2api.NodePeerCountOpts) (*eth2v1.PeerCount, error) {
 					calledMu.Lock()
 
 					primaryCalled[i] = true
@@ -212,10 +210,10 @@ func TestFallback(t *testing.T) {
 			// Create fallback client
 			fallbackClients := make([]eth2wrap.Client, len(tt.fallbackErrs))
 			for i, fallbackErr := range tt.fallbackErrs {
-				cl, err := beaconmock.New()
+				cl, err := beaconmock.New(t.Context())
 				require.NoError(t, err)
 
-				cl.NodePeerCountFunc = func(context.Context) (int, error) {
+				cl.NodePeerCountFunc = func(context.Context, *eth2api.NodePeerCountOpts) (*eth2v1.PeerCount, error) {
 					calledMu.Lock()
 
 					fallbackCalled[i] = true
@@ -229,9 +227,9 @@ func TestFallback(t *testing.T) {
 
 			eth2Cl, err := eth2wrap.Instrument(primaryClients, fallbackClients)
 			require.NoError(t, err)
-			res, err := eth2Cl.NodePeerCount(t.Context())
+			res, err := eth2Cl.NodePeerCount(t.Context(), &eth2api.NodePeerCountOpts{})
 			require.NoError(t, err)
-			require.Equal(t, returnValue, res)
+			require.Equal(t, returnValue, res.Data)
 
 			calledMu.Lock()
 			defer calledMu.Unlock()
@@ -263,9 +261,9 @@ func TestFallback(t *testing.T) {
 }
 
 func TestSyncState(t *testing.T) {
-	cl1, err := beaconmock.New()
+	cl1, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
-	cl2, err := beaconmock.New()
+	cl2, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	cl1.NodeSyncingFunc = func(ctx context.Context, opts *eth2api.NodeSyncingOpts) (*eth2v1.SyncState, error) {
@@ -290,10 +288,10 @@ func TestErrors(t *testing.T) {
 		cl, err := eth2wrap.NewMultiHTTP(time.Hour, [4]byte{}, nil, []string{"localhost:22222"}, nil)
 		require.NoError(t, err)
 
-		_, err = cl.NodePeerCount(ctx)
+		_, err = cl.NodePeerCount(ctx, &eth2api.NodePeerCountOpts{})
 		log.Error(ctx, "See this error log for fields", err)
 		require.Error(t, err)
-		require.ErrorContains(t, err, "beacon api node_peer_count: network operation error")
+		require.ErrorContains(t, err, "beacon api node_peer_count: client is not active")
 	})
 
 	// Test http server that just hangs until request cancelled
@@ -305,10 +303,10 @@ func TestErrors(t *testing.T) {
 		cl, err := eth2wrap.NewMultiHTTP(time.Millisecond, [4]byte{}, nil, []string{srv.URL}, nil)
 		require.NoError(t, err)
 
-		_, err = cl.NodePeerCount(ctx)
+		_, err = cl.NodePeerCount(ctx, &eth2api.NodePeerCountOpts{})
 		log.Error(ctx, "See this error log for fields", err)
 		require.Error(t, err)
-		require.ErrorContains(t, err, "beacon api node_peer_count: http request timeout")
+		require.ErrorContains(t, err, "beacon api node_peer_count: client is not active")
 	})
 
 	t.Run("caller cancelled", func(t *testing.T) {
@@ -318,14 +316,14 @@ func TestErrors(t *testing.T) {
 		cl, err := eth2wrap.NewMultiHTTP(time.Millisecond, [4]byte{}, nil, []string{srv.URL}, nil)
 		require.NoError(t, err)
 
-		_, err = cl.NodePeerCount(ctx)
+		_, err = cl.NodePeerCount(ctx, &eth2api.NodePeerCountOpts{})
 		log.Error(ctx, "See this error log for fields", err)
 		require.Error(t, err)
 		require.ErrorContains(t, err, "beacon api node_peer_count: context canceled")
 	})
 
 	t.Run("zero net op error", func(t *testing.T) {
-		bmock, err := beaconmock.New()
+		bmock, err := beaconmock.New(t.Context())
 		require.NoError(t, err)
 
 		bmock.GenesisFunc = func(context.Context, *eth2api.GenesisOpts) (*eth2v1.Genesis, error) {
@@ -343,7 +341,7 @@ func TestErrors(t *testing.T) {
 	})
 
 	t.Run("eth2api error", func(t *testing.T) {
-		bmock, err := beaconmock.New()
+		bmock, err := beaconmock.New(t.Context())
 		require.NoError(t, err)
 
 		bmock.SignedBeaconBlockFunc = func(_ context.Context, blockID string) (*eth2spec.VersionedSignedBeaconBlock, error) {
@@ -369,7 +367,7 @@ func TestCtxCancel(t *testing.T) {
 	for range 10 {
 		ctx, cancel := context.WithCancel(context.Background())
 
-		bmock, err := beaconmock.New()
+		bmock, err := beaconmock.New(t.Context())
 		require.NoError(t, err)
 		eth2Cl, err := eth2wrap.NewMultiHTTP(time.Second, [4]byte{}, nil, []string{bmock.Address()}, nil)
 		require.NoError(t, err)
@@ -381,65 +379,6 @@ func TestCtxCancel(t *testing.T) {
 	}
 }
 
-func TestBlockAttestations(t *testing.T) {
-	electraAtt1 := testutil.RandomElectraAttestation()
-	electraAtt2 := testutil.RandomElectraAttestation()
-
-	tests := []struct {
-		version          string
-		attestations     []*eth2spec.VersionedAttestation
-		serverJSONStruct any
-		expErr           string
-	}{
-		{
-			version: "electra",
-			attestations: []*eth2spec.VersionedAttestation{
-				{Version: eth2spec.DataVersionElectra, Electra: electraAtt1},
-				{Version: eth2spec.DataVersionElectra, Electra: electraAtt2},
-			},
-			serverJSONStruct: struct{ Data []*eth2e.Attestation }{Data: []*eth2e.Attestation{electraAtt1, electraAtt2}},
-			expErr:           "",
-		},
-	}
-	for _, test := range tests {
-		t.Run(test.version, func(t *testing.T) {
-			statusCode := http.StatusOK
-			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-				require.Equal(t, http.MethodGet, r.Method)
-				require.Equal(t, "/eth/v2/beacon/blocks/head/attestations", r.URL.Path)
-
-				b, err := json.Marshal(test.serverJSONStruct)
-				require.NoError(t, err)
-
-				w.Header().Add("Eth-Consensus-Version", test.version)
-				w.WriteHeader(statusCode)
-				_, _ = w.Write(b)
-			}))
-
-			cl := eth2wrap.NewHTTPAdapterForT(t, srv.URL, nil, time.Hour)
-
-			resp, err := cl.BlockAttestations(context.Background(), "head")
-			if test.expErr != "" {
-				require.ErrorContains(t, err, test.expErr)
-			} else {
-				require.NoError(t, err)
-			}
-
-			require.Equal(t, test.attestations, resp)
-
-			statusCode = http.StatusNotFound
-			resp, err = cl.BlockAttestations(context.Background(), "head")
-			require.NoError(t, err)
-			require.Empty(t, resp)
-
-			statusCode = http.StatusBadRequest
-			resp, err = cl.BlockAttestations(context.Background(), "head")
-			require.Error(t, err)
-			require.Empty(t, resp)
-		})
-	}
-}
-
 // TestOneError tests the case where one of the servers returns errors.
 func TestOneError(t *testing.T) {
 	// Start an erroring server.
@@ -449,7 +388,7 @@ func TestOneError(t *testing.T) {
 	defer srv.Close()
 
 	ctx := context.Background()
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	addresses := []string{
@@ -480,7 +419,7 @@ func TestOneTimeout(t *testing.T) {
 	defer srv.Close()
 	defer cancel() // Cancel the context before stopping the server.
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	addresses := []string{
@@ -557,7 +496,7 @@ func TestOnlyTimeout(t *testing.T) {
 func TestLazy(t *testing.T) {
 	ctx := context.Background()
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	target := testutil.MustParseURL(t, bmock.Address())
@@ -650,7 +589,7 @@ func TestLazyDomain(t *testing.T) {
 		t.Run(test.name, func(t *testing.T) {
 			ctx := context.Background()
 
-			bmock, err := beaconmock.New()
+			bmock, err := beaconmock.New(t.Context())
 			require.NoError(t, err)
 
 			target := testutil.MustParseURL(t, bmock.Address())
diff --git a/app/eth2wrap/lazy_test.go b/app/eth2wrap/lazy_test.go
index 9f8f6f75..212af598 100644
--- a/app/eth2wrap/lazy_test.go
+++ b/app/eth2wrap/lazy_test.go
@@ -6,13 +6,11 @@ import (
 	"context"
 	"testing"
 
-	"github.com/attestantio/go-eth2-client/spec"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
 
 	"github.com/obolnetwork/charon/app/eth2wrap"
 	"github.com/obolnetwork/charon/app/eth2wrap/mocks"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
 )
 
 func TestLazy_Name(t *testing.T) {
@@ -51,75 +49,6 @@ func TestLazy_IsSynced(t *testing.T) {
 	require.True(t, l.IsSynced())
 }
 
-func TestLazy_NodePeerCount(t *testing.T) {
-	client := mocks.NewClient(t)
-	client.On("NodePeerCount", mock.Anything).Return(5, nil).Once()
-
-	l := eth2wrap.NewLazyForT(client)
-
-	c, err := l.NodePeerCount(context.Background())
-	require.NoError(t, err)
-	require.Equal(t, 5, c)
-}
-
-func TestLazy_BlockAttestations(t *testing.T) {
-	ctx := context.Background()
-	atts := make([]*spec.VersionedAttestation, 3)
-
-	client := mocks.NewClient(t)
-	client.On("BlockAttestations", ctx, "state").Return(atts, nil).Once()
-
-	l := eth2wrap.NewLazyForT(client)
-
-	atts2, err := l.BlockAttestations(ctx, "state")
-	require.NoError(t, err)
-	require.Equal(t, atts, atts2)
-}
-
-func TestLazy_AggregateSyncCommitteeSelections(t *testing.T) {
-	ctx := context.Background()
-	partsel := make([]*eth2exp.SyncCommitteeSelection, 1)
-	selections := make([]*eth2exp.SyncCommitteeSelection, 3)
-
-	client := mocks.NewClient(t)
-	client.On("AggregateSyncCommitteeSelections", ctx, partsel).Return(selections, nil).Once()
-
-	l := eth2wrap.NewLazyForT(client)
-
-	selections2, err := l.AggregateSyncCommitteeSelections(ctx, partsel)
-	require.NoError(t, err)
-	require.Equal(t, selections, selections2)
-}
-
-func TestLazy_AggregateBeaconCommitteeSelections(t *testing.T) {
-	ctx := context.Background()
-	partsel := make([]*eth2exp.BeaconCommitteeSelection, 1)
-	selections := make([]*eth2exp.BeaconCommitteeSelection, 3)
-
-	client := mocks.NewClient(t)
-	client.On("AggregateBeaconCommitteeSelections", ctx, partsel).Return(selections, nil).Once()
-
-	l := eth2wrap.NewLazyForT(client)
-
-	selections2, err := l.AggregateBeaconCommitteeSelections(ctx, partsel)
-	require.NoError(t, err)
-	require.Equal(t, selections, selections2)
-}
-
-func TestLazy_ProposerConfig(t *testing.T) {
-	ctx := context.Background()
-	resp := &eth2exp.ProposerConfigResponse{}
-
-	client := mocks.NewClient(t)
-	client.On("ProposerConfig", ctx).Return(resp, nil).Once()
-
-	l := eth2wrap.NewLazyForT(client)
-
-	resp2, err := l.ProposerConfig(ctx)
-	require.NoError(t, err)
-	require.Equal(t, resp, resp2)
-}
-
 func TestLazy_ActiveValidators(t *testing.T) {
 	ctx := context.Background()
 	vals := make(eth2wrap.ActiveValidators)
diff --git a/app/eth2wrap/multi_test.go b/app/eth2wrap/multi_test.go
index 61017ae8..5ba1d736 100644
--- a/app/eth2wrap/multi_test.go
+++ b/app/eth2wrap/multi_test.go
@@ -7,13 +7,11 @@ import (
 	"errors"
 	"testing"
 
-	"github.com/attestantio/go-eth2-client/spec"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
 
 	"github.com/obolnetwork/charon/app/eth2wrap"
 	"github.com/obolnetwork/charon/app/eth2wrap/mocks"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
 )
 
 func TestMulti_Name(t *testing.T) {
@@ -57,108 +55,6 @@ func TestMulti_IsSynced(t *testing.T) {
 	require.True(t, m.IsSynced())
 }
 
-func TestMulti_NodePeerCount(t *testing.T) {
-	client := mocks.NewClient(t)
-	client.On("Address").Return("test").Once()
-	client.On("NodePeerCount", mock.Anything).Return(5, nil).Once()
-
-	m := eth2wrap.NewMultiForT([]eth2wrap.Client{client}, nil)
-
-	c, err := m.NodePeerCount(context.Background())
-	require.NoError(t, err)
-	require.Equal(t, 5, c)
-
-	expectedErr := errors.New("boo")
-	client.On("NodePeerCount", mock.Anything).Return(0, expectedErr).Once()
-
-	_, err = m.NodePeerCount(context.Background())
-	require.ErrorIs(t, err, expectedErr)
-}
-
-func TestMulti_BlockAttestations(t *testing.T) {
-	ctx := context.Background()
-	atts := make([]*spec.VersionedAttestation, 3)
-
-	client := mocks.NewClient(t)
-	client.On("Address").Return("test").Once()
-	client.On("BlockAttestations", mock.Anything, "state").Return(atts, nil).Once()
-
-	m := eth2wrap.NewMultiForT([]eth2wrap.Client{client}, nil)
-
-	atts2, err := m.BlockAttestations(ctx, "state")
-	require.NoError(t, err)
-	require.Equal(t, atts, atts2)
-
-	expectedErr := errors.New("boo")
-	client.On("BlockAttestations", mock.Anything, "state").Return(nil, expectedErr).Once()
-
-	_, err = m.BlockAttestations(ctx, "state")
-	require.ErrorIs(t, err, expectedErr)
-}
-
-func TestMulti_AggregateSyncCommitteeSelections(t *testing.T) {
-	ctx := context.Background()
-	partsel := make([]*eth2exp.SyncCommitteeSelection, 1)
-	selections := make([]*eth2exp.SyncCommitteeSelection, 3)
-
-	client := mocks.NewClient(t)
-	client.On("Address").Return("test").Once()
-	client.On("AggregateSyncCommitteeSelections", mock.Anything, partsel).Return(selections, nil).Once()
-
-	m := eth2wrap.NewMultiForT([]eth2wrap.Client{client}, nil)
-
-	selections2, err := m.AggregateSyncCommitteeSelections(ctx, partsel)
-	require.NoError(t, err)
-	require.Equal(t, selections, selections2)
-
-	expectedErr := errors.New("boo")
-	client.On("AggregateSyncCommitteeSelections", mock.Anything, partsel).Return(nil, expectedErr).Once()
-	_, err = m.AggregateSyncCommitteeSelections(ctx, partsel)
-	require.ErrorIs(t, err, expectedErr)
-}
-
-func TestMulti_AggregateBeaconCommitteeSelections(t *testing.T) {
-	ctx := context.Background()
-	partsel := make([]*eth2exp.BeaconCommitteeSelection, 1)
-	selections := make([]*eth2exp.BeaconCommitteeSelection, 3)
-
-	client := mocks.NewClient(t)
-	client.On("Address").Return("test").Once()
-	client.On("AggregateBeaconCommitteeSelections", mock.Anything, partsel).Return(selections, nil).Once()
-
-	m := eth2wrap.NewMultiForT([]eth2wrap.Client{client}, nil)
-
-	selections2, err := m.AggregateBeaconCommitteeSelections(ctx, partsel)
-	require.NoError(t, err)
-	require.Equal(t, selections, selections2)
-
-	expectedErr := errors.New("boo")
-	client.On("AggregateBeaconCommitteeSelections", mock.Anything, partsel).Return(nil, expectedErr).Once()
-	_, err = m.AggregateBeaconCommitteeSelections(ctx, partsel)
-	require.ErrorIs(t, err, expectedErr)
-}
-
-func TestMulti_ProposerConfig(t *testing.T) {
-	ctx := context.Background()
-	resp := &eth2exp.ProposerConfigResponse{}
-
-	client := mocks.NewClient(t)
-	client.On("Address").Return("test").Once()
-	client.On("ProposerConfig", mock.Anything).Return(resp, nil).Once()
-
-	m := eth2wrap.NewMultiForT([]eth2wrap.Client{client}, nil)
-
-	resp2, err := m.ProposerConfig(ctx)
-	require.NoError(t, err)
-	require.Equal(t, resp, resp2)
-
-	expectedErr := errors.New("boo")
-	client.On("ProposerConfig", mock.Anything).Return(nil, expectedErr).Once()
-
-	_, err = m.ProposerConfig(ctx)
-	require.ErrorIs(t, err, expectedErr)
-}
-
 func TestMulti_ActiveValidators(t *testing.T) {
 	ctx := context.Background()
 	vals := make(eth2wrap.ActiveValidators)
diff --git a/app/eth2wrap/synthproposer_test.go b/app/eth2wrap/synthproposer_test.go
index 587107a7..8ab77c69 100644
--- a/app/eth2wrap/synthproposer_test.go
+++ b/app/eth2wrap/synthproposer_test.go
@@ -177,7 +177,7 @@ func TestSynthProposer(t *testing.T) {
 				activeVals                 = 0
 			)
 
-			bmock, err := beaconmock.New(beaconmock.WithValidatorSet(set), beaconmock.WithSlotsPerEpoch(slotsPerEpoch))
+			bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(set), beaconmock.WithSlotsPerEpoch(slotsPerEpoch))
 			require.NoError(t, err)
 
 			bmock.SubmitProposalFunc = func(ctx context.Context, opts *eth2api.SubmitProposalOpts) error {
@@ -332,7 +332,7 @@ func TestSynthProposerBlockNotFound(t *testing.T) {
 		timesCalled   int
 	)
 
-	bmock, err := beaconmock.New(beaconmock.WithValidatorSet(set), beaconmock.WithSlotsPerEpoch(slotsPerEpoch))
+	bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(set), beaconmock.WithSlotsPerEpoch(slotsPerEpoch))
 	require.NoError(t, err)
 
 	bmock.ProposerDutiesFunc = func(ctx context.Context, e eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) {
diff --git a/app/eth2wrap/utils_test.go b/app/eth2wrap/utils_test.go
index f2e5c97a..26dad117 100644
--- a/app/eth2wrap/utils_test.go
+++ b/app/eth2wrap/utils_test.go
@@ -14,7 +14,7 @@ import (
 )
 
 func TestFetchGenesisTime(t *testing.T) {
-	eth2Cl, err := beaconmock.New()
+	eth2Cl, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	genesisTime, err := eth2wrap.FetchGenesisTime(t.Context(), eth2Cl)
@@ -25,7 +25,7 @@ func TestFetchGenesisTime(t *testing.T) {
 }
 
 func TestFetchSlotsConfig(t *testing.T) {
-	eth2Cl, err := beaconmock.New()
+	eth2Cl, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	slotDuration, slotsPerEpoch, err := eth2wrap.FetchSlotsConfig(t.Context(), eth2Cl)
@@ -37,7 +37,7 @@ func TestFetchSlotsConfig(t *testing.T) {
 }
 
 func TestFetchForkConfig(t *testing.T) {
-	eth2Cl, err := beaconmock.New()
+	eth2Cl, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	forkConfig, err := eth2wrap.FetchForkConfig(t.Context(), eth2Cl)
diff --git a/app/eth2wrap/valcache_test.go b/app/eth2wrap/valcache_test.go
index 17e9e6a9..e669cf35 100644
--- a/app/eth2wrap/valcache_test.go
+++ b/app/eth2wrap/valcache_test.go
@@ -43,7 +43,7 @@ func TestValidatorCache(t *testing.T) {
 	}
 
 	// Create a mock client.
-	eth2Cl, err := beaconmock.New()
+	eth2Cl, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// Configure it to return the set of validators if queried.
@@ -97,7 +97,7 @@ func TestValidatorCache(t *testing.T) {
 func TestGetBySlot(t *testing.T) {
 	t.Run("successful fetch", func(t *testing.T) {
 		// Create a mock client.
-		eth2Cl, err := beaconmock.New()
+		eth2Cl, err := beaconmock.New(t.Context())
 		require.NoError(t, err)
 
 		// Create two validators pubkeys
@@ -194,7 +194,7 @@ func TestGetBySlot(t *testing.T) {
 
 	t.Run("fallback to head state", func(t *testing.T) {
 		// Create a mock client.
-		eth2Cl, err := beaconmock.New()
+		eth2Cl, err := beaconmock.New(t.Context())
 		require.NoError(t, err)
 
 		// Create two validators pubkeys
diff --git a/app/monitoringapi_internal_test.go b/app/monitoringapi_internal_test.go
index 9a03ceed..a8a695ed 100644
--- a/app/monitoringapi_internal_test.go
+++ b/app/monitoringapi_internal_test.go
@@ -103,7 +103,7 @@ func TestStartChecker(t *testing.T) {
 			slotDuration := 12 * time.Second
 			slotsPerEpoch := 32
 
-			bmock, err := beaconmock.New(beaconmock.WithSlotDuration(slotDuration), beaconmock.WithSlotsPerEpoch(slotsPerEpoch))
+			bmock, err := beaconmock.New(t.Context(), beaconmock.WithSlotDuration(slotDuration), beaconmock.WithSlotsPerEpoch(slotsPerEpoch))
 			require.NoError(t, err)
 
 			bmock.NodeSyncingFunc = func(ctx context.Context, opts *eth2api.NodeSyncingOpts) (*eth2v1.SyncState, error) {
@@ -117,8 +117,8 @@ func TestStartChecker(t *testing.T) {
 			}
 
 			if tt.zeroBNPeers {
-				bmock.NodePeerCountFunc = func(ctx context.Context) (int, error) {
-					return 0, nil
+				bmock.NodePeerCountFunc = func(ctx context.Context, _ *eth2api.NodePeerCountOpts) (*eth2v1.PeerCount, error) {
+					return &eth2v1.PeerCount{Connected: 0}, nil
 				}
 			}
 
diff --git a/app/obolapi/api_internal_test.go b/app/obolapi/api_internal_test.go
index 8f93776b..759f9a4c 100644
--- a/app/obolapi/api_internal_test.go
+++ b/app/obolapi/api_internal_test.go
@@ -120,7 +120,6 @@ func TestHttpGet(t *testing.T) {
 			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				require.Equal(t, r.URL.Path, "/get-request")
 				require.Equal(t, r.Method, http.MethodGet)
-				require.Equal(t, r.Header.Get("Content-Type"), "application/json")
 				w.WriteHeader(http.StatusOK)
 			})),
 			expectedError: "",
@@ -132,7 +131,6 @@ func TestHttpGet(t *testing.T) {
 			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				require.Equal(t, r.URL.Path, "/get-request")
 				require.Equal(t, r.Method, http.MethodGet)
-				require.Equal(t, r.Header.Get("Content-Type"), "application/json")
 				require.Equal(t, r.Header.Get("test_header_key"), "test_header_value") //nolint:canonicalheader
 
 				w.WriteHeader(http.StatusOK)
@@ -149,7 +147,6 @@ func TestHttpGet(t *testing.T) {
 			server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				require.Equal(t, r.URL.Path, "/get-request")
 				require.Equal(t, r.Method, http.MethodGet)
-				require.Equal(t, r.Header.Get("Content-Type"), "application/json")
 
 				w.WriteHeader(http.StatusBadRequest)
 				_, err := w.Write([]byte(`"Bad Request response"`))
diff --git a/app/obolapi/exit_test.go b/app/obolapi/exit_test.go
index 3b39519b..2d49551a 100644
--- a/app/obolapi/exit_test.go
+++ b/app/obolapi/exit_test.go
@@ -29,7 +29,7 @@ const exitEpoch = eth2p0.Epoch(194048)
 func TestAPIFlow(t *testing.T) {
 	kn := 4
 
-	beaconMock, err := beaconmock.New()
+	beaconMock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	defer func() {
@@ -121,7 +121,7 @@ func TestAPIFlow(t *testing.T) {
 func TestAPIFlowMissingSig(t *testing.T) {
 	kn := 4
 
-	beaconMock, err := beaconmock.New()
+	beaconMock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	defer func() {
diff --git a/app/retry/retry_test.go b/app/retry/retry_test.go
index 2763b64e..44f7ba9d 100644
--- a/app/retry/retry_test.go
+++ b/app/retry/retry_test.go
@@ -105,7 +105,7 @@ func TestRetryer(t *testing.T) {
 
 func TestShutdown(t *testing.T) {
 	ctx := context.Background()
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	deadlineFunc, err := core.NewDutyDeadlineFunc(ctx, bmock)
diff --git a/app/sse/listener_internal_test.go b/app/sse/listener_internal_test.go
index 63555c9f..551c54aa 100644
--- a/app/sse/listener_internal_test.go
+++ b/app/sse/listener_internal_test.go
@@ -105,7 +105,7 @@ func TestHandleEvents(t *testing.T) {
 }
 
 func TestStartListener(t *testing.T) {
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	_, err = StartListener(t.Context(), bmock, []string{bmock.Address()}, []string{})
diff --git a/cmd/addvalidators_internal_test.go b/cmd/addvalidators_internal_test.go
deleted file mode 100644
index 82906e61..00000000
--- a/cmd/addvalidators_internal_test.go
+++ /dev/null
@@ -1,276 +0,0 @@
-// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
-
-package cmd
-
-import (
-	"context"
-	"fmt"
-	"math/rand"
-	"os"
-	"path"
-	"path/filepath"
-	"strings"
-	"testing"
-
-	k1 "github.com/decred/dcrd/dcrec/secp256k1/v4"
-	"github.com/stretchr/testify/require"
-
-	"github.com/obolnetwork/charon/cluster"
-	"github.com/obolnetwork/charon/cluster/manifest"
-	manifestpb "github.com/obolnetwork/charon/cluster/manifestpb/v1"
-	"github.com/obolnetwork/charon/testutil"
-)
-
-const (
-	feeRecipientAddr = "0x0000000000000000000000000000000000000000"
-)
-
-func TestValidateConfigAddValidators(t *testing.T) {
-	tests := []struct {
-		name   string
-		conf   addValidatorsConfig
-		numOps int
-		errMsg string
-	}{
-		{
-			name: "insufficient validators",
-			conf: addValidatorsConfig{
-				NumVals: 0,
-			},
-			errMsg: "insufficient validator count",
-		},
-		{
-			name: "empty fee recipient addrs",
-			conf: addValidatorsConfig{
-				NumVals:           1,
-				FeeRecipientAddrs: nil,
-			},
-			errMsg: "empty fee recipient addresses",
-		},
-		{
-			name: "empty withdrawal addrs",
-			conf: addValidatorsConfig{
-				NumVals:           1,
-				WithdrawalAddrs:   nil,
-				FeeRecipientAddrs: []string{feeRecipientAddr},
-			},
-			errMsg: "empty withdrawal addresses",
-		},
-		{
-			name: "addrs length mismatch",
-			conf: addValidatorsConfig{
-				NumVals:           1,
-				WithdrawalAddrs:   []string{feeRecipientAddr, feeRecipientAddr},
-				FeeRecipientAddrs: []string{feeRecipientAddr},
-			},
-			errMsg: "fee recipient and withdrawal addresses lengths mismatch",
-		},
-		{
-			name: "single addr for all validators",
-			conf: addValidatorsConfig{
-				NumVals:           2,
-				WithdrawalAddrs:   []string{feeRecipientAddr},
-				FeeRecipientAddrs: []string{feeRecipientAddr},
-			},
-		},
-		{
-			name: "count and addrs mismatch",
-			conf: addValidatorsConfig{
-				NumVals:           2,
-				WithdrawalAddrs:   []string{feeRecipientAddr, feeRecipientAddr, feeRecipientAddr},
-				FeeRecipientAddrs: []string{feeRecipientAddr, feeRecipientAddr, feeRecipientAddr},
-			},
-			errMsg: "count of validators and addresses mismatch",
-		},
-		{
-			name: "multiple addrs for multiple validators",
-			conf: addValidatorsConfig{
-				NumVals:           2,
-				WithdrawalAddrs:   []string{feeRecipientAddr, feeRecipientAddr},
-				FeeRecipientAddrs: []string{feeRecipientAddr, feeRecipientAddr},
-			},
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			err := validateConf(tt.conf)
-			if tt.errMsg != "" {
-				require.Equal(t, tt.errMsg, err.Error())
-			} else {
-				require.NoError(t, err)
-			}
-		})
-	}
-}
-
-func TestRunAddValidators(t *testing.T) {
-	const (
-		n        = 3
-		valCount = 1
-	)
-
-	var nodeDirnames []string
-	for i := range n {
-		nodeDirnames = append(nodeDirnames, fmt.Sprintf("node%d", i))
-	}
-
-	t.Run("add validators once", func(t *testing.T) {
-		seed := 0
-		random := rand.New(rand.NewSource(int64(seed)))
-		lock, p2pKeys, _ := cluster.NewForT(t, valCount, n, n, seed, random)
-
-		tmp := t.TempDir()
-		for _, dirname := range nodeDirnames {
-			dir := path.Join(tmp, dirname)
-			require.NoError(t, os.Mkdir(dir, 0o777))
-			require.NoError(t, os.Mkdir(path.Join(dir, "validator_keys"), 0o777))
-		}
-
-		conf := addValidatorsConfig{
-			NumVals:           1,
-			WithdrawalAddrs:   []string{feeRecipientAddr},
-			FeeRecipientAddrs: []string{feeRecipientAddr},
-			TestConfig: addValidatorTestConfig{
-				Lock:    &lock,
-				P2PKeys: p2pKeys,
-			},
-			ClusterDir: tmp,
-		}
-
-		err := runAddValidatorsSolo(context.Background(), conf)
-		require.NoError(t, err)
-
-		// Verify the new cluster manifest
-		manifestFile := filepath.Join(tmp, "node0", "cluster-manifest.pb")
-		cluster, err := manifest.LoadCluster(manifestFile, "", nil)
-		require.NoError(t, err)
-
-		require.Len(t, cluster.GetValidators(), valCount+1)
-		require.Equal(t, cluster.GetValidators()[1].GetFeeRecipientAddress(), feeRecipientAddr)
-		require.Equal(t, cluster.GetValidators()[1].GetWithdrawalAddress(), feeRecipientAddr)
-	})
-
-	t.Run("add validators twice", func(t *testing.T) {
-		seed := 0
-		random := rand.New(rand.NewSource(int64(seed)))
-		lock, p2pKeys, _ := cluster.NewForT(t, valCount, n, n, seed, random)
-
-		tmp := t.TempDir()
-		for _, dirname := range nodeDirnames {
-			dir := path.Join(tmp, dirname)
-			require.NoError(t, os.Mkdir(dir, 0o777))
-			require.NoError(t, os.Mkdir(path.Join(dir, "validator_keys"), 0o777))
-		}
-
-		conf := addValidatorsConfig{
-			NumVals:           1,
-			WithdrawalAddrs:   []string{feeRecipientAddr},
-			FeeRecipientAddrs: []string{feeRecipientAddr},
-			TestConfig: addValidatorTestConfig{
-				Lock:    &lock,
-				P2PKeys: p2pKeys,
-			},
-			ClusterDir: tmp,
-		}
-
-		// First add one validator
-		require.NoError(t, runAddValidatorsSolo(context.Background(), conf))
-
-		manifestFile := filepath.Join(tmp, "node0", "cluster-manifest.pb")
-		rawDAG, err := manifest.LoadDAG(manifestFile, "", nil)
-		require.NoError(t, err)
-
-		cluster, err := manifest.Materialise(rawDAG)
-		require.NoError(t, err)
-		require.Equal(t, cluster.GetInitialMutationHash(), lock.LockHash)
-
-		// Run the second add validators command using cluster manifest output from the first run
-		conf.TestConfig.Lock = nil
-		conf.TestConfig.ClusterDAG = rawDAG
-
-		// Delete existing deposit data file in each node directory since the deposit file names are same
-		// when add validators command is run twice consecutively. This is because the test finishes in
-		// milliseconds and filenames are named YYYYMMDDHHMMSS which doesn't account for milliseconds.
-		for i := range n {
-			entries, err := os.ReadDir(nodeDir(tmp, i))
-			require.NoError(t, err)
-
-			for _, e := range entries {
-				if strings.Contains(e.Name(), "deposit-data") {
-					require.NoError(t, os.Remove(path.Join(nodeDir(tmp, i), e.Name())))
-				}
-			}
-		}
-
-		// Then add the second validator
-		require.NoError(t, runAddValidatorsSolo(context.Background(), conf))
-
-		cluster, err = manifest.LoadCluster(manifestFile, "", nil)
-		require.NoError(t, err)
-
-		// The cluster manifest should contain three validators now since the
-		// original cluster already had one validator, and we added two more.
-		require.Len(t, cluster.GetValidators(), valCount+2)
-		require.Equal(t, cluster.GetInitialMutationHash(), lock.LockHash)
-
-		entries, err := os.ReadDir(path.Join(tmp, "node0"))
-		require.NoError(t, err)
-		require.Len(t, entries, 3)
-
-		require.Contains(t, entries[0].Name(), "cluster-manifest")
-		require.Contains(t, entries[1].Name(), "deposit-data")
-		require.Contains(t, entries[2].Name(), "validator_keys")
-	})
-}
-
-func TestValidateP2PKeysOrder(t *testing.T) {
-	const (
-		seed = 123
-		n    = 4
-	)
-
-	t.Run("correct order", func(t *testing.T) {
-		var (
-			p2pKeys []*k1.PrivateKey
-			ops     []*manifestpb.Operator
-		)
-
-		for i := range n {
-			key, enrStr := testutil.RandomENR(t, seed+i)
-			p2pKeys = append(p2pKeys, key)
-			ops = append(ops, &manifestpb.Operator{Enr: enrStr.String()})
-		}
-
-		err := validateP2PKeysOrder(p2pKeys, ops)
-		require.NoError(t, err)
-	})
-
-	t.Run("length mismatch", func(t *testing.T) {
-		key, _ := testutil.RandomENR(t, seed)
-		err := validateP2PKeysOrder([]*k1.PrivateKey{key}, nil)
-		require.ErrorContains(t, err, "length of p2p keys and enrs don't match")
-	})
-
-	t.Run("invalid order", func(t *testing.T) {
-		var (
-			p2pKeys []*k1.PrivateKey
-			ops     []*manifestpb.Operator
-		)
-
-		for i := range n {
-			key, enrStr := testutil.RandomENR(t, seed+i)
-			p2pKeys = append(p2pKeys, key)
-			ops = append(ops, &manifestpb.Operator{Enr: enrStr.String()})
-		}
-
-		// Swap first and last elements of p2p keys list
-		first := p2pKeys[0]
-		last := p2pKeys[n-1]
-		p2pKeys[0] = last
-		p2pKeys[n-1] = first
-
-		err := validateP2PKeysOrder(p2pKeys, ops)
-		require.ErrorContains(t, err, "invalid p2p key order")
-	})
-}
diff --git a/cmd/createcluster_internal_test.go b/cmd/createcluster_internal_test.go
index e1a4256f..477345ca 100644
--- a/cmd/createcluster_internal_test.go
+++ b/cmd/createcluster_internal_test.go
@@ -35,6 +35,10 @@ import (
 
 //go:generate go test . -run=TestCreateCluster -update
 
+const (
+	feeRecipientAddr = "0x0000000000000000000000000000000000000000"
+)
+
 func TestCreateCluster(t *testing.T) {
 	defPath := "../cluster/examples/cluster-definition-006.json"
 	def, err := loadDefinition(context.Background(), defPath)
diff --git a/cmd/exit_broadcast_internal_test.go b/cmd/exit_broadcast_internal_test.go
index 0cda5d7f..5de9e7af 100644
--- a/cmd/exit_broadcast_internal_test.go
+++ b/cmd/exit_broadcast_internal_test.go
@@ -110,6 +110,7 @@ func testRunBcastFullExitCmdFlow(t *testing.T, fromFile bool, all bool) {
 	}
 
 	beaconMock, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithValidatorSet(validatorSet),
 		beaconmock.WithEndpoint("/eth/v1/beacon/pool/voluntary_exits", ""),
 	)
@@ -310,7 +311,7 @@ func Test_runBcastFullExitCmd_Config(t *testing.T) {
 			bnURL := badStr
 
 			if !test.badBeaconNodeEndpoints {
-				beaconMock, err := beaconmock.New()
+				beaconMock, err := beaconmock.New(t.Context())
 				require.NoError(t, err)
 
 				defer func() {
@@ -490,6 +491,7 @@ func TestExitBcastFullExitNotActivated(t *testing.T) {
 	}
 
 	beaconMock, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithValidatorSet(validatorSet),
 		beaconmock.WithEndpoint("/eth/v1/beacon/pool/voluntary_exits", ""),
 	)
diff --git a/cmd/exit_delete_internal_test.go b/cmd/exit_delete_internal_test.go
new file mode 100644
index 00000000..198b7a30
--- /dev/null
+++ b/cmd/exit_delete_internal_test.go
@@ -0,0 +1,182 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package cmd
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"math/rand"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+	"time"
+
+	eth2v1 "github.com/attestantio/go-eth2-client/api/v1"
+	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
+	"github.com/stretchr/testify/require"
+
+	"github.com/obolnetwork/charon/cluster"
+	"github.com/obolnetwork/charon/tbls"
+	"github.com/obolnetwork/charon/testutil"
+	"github.com/obolnetwork/charon/testutil/beaconmock"
+	"github.com/obolnetwork/charon/testutil/obolapimock"
+)
+
+func Test_runDeleteExit(t *testing.T) {
+	t.Parallel()
+	t.Run("full flow", func(t *testing.T) {
+		t.Parallel()
+		testRunDeleteExitFullFlow(t, false)
+	})
+	t.Run("full flow all", func(t *testing.T) {
+		t.Parallel()
+		testRunDeleteExitFullFlow(t, true)
+	})
+}
+
+func testRunDeleteExitFullFlow(t *testing.T, all bool) {
+	t.Helper()
+
+	ctx := context.Background()
+
+	valAmt := 100
+	operatorAmt := 4
+
+	random := rand.New(rand.NewSource(int64(0)))
+
+	lock, enrs, keyShares := cluster.NewForT(
+		t,
+		valAmt,
+		operatorAmt,
+		operatorAmt,
+		0,
+		random,
+	)
+
+	root := t.TempDir()
+
+	operatorShares := make([][]tbls.PrivateKey, operatorAmt)
+
+	for opIdx := range operatorAmt {
+		for _, share := range keyShares {
+			operatorShares[opIdx] = append(operatorShares[opIdx], share[opIdx])
+		}
+	}
+
+	mBytes, err := json.Marshal(lock)
+	require.NoError(t, err)
+
+	validatorSet := beaconmock.ValidatorSet{}
+
+	for idx, v := range lock.Validators {
+		validatorSet[eth2p0.ValidatorIndex(idx)] = &eth2v1.Validator{
+			Index:   eth2p0.ValidatorIndex(idx),
+			Balance: 42,
+			Status:  eth2v1.ValidatorStateActiveOngoing,
+			Validator: &eth2p0.Validator{
+				PublicKey:             eth2p0.BLSPubKey(v.PubKey),
+				WithdrawalCredentials: testutil.RandomBytes32(),
+			},
+		}
+	}
+
+	beaconMock, err := beaconmock.New(
+		t.Context(),
+		beaconmock.WithValidatorSet(validatorSet),
+	)
+	require.NoError(t, err)
+
+	defer func() {
+		require.NoError(t, beaconMock.Close())
+	}()
+
+	eth2Cl, err := eth2Client(ctx, []string{}, map[string]string{}, []string{beaconMock.Address()}, 10*time.Second, [4]byte(lock.ForkVersion))
+	require.NoError(t, err)
+
+	handler, addLockFiles := obolapimock.MockServer(false, eth2Cl)
+	srv := httptest.NewServer(handler)
+
+	addLockFiles(lock)
+
+	defer srv.Close()
+
+	writeAllLockData(t, root, operatorAmt, enrs, operatorShares, mBytes)
+
+	idx := 1
+	baseDir := filepath.Join(root, fmt.Sprintf("op%d", idx))
+
+	config := exitConfig{
+		BeaconNodeEndpoints: []string{beaconMock.Address()},
+		ValidatorPubkey:     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,
+		ExitEpoch:           194048,
+		BeaconNodeTimeout:   30 * time.Second,
+		PublishTimeout:      10 * time.Second,
+		All:                 all,
+	}
+
+	require.NoError(t, runSignPartialExit(ctx, config), "operator index: %v", idx)
+
+	require.NoError(t, runDeleteExit(ctx, config))
+}
+
+func TestExitDeleteCLI(t *testing.T) {
+	tests := []struct {
+		name        string
+		expectedErr string
+		flags       []string
+	}{
+		{
+			name:        "no validator public key and not all",
+			expectedErr: "validator-public-key must be specified when exiting single validator.",
+			flags: []string{
+				"--publish-address=test",
+				"--private-key-file=test",
+				"--lock-file=test",
+				"--publish-timeout=1ms",
+				"--all=false",
+				"--testnet-name=test",
+				"--testnet-fork-version=test",
+				"--testnet-chain-id=1",
+				"--testnet-genesis-timestamp=1",
+				"--testnet-capella-hard-fork=test",
+			},
+		},
+		{
+			name:        "validator public key and all",
+			expectedErr: "validator-public-key should not be specified when all is, as it is obsolete and misleading.",
+			flags: []string{
+				"--publish-address=test",
+				"--private-key-file=test",
+				"--lock-file=test",
+				"--validator-public-key=test",
+				"--publish-timeout=1ms",
+				"--all=true",
+				"--testnet-name=test",
+				"--testnet-fork-version=test",
+				"--testnet-chain-id=1",
+				"--testnet-genesis-timestamp=1",
+				"--testnet-capella-hard-fork=test",
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			cmd := newExitCmd(newDeleteExitCmd(runDeleteExit))
+			cmd.SetArgs(append([]string{"delete"}, test.flags...))
+
+			err := cmd.Execute()
+			if test.expectedErr != "" {
+				require.Error(t, err)
+				require.ErrorContains(t, err, test.expectedErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
diff --git a/cmd/exit_fetch_internal_test.go b/cmd/exit_fetch_internal_test.go
index b2d7ebda..ee1a9c7b 100644
--- a/cmd/exit_fetch_internal_test.go
+++ b/cmd/exit_fetch_internal_test.go
@@ -84,6 +84,7 @@ func testRunFetchExitFullFlow(t *testing.T, all bool) {
 	}
 
 	beaconMock, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithValidatorSet(validatorSet),
 	)
 	require.NoError(t, err)
@@ -297,6 +298,7 @@ func TestFetchExitFullFlowNotActivated(t *testing.T) {
 	}
 
 	beaconMock, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithValidatorSet(validatorSet),
 	)
 	require.NoError(t, err)
diff --git a/cmd/exit_list_internal_test.go b/cmd/exit_list_internal_test.go
index ca274779..492b2073 100644
--- a/cmd/exit_list_internal_test.go
+++ b/cmd/exit_list_internal_test.go
@@ -67,7 +67,7 @@ func Test_runListActiveVals(t *testing.T) {
 		}
 	}
 
-	beaconMock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet))
+	beaconMock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(validatorSet))
 	require.NoError(t, err)
 
 	defer func() {
@@ -135,7 +135,7 @@ func Test_listActiveVals(t *testing.T) {
 			}
 		}
 
-		beaconMock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet))
+		beaconMock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(validatorSet))
 		require.NoError(t, err)
 
 		defer func() {
@@ -178,7 +178,7 @@ func Test_listActiveVals(t *testing.T) {
 			}
 		}
 
-		beaconMock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet))
+		beaconMock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(validatorSet))
 		require.NoError(t, err)
 
 		defer func() {
diff --git a/cmd/exit_sign_internal_test.go b/cmd/exit_sign_internal_test.go
index b9f4c997..0b690c70 100644
--- a/cmd/exit_sign_internal_test.go
+++ b/cmd/exit_sign_internal_test.go
@@ -184,7 +184,7 @@ func runSubmitPartialExitFlowTest(t *testing.T, useValIdx bool, skipBeaconNodeCh
 		}
 	}
 
-	beaconMock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet))
+	beaconMock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(validatorSet))
 	require.NoError(t, err)
 
 	defer func() {
@@ -355,7 +355,7 @@ func Test_runSubmitPartialExit_Config(t *testing.T) {
 			bnURL := badStr
 
 			if !test.badBeaconNodeEndpoints {
-				beaconMock, err := beaconmock.New()
+				beaconMock, err := beaconmock.New(t.Context())
 				require.NoError(t, err)
 
 				defer func() {
diff --git a/cmd/relay/relay_internal_test.go b/cmd/relay/relay_internal_test.go
index d59464a4..e084c414 100644
--- a/cmd/relay/relay_internal_test.go
+++ b/cmd/relay/relay_internal_test.go
@@ -30,7 +30,7 @@ func TestRunBootnode(t *testing.T) {
 	config := Config{
 		DataDir:   temp,
 		LogConfig: log.DefaultConfig(),
-		P2PConfig: p2p.Config{TCPAddrs: []string{testutil.AvailableAddr(t).String()}},
+		P2PConfig: p2p.Config{TCPAddrs: []string{testutil.AvailableAddr(t).String()}, UDPAddrs: []string{testutil.AvailableUDPAddr(t).String()}},
 		HTTPAddr:  testutil.AvailableAddr(t).String(),
 	}
 
@@ -51,7 +51,7 @@ func TestRunBootnodeAutoP2P(t *testing.T) {
 	config := Config{
 		DataDir:   temp,
 		LogConfig: log.DefaultConfig(),
-		P2PConfig: p2p.Config{TCPAddrs: []string{testutil.AvailableAddr(t).String()}},
+		P2PConfig: p2p.Config{TCPAddrs: []string{testutil.AvailableAddr(t).String()}, UDPAddrs: []string{testutil.AvailableUDPAddr(t).String()}},
 		HTTPAddr:  testutil.AvailableAddr(t).String(),
 	}
 
@@ -71,7 +71,7 @@ func TestRunBootnodeAutoP2P(t *testing.T) {
 func TestServeAddrs(t *testing.T) {
 	t.Run("multiaddrs", func(t *testing.T) {
 		testServeAddrs(t,
-			p2p.Config{TCPAddrs: []string{testutil.AvailableAddr(t).String()}},
+			p2p.Config{TCPAddrs: []string{testutil.AvailableAddr(t).String()}, UDPAddrs: []string{testutil.AvailableUDPAddr(t).String()}},
 			"",
 			func(t *testing.T, data []byte) bool {
 				t.Helper()
@@ -104,7 +104,7 @@ func TestServeAddrs(t *testing.T) {
 
 	t.Run("enr", func(t *testing.T) {
 		testServeAddrs(t,
-			p2p.Config{TCPAddrs: []string{testutil.AvailableAddr(t).String()}},
+			p2p.Config{TCPAddrs: []string{testutil.AvailableAddr(t).String()}, UDPAddrs: []string{testutil.AvailableUDPAddr(t).String()}},
 			"enr",
 			func(t *testing.T, data []byte) bool {
 				t.Helper()
@@ -128,6 +128,7 @@ func TestServeAddrs(t *testing.T) {
 		testServeAddrs(t,
 			p2p.Config{
 				TCPAddrs:   []string{testutil.AvailableAddr(t).String()},
+				UDPAddrs:   []string{testutil.AvailableUDPAddr(t).String()},
 				ExternalIP: "222.222.222.222",
 			},
 			"enr",
@@ -153,6 +154,7 @@ func TestServeAddrs(t *testing.T) {
 		testServeAddrs(t,
 			p2p.Config{
 				TCPAddrs:     []string{testutil.AvailableAddr(t).String()},
+				UDPAddrs:     []string{testutil.AvailableUDPAddr(t).String()},
 				ExternalHost: "www.google.com",
 			},
 			"enr",
@@ -242,7 +244,7 @@ func TestRelayMetricsExported(t *testing.T) {
 	config := Config{
 		DataDir:        temp,
 		LogConfig:      log.DefaultConfig(),
-		P2PConfig:      p2p.Config{TCPAddrs: []string{testutil.AvailableAddr(t).String()}},
+		P2PConfig:      p2p.Config{TCPAddrs: []string{testutil.AvailableAddr(t).String()}, UDPAddrs: []string{testutil.AvailableUDPAddr(t).String()}},
 		HTTPAddr:       testutil.AvailableAddr(t).String(),
 		MonitoringAddr: testutil.AvailableAddr(t).String(),
 	}
diff --git a/cmd/test.go b/cmd/test.go
index 1b8f187f..08338039 100644
--- a/cmd/test.go
+++ b/cmd/test.go
@@ -344,6 +344,8 @@ func writeResultToFile(res testCategoryResult, path string) error {
 		file.MEV = res
 	case infraTestCategory:
 		file.Infra = res
+	default:
+		return errors.New("unknown category", z.Str("category", res.CategoryName))
 	}
 
 	// write data to temp file
@@ -394,6 +396,7 @@ func writeResultToWriter(res testCategoryResult, w io.Writer) error {
 		lines = append(lines, categoryDefaultASCII()...)
 	}
 
+	//nolint:revive // enforce-switch-style: the list is exhaustive and there is no need for default
 	switch res.Score {
 	case categoryScoreA:
 		lines = appendScore(lines, scoreAASCII())
@@ -488,6 +491,7 @@ func calculateScore(results []testResult) categoryScore {
 	avg := 0
 
 	for _, t := range results {
+		//nolint:revive // enforce-switch-style: the list is exhaustive and there is no need for default
 		switch t.Verdict {
 		case testVerdictPoor:
 			return categoryScoreC
diff --git a/cmd/testinfra.go b/cmd/testinfra.go
index bad73c4f..3721430d 100644
--- a/cmd/testinfra.go
+++ b/cmd/testinfra.go
@@ -782,6 +782,7 @@ func availableMemoryMacos(ctx context.Context) (int64, error) {
 			}
 
 			pagesSpeculative = bytes
+		default:
 		}
 	}
 
diff --git a/cmd/testpeers.go b/cmd/testpeers.go
index f0aa042f..8ed075f1 100644
--- a/cmd/testpeers.go
+++ b/cmd/testpeers.go
@@ -260,7 +260,7 @@ func runTestPeers(ctx context.Context, w io.Writer, conf testPeersConfig) (res t
 
 // charon peers tests
 
-func testAllPeers(ctx context.Context, queuedTestCases []testCaseName, allTestCases map[testCaseName]testCasePeer, conf testPeersConfig, tcpNode host.Host, allPeersResCh chan map[string][]testResult) error {
+func testAllPeers(ctx context.Context, queuedTestCases []testCaseName, allTestCases map[testCaseName]testCasePeer, conf testPeersConfig, p2pNode host.Host, allPeersResCh chan map[string][]testResult) error {
 	// run tests for all peer nodes
 	allPeersRes := make(map[string][]testResult)
 	singlePeerResCh := make(chan map[string][]testResult)
@@ -275,7 +275,7 @@ func testAllPeers(ctx context.Context, queuedTestCases []testCaseName, allTestCa
 		currENR := enr // TODO: can be removed after go1.22 version bump
 
 		group.Go(func() error {
-			return testSinglePeer(ctx, queuedTestCases, allTestCases, conf, tcpNode, currENR, singlePeerResCh)
+			return testSinglePeer(ctx, queuedTestCases, allTestCases, conf, p2pNode, currENR, singlePeerResCh)
 		})
 	}
 
@@ -302,7 +302,7 @@ func testAllPeers(ctx context.Context, queuedTestCases []testCaseName, allTestCa
 	return nil
 }
 
-func testSinglePeer(ctx context.Context, queuedTestCases []testCaseName, allTestCases map[testCaseName]testCasePeer, conf testPeersConfig, tcpNode host.Host, target string, allTestResCh chan map[string][]testResult) error {
+func testSinglePeer(ctx context.Context, queuedTestCases []testCaseName, allTestCases map[testCaseName]testCasePeer, conf testPeersConfig, p2pNode host.Host, target string, allTestResCh chan map[string][]testResult) error {
 	singleTestResCh := make(chan testResult)
 	allTestRes := []testResult{}
 
@@ -325,7 +325,7 @@ func testSinglePeer(ctx context.Context, queuedTestCases []testCaseName, allTest
 	}
 
 	// run all peers tests for a peer, pushing each completed test to the channel until all are complete or timeout occurs
-	go runPeerTest(ctx, queuedTestCases, allTestCases, conf, tcpNode, peerTarget, singleTestResCh)
+	go runPeerTest(ctx, queuedTestCases, allTestCases, conf, p2pNode, peerTarget, singleTestResCh)
 
 	testCounter := 0
 
@@ -358,7 +358,7 @@ func testSinglePeer(ctx context.Context, queuedTestCases []testCaseName, allTest
 	return nil
 }
 
-func runPeerTest(ctx context.Context, queuedTestCases []testCaseName, allTestCases map[testCaseName]testCasePeer, conf testPeersConfig, tcpNode host.Host, target p2p.Peer, testResCh chan testResult) {
+func runPeerTest(ctx context.Context, queuedTestCases []testCaseName, allTestCases map[testCaseName]testCasePeer, conf testPeersConfig, p2pNode host.Host, target p2p.Peer, testResCh chan testResult) {
 	defer close(testResCh)
 
 	for _, t := range queuedTestCases {
@@ -367,12 +367,12 @@ func runPeerTest(ctx context.Context, queuedTestCases []testCaseName, allTestCas
 			testResCh <- failedTestResult(testResult{Name: t.name}, errTimeoutInterrupted)
 			return
 		default:
-			testResCh <- allTestCases[t](ctx, &conf, tcpNode, target)
+			testResCh <- allTestCases[t](ctx, &conf, p2pNode, target)
 		}
 	}
 }
 
-func peerPingTest(ctx context.Context, _ *testPeersConfig, tcpNode host.Host, peer p2p.Peer) testResult {
+func peerPingTest(ctx context.Context, _ *testPeersConfig, p2pNode host.Host, peer p2p.Peer) testResult {
 	testRes := testResult{Name: "Ping"}
 
 	ticker := time.NewTicker(1)
@@ -385,7 +385,7 @@ func peerPingTest(ctx context.Context, _ *testPeersConfig, tcpNode host.Host, pe
 		default:
 			ticker.Reset(3 * time.Second)
 
-			result, err := pingPeerOnce(ctx, tcpNode, peer)
+			result, err := pingPeerOnce(ctx, p2pNode, peer)
 			if err != nil {
 				return failedTestResult(testRes, err)
 			}
@@ -414,10 +414,10 @@ func peerPingTest(ctx context.Context, _ *testPeersConfig, tcpNode host.Host, pe
 	return testRes
 }
 
-func peerPingMeasureTest(ctx context.Context, _ *testPeersConfig, tcpNode host.Host, peer p2p.Peer) testResult {
+func peerPingMeasureTest(ctx context.Context, _ *testPeersConfig, p2pNode host.Host, peer p2p.Peer) testResult {
 	testRes := testResult{Name: "PingMeasure"}
 
-	result, err := pingPeerOnce(ctx, tcpNode, peer)
+	result, err := pingPeerOnce(ctx, p2pNode, peer)
 	if err != nil {
 		return failedTestResult(testRes, err)
 	}
@@ -431,7 +431,7 @@ func peerPingMeasureTest(ctx context.Context, _ *testPeersConfig, tcpNode host.H
 	return testRes
 }
 
-func peerPingLoadTest(ctx context.Context, conf *testPeersConfig, tcpNode host.Host, peer p2p.Peer) testResult {
+func peerPingLoadTest(ctx context.Context, conf *testPeersConfig, p2pNode host.Host, peer p2p.Peer) testResult {
 	log.Info(ctx, "Running ping load tests...",
 		z.Any("duration", conf.LoadTestDuration),
 		z.Any("target", peer.Name),
@@ -454,7 +454,7 @@ func peerPingLoadTest(ctx context.Context, conf *testPeersConfig, tcpNode host.H
 			wg.Add(1)
 
 			go func() {
-				pingPeerContinuously(pingCtx, tcpNode, peer, testResCh)
+				pingPeerContinuously(pingCtx, p2pNode, peer, testResCh)
 				wg.Done()
 			}()
 		case <-pingCtx.Done():
@@ -470,7 +470,7 @@ func peerPingLoadTest(ctx context.Context, conf *testPeersConfig, tcpNode host.H
 	return testRes
 }
 
-func peerDirectConnTest(ctx context.Context, conf *testPeersConfig, tcpNode host.Host, p2pPeer p2p.Peer) testResult {
+func peerDirectConnTest(ctx context.Context, conf *testPeersConfig, p2pNode host.Host, p2pPeer p2p.Peer) testResult {
 	testRes := testResult{Name: "DirectConn"}
 
 	log.Info(ctx, "Trying to establish direct connection...",
@@ -479,7 +479,7 @@ func peerDirectConnTest(ctx context.Context, conf *testPeersConfig, tcpNode host
 
 	var err error
 	for range int(conf.DirectConnectionTimeout.Seconds()) {
-		err = tcpNode.Connect(network.WithForceDirectDial(ctx, "relay_to_direct"), peer.AddrInfo{ID: p2pPeer.ID})
+		err = p2pNode.Connect(network.WithForceDirectDial(ctx, "relay_to_direct"), peer.AddrInfo{ID: p2pPeer.ID})
 		if err == nil {
 			break
 		}
@@ -493,7 +493,7 @@ func peerDirectConnTest(ctx context.Context, conf *testPeersConfig, tcpNode host
 
 	log.Info(ctx, "Direct connection established", z.Any("target", p2pPeer.Name))
 
-	conns := tcpNode.Network().ConnsToPeer(p2pPeer.ID)
+	conns := p2pNode.Network().ConnsToPeer(p2pPeer.ID)
 	if len(conns) < 2 {
 		return failedTestResult(testRes, errors.New("expected 2 connections to peer (relay and direct)", z.Int("connections", len(conns))))
 	}
@@ -771,6 +771,8 @@ func fetchENRs(ctx context.Context, conf testPeersConfig) ([]string, error) {
 		if err != nil {
 			return nil, err
 		}
+	default:
+		return nil, errors.New("no enrs, definition file or lock file specified")
 	}
 
 	return enrs, nil
@@ -846,7 +848,7 @@ func setupP2P(ctx context.Context, privKey *k1.PrivateKey, conf p2p.Config, peer
 		return nil, nil, err
 	}
 
-	tcpNode, err := p2p.NewTCPNode(ctx, conf, privKey, connGater, false)
+	tcpNode, err := p2p.NewNode(ctx, conf, privKey, connGater, false, p2p.NodeTypeTCP)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -867,8 +869,8 @@ func setupP2P(ctx context.Context, privKey *k1.PrivateKey, conf p2p.Config, peer
 	}, nil
 }
 
-func pingPeerOnce(ctx context.Context, tcpNode host.Host, peer p2p.Peer) (ping.Result, error) {
-	pingSvc := ping.NewPingService(tcpNode)
+func pingPeerOnce(ctx context.Context, p2pNode host.Host, peer p2p.Peer) (ping.Result, error) {
+	pingSvc := ping.NewPingService(p2pNode)
 
 	pingCtx, cancel := context.WithCancel(ctx)
 	defer cancel()
@@ -883,9 +885,9 @@ func pingPeerOnce(ctx context.Context, tcpNode host.Host, peer p2p.Peer) (ping.R
 	return result, nil
 }
 
-func pingPeerContinuously(ctx context.Context, tcpNode host.Host, peer p2p.Peer, resCh chan<- time.Duration) {
+func pingPeerContinuously(ctx context.Context, p2pNode host.Host, peer p2p.Peer, resCh chan<- time.Duration) {
 	for {
-		r, err := pingPeerOnce(ctx, tcpNode, peer)
+		r, err := pingPeerOnce(ctx, p2pNode, peer)
 		if err != nil {
 			return
 		}
diff --git a/cmd/testpeers_internal_test.go b/cmd/testpeers_internal_test.go
index 3896a8b8..f4b2c263 100644
--- a/cmd/testpeers_internal_test.go
+++ b/cmd/testpeers_internal_test.go
@@ -546,7 +546,7 @@ func startPeer(t *testing.T, conf testPeersConfig, peerPrivKey *k1.PrivateKey) e
 	connGater, err := p2p.NewConnGater([]peer.ID{hostAsPeer.ID}, relays)
 	require.NoError(t, err)
 
-	peerTCPNode, err := p2p.NewTCPNode(ctx, peerConf.P2P, peerPrivKey, connGater, false)
+	peerTCPNode, err := p2p.NewNode(ctx, peerConf.P2P, peerPrivKey, connGater, false, p2p.NodeTypeTCP)
 	require.NoError(t, err)
 
 	for _, relay := range relays {
diff --git a/core/bcast/bcast_test.go b/core/bcast/bcast_test.go
index dfe93a57..b5c12e19 100644
--- a/core/bcast/bcast_test.go
+++ b/core/bcast/bcast_test.go
@@ -42,7 +42,7 @@ func TestBroadcast(t *testing.T) {
 	}
 
 	for _, testFunc := range testFuncs {
-		mock, err := beaconmock.New()
+		mock, err := beaconmock.New(t.Context())
 		require.NoError(t, err)
 
 		test := testFunc(t, &mock)
@@ -74,7 +74,7 @@ func TestBroadcast(t *testing.T) {
 }
 
 func TestBroadcastOtherDuties(t *testing.T) {
-	mock, err := beaconmock.New()
+	mock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	bcaster, err := bcast.New(context.Background(), mock)
diff --git a/core/deadline_test.go b/core/deadline_test.go
index a6573049..5431a1e4 100644
--- a/core/deadline_test.go
+++ b/core/deadline_test.go
@@ -88,7 +88,7 @@ func TestDeadliner(t *testing.T) {
 }
 
 func TestNewDutyDeadlineFunc(t *testing.T) {
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	genesisTime, err := eth2wrap.FetchGenesisTime(t.Context(), bmock)
@@ -171,7 +171,7 @@ func TestNewDutyDeadlineFunc(t *testing.T) {
 	}
 }
 
-// sendDuties runs a goroutine which adds the duties to the deadliner channel.
+// addDuties runs a goroutine which adds the duties to the deadliner channel.
 func addDuties(t *testing.T, wg *sync.WaitGroup, duties []core.Duty, expCh chan bool, deadliner core.Deadliner) {
 	t.Helper()
 
diff --git a/core/eth2signeddata_test.go b/core/eth2signeddata_test.go
index d9343981..2edb965a 100644
--- a/core/eth2signeddata_test.go
+++ b/core/eth2signeddata_test.go
@@ -82,7 +82,7 @@ func TestVerifyEth2SignedData(t *testing.T) {
 
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
-			bmock, err := beaconmock.New()
+			bmock, err := beaconmock.New(t.Context())
 			require.NoError(t, err)
 
 			epoch, err := test.data.Epoch(context.Background(), bmock)
diff --git a/core/fetcher/fetcher_test.go b/core/fetcher/fetcher_test.go
index 5c4ae39c..34a82a68 100644
--- a/core/fetcher/fetcher_test.go
+++ b/core/fetcher/fetcher_test.go
@@ -22,7 +22,6 @@ import (
 	"github.com/obolnetwork/charon/app/eth2wrap/mocks"
 	"github.com/obolnetwork/charon/core"
 	"github.com/obolnetwork/charon/core/fetcher"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
 	"github.com/obolnetwork/charon/testutil"
 	"github.com/obolnetwork/charon/testutil/beaconmock"
 )
@@ -63,7 +62,7 @@ func TestFetchAttester(t *testing.T) {
 		pubkeysByIdx[vIdxB]: core.NewAttesterDefinition(&dutyB),
 	}
 	duty := core.NewAttesterDuty(slot)
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	fetch := mustCreateFetcher(t, bmock)
@@ -139,7 +138,7 @@ func TestFetchAggregator(t *testing.T) {
 		pubkeysByIdx[vIdxB]: testutil.RandomCoreBeaconCommitteeSelection(),
 	}
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	var aggAttCallCount int
@@ -296,7 +295,7 @@ func TestFetchBlocks(t *testing.T) {
 	)
 	require.NoError(t, err)
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	t.Run("fetch DutyProposer", func(t *testing.T) {
@@ -385,14 +384,14 @@ func TestFetchSyncContribution(t *testing.T) {
 		sigB = "99e60f20dde4d4872b048d703f1943071c20213d504012e7e520c229da87661803b9f139b9a0c5be31de3cef6821c080125aed38ebaf51ba9a2e9d21d7fbf2903577983109d097a8599610a92c0305408d97c1fd4b0b2d1743fb4eedf5443f99"
 	)
 	// Construct sync committee selections.
-	selectionA := &eth2exp.SyncCommitteeSelection{
+	selectionA := &eth2v1.SyncCommitteeSelection{
 		ValidatorIndex:    vIdxA,
 		Slot:              slot,
 		SubcommitteeIndex: subCommIdxA,
 		SelectionProof:    blsSigFromHex(t, sigA),
 	}
 
-	selectionB := &eth2exp.SyncCommitteeSelection{
+	selectionB := &eth2v1.SyncCommitteeSelection{
 		ValidatorIndex:    vIdxB,
 		Slot:              slot,
 		SubcommitteeIndex: subCommIdxB,
@@ -414,7 +413,7 @@ func TestFetchSyncContribution(t *testing.T) {
 	}
 
 	t.Run("contribution aggregator", func(t *testing.T) {
-		bmock, err := beaconmock.New()
+		bmock, err := beaconmock.New(t.Context())
 		require.NoError(t, err)
 
 		bmock.SyncCommitteeContributionFunc = func(ctx context.Context, resSlot eth2p0.Slot, subcommitteeIndex uint64, beaconBlockRoot eth2p0.Root) (*altair.SyncCommitteeContribution, error) {
@@ -439,7 +438,7 @@ func TestFetchSyncContribution(t *testing.T) {
 				s, ok := selection.(core.SyncCommitteeSelection)
 				require.True(t, ok)
 
-				if s.SubcommitteeIndex == eth2p0.CommitteeIndex(subcommitteeIndex) {
+				if s.SubcommitteeIndex == subcommitteeIndex {
 					signedSelection = s
 				}
 			}
@@ -489,12 +488,12 @@ func TestFetchSyncContribution(t *testing.T) {
 				}
 
 				require.Equal(t, eth2p0.Slot(slot), selection.Slot)
-				require.Equal(t, eth2p0.CommitteeIndex(contribution.SubcommitteeIndex), selection.SubcommitteeIndex)
+				require.Equal(t, contribution.SubcommitteeIndex, selection.SubcommitteeIndex)
 
 				if selection.ValidatorIndex == eth2p0.ValidatorIndex(vIdxA) {
-					require.Equal(t, eth2p0.CommitteeIndex(subCommIdxA), selection.SubcommitteeIndex)
+					require.Equal(t, uint64(subCommIdxA), selection.SubcommitteeIndex)
 				} else if selection.ValidatorIndex == eth2p0.ValidatorIndex(vIdxB) {
-					require.Equal(t, eth2p0.CommitteeIndex(subCommIdxB), selection.SubcommitteeIndex)
+					require.Equal(t, uint64(subCommIdxB), selection.SubcommitteeIndex)
 				}
 
 				message, ok := syncMsgsByPubkey[pubkey].(core.SignedSyncMessage)
@@ -510,7 +509,7 @@ func TestFetchSyncContribution(t *testing.T) {
 	})
 
 	t.Run("not contribution aggregator", func(t *testing.T) {
-		bmock, err := beaconmock.New()
+		bmock, err := beaconmock.New(t.Context())
 		require.NoError(t, err)
 
 		fetch := mustCreateFetcher(t, bmock)
@@ -521,7 +520,7 @@ func TestFetchSyncContribution(t *testing.T) {
 				// Signature corresponding to a non-aggregator. Refer https://github.com/prysmaticlabs/prysm/blob/39a7988e9edbed5b517229b4d66c2a8aab7c7b4d/beacon-chain/sync/validate_sync_contribution_proof_test.go#L336
 				sig := "b9251a82040d4620b8c5665f328ee6c2eaa02d31d71d153f4abba31a7922a981e541e85283f0ced387d26e86aef9386d18c6982b9b5f8759882fe7f25a328180d86e146994ef19d28bc1432baf29751dec12b5f3d65dbbe224d72cf900c6831a"
 				resp := blsSigFromHex(t, sig)
-				selection := &eth2exp.SyncCommitteeSelection{
+				selection := &eth2v1.SyncCommitteeSelection{
 					SelectionProof: resp,
 				}
 
@@ -536,7 +535,7 @@ func TestFetchSyncContribution(t *testing.T) {
 	})
 
 	t.Run("fetch contribution data error", func(t *testing.T) {
-		bmock, err := beaconmock.New()
+		bmock, err := beaconmock.New(t.Context())
 		require.NoError(t, err)
 
 		fetch := mustCreateFetcher(t, bmock)
diff --git a/core/gater_test.go b/core/gater_test.go
index 88a0b87b..a597c44f 100644
--- a/core/gater_test.go
+++ b/core/gater_test.go
@@ -20,6 +20,7 @@ func TestDutyGater(t *testing.T) {
 	// Allow slots 0-3.
 	slotDuration := time.Second
 	bmock, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithGenesisTime(now),
 		beaconmock.WithSlotDuration(slotDuration),
 		beaconmock.WithSlotsPerEpoch(2),
diff --git a/core/parsigdb/memory_internal_test.go b/core/parsigdb/memory_internal_test.go
index 97dc7d6c..1e4c0a8b 100644
--- a/core/parsigdb/memory_internal_test.go
+++ b/core/parsigdb/memory_internal_test.go
@@ -6,13 +6,13 @@ import (
 	"context"
 	"testing"
 
+	eth2v1 "github.com/attestantio/go-eth2-client/api/v1"
 	"github.com/attestantio/go-eth2-client/spec/altair"
 	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
 	"github.com/stretchr/testify/require"
 
 	"github.com/obolnetwork/charon/cluster"
 	"github.com/obolnetwork/charon/core"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
 	"github.com/obolnetwork/charon/testutil"
 )
 
@@ -73,7 +73,7 @@ func TestGetThresholdMatching(t *testing.T) {
 				},
 				"Selection": func(i int) core.ParSignedData {
 					// Message is constant
-					msg := &eth2exp.BeaconCommitteeSelection{
+					msg := &eth2v1.BeaconCommitteeSelection{
 						ValidatorIndex: valIdx,
 						Slot:           eth2p0.Slot(test.input[i]), // Vary slot based on input
 						SelectionProof: testutil.RandomEth2Signature(),
diff --git a/core/parsigex/parsigex_test.go b/core/parsigex/parsigex_test.go
index 5feb7a7e..034966f6 100644
--- a/core/parsigex/parsigex_test.go
+++ b/core/parsigex/parsigex_test.go
@@ -127,7 +127,7 @@ func TestParSigExVerifier(t *testing.T) {
 		shareIdx = 1
 	)
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	slotsPerEpoch, err := bmock.SlotsPerEpoch(ctx)
@@ -314,7 +314,7 @@ func TestParSigExVerifier(t *testing.T) {
 
 		data := &altair.SyncAggregatorSelectionData{
 			Slot:              selection.Slot,
-			SubcommitteeIndex: uint64(selection.SubcommitteeIndex),
+			SubcommitteeIndex: selection.SubcommitteeIndex,
 		}
 		sigRoot, err := data.HashTreeRoot()
 		require.NoError(t, err)
diff --git a/core/scheduler/scheduler_internal_test.go b/core/scheduler/scheduler_internal_test.go
index 496a006c..c47c174a 100644
--- a/core/scheduler/scheduler_internal_test.go
+++ b/core/scheduler/scheduler_internal_test.go
@@ -27,6 +27,7 @@ func setupScheduler(t *testing.T) (*Scheduler, validators) {
 
 	// Configure beacon mock.
 	eth2Cl, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithValidatorSet(valSet),
 		beaconmock.WithGenesisTime(t0),
 		beaconmock.WithDeterministicAttesterDuties(0),
diff --git a/core/scheduler/scheduler_test.go b/core/scheduler/scheduler_test.go
index 98c258a0..f02bb3e0 100644
--- a/core/scheduler/scheduler_test.go
+++ b/core/scheduler/scheduler_test.go
@@ -120,7 +120,7 @@ func TestSchedulerWait(t *testing.T) {
 			var t0 time.Time
 
 			clock := newTestClock(t0)
-			eth2Cl, err := beaconmock.New()
+			eth2Cl, err := beaconmock.New(t.Context())
 			require.NoError(t, err)
 
 			eth2Cl.GenesisFunc = func(context.Context, *eth2api.GenesisOpts) (*eth2v1.Genesis, error) {
@@ -196,6 +196,7 @@ func TestSchedulerDuties(t *testing.T) {
 
 			valSet := beaconmock.ValidatorSetA
 			eth2Cl, err := beaconmock.New(
+				t.Context(),
 				beaconmock.WithValidatorSet(valSet),
 				beaconmock.WithGenesisTime(t0),
 				beaconmock.WithDeterministicAttesterDuties(test.Factor),
@@ -309,6 +310,7 @@ func TestScheduler_GetDuty(t *testing.T) {
 
 	// Configure beacon mock.
 	eth2Cl, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithValidatorSet(valSet),
 		beaconmock.WithGenesisTime(t0),
 		beaconmock.WithDeterministicAttesterDuties(0),
@@ -402,6 +404,7 @@ func TestNoActive(t *testing.T) {
 
 	// Configure beacon mock.
 	eth2Cl, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithGenesisTime(t0),
 		beaconmock.WithSlotDuration(slotDuration),
 		beaconmock.WithSlotsPerEpoch(1),
@@ -432,6 +435,7 @@ func TestHandleChainReorgEvent(t *testing.T) {
 
 	// Configure beacon mock.
 	eth2Cl, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithValidatorSet(valSet),
 		beaconmock.WithGenesisTime(t0),
 		beaconmock.WithDeterministicAttesterDuties(1),
diff --git a/core/sigagg/sigagg_test.go b/core/sigagg/sigagg_test.go
index b0d34ec5..2cdb2b0f 100644
--- a/core/sigagg/sigagg_test.go
+++ b/core/sigagg/sigagg_test.go
@@ -35,7 +35,7 @@ func TestSigAgg(t *testing.T) {
 		peers     = 4
 	)
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	t.Run("invalid threshold", func(t *testing.T) {
@@ -82,7 +82,7 @@ func TestSigAgg_DutyAttester(t *testing.T) {
 	msgRoot, err := att.MessageRoot()
 	require.NoError(t, err)
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	epoch, err := att.Epoch(ctx, bmock)
@@ -171,7 +171,7 @@ func TestSigAgg_DutyRandao(t *testing.T) {
 		epoch     = 123
 	)
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	randao := core.NewSignedRandao(epoch, eth2p0.BLSSignature{})
@@ -248,7 +248,7 @@ func TestSigAgg_DutyExit(t *testing.T) {
 		epoch     = 123
 	)
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// Generate private shares
@@ -330,7 +330,7 @@ func TestSigAgg_DutyProposer(t *testing.T) {
 		peers     = 4
 	)
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// Generate private shares
@@ -469,7 +469,7 @@ func TestSigAgg_DutyBuilderProposer(t *testing.T) {
 		peers     = 4
 	)
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// Generate private shares
@@ -592,7 +592,7 @@ func TestSigAgg_DutyBuilderRegistration(t *testing.T) {
 		epoch     = 123
 	)
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// Generate private shares
diff --git a/core/tracker/inclusion_internal_test.go b/core/tracker/inclusion_internal_test.go
index 68d08ac8..0bec600c 100644
--- a/core/tracker/inclusion_internal_test.go
+++ b/core/tracker/inclusion_internal_test.go
@@ -8,6 +8,8 @@ import (
 	"slices"
 	"testing"
 
+	eth2api "github.com/attestantio/go-eth2-client/api"
+	eth2v1 "github.com/attestantio/go-eth2-client/api/v1"
 	eth2spec "github.com/attestantio/go-eth2-client/spec"
 	"github.com/attestantio/go-eth2-client/spec/electra"
 	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
@@ -17,7 +19,6 @@ import (
 	"github.com/obolnetwork/charon/app/eth2wrap"
 	"github.com/obolnetwork/charon/app/featureset"
 	"github.com/obolnetwork/charon/core"
-	"github.com/obolnetwork/charon/eth2util/statecomm"
 	"github.com/obolnetwork/charon/testutil"
 	"github.com/obolnetwork/charon/testutil/beaconmock"
 )
@@ -28,10 +29,10 @@ func TestDuplicateAttData(t *testing.T) {
 	featureset.EnableForT(t, featureset.AttestationInclusion)
 
 	tests := []struct {
-		name                      string
-		attData                   *eth2p0.AttestationData
-		attestationsFunc          func(*eth2p0.AttestationData, bitfield.Bitlist, bitfield.Bitlist, bitfield.Bitlist) []*eth2spec.VersionedAttestation
-		beaconStateCommitteesFunc func(*eth2p0.AttestationData) []*statecomm.StateCommittee
+		name                 string
+		attData              *eth2p0.AttestationData
+		attestationsFunc     func(*eth2p0.AttestationData, bitfield.Bitlist, bitfield.Bitlist, bitfield.Bitlist) []*eth2spec.VersionedAttestation
+		beaconCommitteesFunc func(*eth2p0.AttestationData) []*eth2v1.BeaconCommittee
 	}{
 		{
 			name:    "phase0",
@@ -43,8 +44,8 @@ func TestDuplicateAttData(t *testing.T) {
 					{Version: eth2spec.DataVersionPhase0, Phase0: &eth2p0.Attestation{AggregationBits: aggBits3, Data: attData}},
 				}
 			},
-			beaconStateCommitteesFunc: func(attData *eth2p0.AttestationData) []*statecomm.StateCommittee {
-				return []*statecomm.StateCommittee{
+			beaconCommitteesFunc: func(attData *eth2p0.AttestationData) []*eth2v1.BeaconCommittee {
+				return []*eth2v1.BeaconCommittee{
 					{Index: attData.Index, Slot: attData.Slot, Validators: []eth2p0.ValidatorIndex{0, 1}},
 				}
 			},
@@ -59,8 +60,8 @@ func TestDuplicateAttData(t *testing.T) {
 					{Version: eth2spec.DataVersionAltair, Altair: &eth2p0.Attestation{AggregationBits: aggBits3, Data: attData}},
 				}
 			},
-			beaconStateCommitteesFunc: func(attData *eth2p0.AttestationData) []*statecomm.StateCommittee {
-				return []*statecomm.StateCommittee{
+			beaconCommitteesFunc: func(attData *eth2p0.AttestationData) []*eth2v1.BeaconCommittee {
+				return []*eth2v1.BeaconCommittee{
 					{Index: attData.Index, Slot: attData.Slot, Validators: []eth2p0.ValidatorIndex{0, 1}},
 				}
 			},
@@ -75,8 +76,8 @@ func TestDuplicateAttData(t *testing.T) {
 					{Version: eth2spec.DataVersionBellatrix, Bellatrix: &eth2p0.Attestation{AggregationBits: aggBits3, Data: attData}},
 				}
 			},
-			beaconStateCommitteesFunc: func(attData *eth2p0.AttestationData) []*statecomm.StateCommittee {
-				return []*statecomm.StateCommittee{
+			beaconCommitteesFunc: func(attData *eth2p0.AttestationData) []*eth2v1.BeaconCommittee {
+				return []*eth2v1.BeaconCommittee{
 					{Index: attData.Index, Slot: attData.Slot, Validators: []eth2p0.ValidatorIndex{0, 1}},
 				}
 			},
@@ -91,8 +92,8 @@ func TestDuplicateAttData(t *testing.T) {
 					{Version: eth2spec.DataVersionCapella, Capella: &eth2p0.Attestation{AggregationBits: aggBits3, Data: attData}},
 				}
 			},
-			beaconStateCommitteesFunc: func(attData *eth2p0.AttestationData) []*statecomm.StateCommittee {
-				return []*statecomm.StateCommittee{
+			beaconCommitteesFunc: func(attData *eth2p0.AttestationData) []*eth2v1.BeaconCommittee {
+				return []*eth2v1.BeaconCommittee{
 					{Index: attData.Index, Slot: attData.Slot, Validators: []eth2p0.ValidatorIndex{0, 1}},
 				}
 			},
@@ -107,8 +108,8 @@ func TestDuplicateAttData(t *testing.T) {
 					{Version: eth2spec.DataVersionDeneb, Deneb: &eth2p0.Attestation{AggregationBits: aggBits3, Data: attData}},
 				}
 			},
-			beaconStateCommitteesFunc: func(attData *eth2p0.AttestationData) []*statecomm.StateCommittee {
-				return []*statecomm.StateCommittee{
+			beaconCommitteesFunc: func(attData *eth2p0.AttestationData) []*eth2v1.BeaconCommittee {
+				return []*eth2v1.BeaconCommittee{
 					{Index: attData.Index, Slot: attData.Slot, Validators: []eth2p0.ValidatorIndex{0, 1}},
 				}
 			},
@@ -130,8 +131,8 @@ func TestDuplicateAttData(t *testing.T) {
 					{Version: eth2spec.DataVersionElectra, Electra: &electra.Attestation{AggregationBits: aggBits3, Data: attData, CommitteeBits: twoComm}},
 				}
 			},
-			beaconStateCommitteesFunc: func(attData *eth2p0.AttestationData) []*statecomm.StateCommittee {
-				return []*statecomm.StateCommittee{
+			beaconCommitteesFunc: func(attData *eth2p0.AttestationData) []*eth2v1.BeaconCommittee {
+				return []*eth2v1.BeaconCommittee{
 					{Index: 0, Slot: attData.Slot, Validators: []eth2p0.ValidatorIndex{0, 1, 2}},
 					{Index: 1, Slot: attData.Slot, Validators: []eth2p0.ValidatorIndex{0, 1, 2}},
 					{Index: 2, Slot: attData.Slot, Validators: []eth2p0.ValidatorIndex{0, 1, 2}},
@@ -154,8 +155,8 @@ func TestDuplicateAttData(t *testing.T) {
 					{Version: eth2spec.DataVersionElectra, Electra: &electra.Attestation{AggregationBits: aggBits2, Data: attData, CommitteeBits: oneComm}},
 				}
 			},
-			beaconStateCommitteesFunc: func(attData *eth2p0.AttestationData) []*statecomm.StateCommittee {
-				return []*statecomm.StateCommittee{
+			beaconCommitteesFunc: func(attData *eth2p0.AttestationData) []*eth2v1.BeaconCommittee {
+				return []*eth2v1.BeaconCommittee{
 					{Index: 0, Slot: attData.Slot, Validators: []eth2p0.ValidatorIndex{0, 1, 2}},
 					{Index: 1, Slot: attData.Slot, Validators: []eth2p0.ValidatorIndex{0, 1, 2}},
 					{Index: 2, Slot: attData.Slot, Validators: []eth2p0.ValidatorIndex{0, 1, 2}},
@@ -166,7 +167,7 @@ func TestDuplicateAttData(t *testing.T) {
 
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
-			bmock, err := beaconmock.New()
+			bmock, err := beaconmock.New(t.Context())
 			require.NoError(t, err)
 
 			// Mock 3 attestations, with same data but different aggregation bits.
@@ -175,12 +176,12 @@ func TestDuplicateAttData(t *testing.T) {
 			aggBits2 := testutil.RandomBitList(8)
 			aggBits3 := testutil.RandomBitList(8)
 
-			bmock.BlockAttestationsFunc = func(_ context.Context, _ string) ([]*eth2spec.VersionedAttestation, error) {
+			bmock.BeaconBlockAttestationsFunc = func(_ context.Context, _ *eth2api.BeaconBlockAttestationsOpts) ([]*eth2spec.VersionedAttestation, error) {
 				return test.attestationsFunc(attData, aggBits1, aggBits2, aggBits3), nil
 			}
 
-			bmock.BeaconStateCommitteesFunc = func(_ context.Context, slot uint64) ([]*statecomm.StateCommittee, error) {
-				return test.beaconStateCommitteesFunc(attData), nil
+			bmock.BeaconCommitteesFunc = func(_ context.Context, opts *eth2api.BeaconCommitteesOpts) ([]*eth2v1.BeaconCommittee, error) {
+				return test.beaconCommitteesFunc(attData), nil
 			}
 
 			noopTrackerInclFunc := func(duty core.Duty, key core.PubKey, data core.SignedData, err error) {}
diff --git a/core/validatorapi/router_internal_test.go b/core/validatorapi/router_internal_test.go
index 744161ef..524419e3 100644
--- a/core/validatorapi/router_internal_test.go
+++ b/core/validatorapi/router_internal_test.go
@@ -40,7 +40,6 @@ import (
 
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/eth2wrap"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
 	"github.com/obolnetwork/charon/testutil"
 )
 
@@ -1392,23 +1391,23 @@ func TestRouter(t *testing.T) {
 	})
 
 	t.Run("aggregate sync committee selections", func(t *testing.T) {
-		selections := []*eth2exp.SyncCommitteeSelection{testutil.RandomSyncCommitteeSelection(), testutil.RandomSyncCommitteeSelection()}
+		selections := []*eth2v1.SyncCommitteeSelection{testutil.RandomSyncCommitteeSelection(), testutil.RandomSyncCommitteeSelection()}
 
 		handler := testHandler{
-			AggregateSyncCommitteeSelectionsFunc: func(ctx context.Context, partialSelections []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error) {
+			SyncCommitteeSelectionsFunc: func(ctx context.Context, opts *eth2api.SyncCommitteeSelectionsOpts) (*eth2api.Response[[]*eth2v1.SyncCommitteeSelection], error) {
 				for i := range selections {
-					require.Equal(t, selections[i], partialSelections[i])
+					require.Equal(t, selections[i], opts.Selections[i])
 				}
 
-				return partialSelections, nil
+				return wrapResponse(opts.Selections), nil
 			},
 		}
 
 		callback := func(ctx context.Context, cl *eth2http.Service) {
 			eth2Cl := eth2wrap.AdaptEth2HTTP(cl, nil, time.Second)
-			actual, err := eth2Cl.AggregateSyncCommitteeSelections(ctx, selections)
+			actual, err := eth2Cl.SyncCommitteeSelections(ctx, &eth2api.SyncCommitteeSelectionsOpts{Selections: selections})
 			require.NoError(t, err)
-			require.Equal(t, selections, actual)
+			require.Equal(t, selections, actual.Data)
 		}
 
 		testRouter(t, handler, callback)
@@ -1447,8 +1446,8 @@ func TestBeaconCommitteeSelections(t *testing.T) {
 	)
 
 	handler := testHandler{
-		AggregateBeaconCommitteeSelectionsFunc: func(ctx context.Context, selections []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error) {
-			return selections, nil
+		BeaconCommitteeSelectionsFunc: func(ctx context.Context, opts *eth2api.BeaconCommitteeSelectionsOpts) (*eth2api.Response[[]*eth2v1.BeaconCommitteeSelection], error) {
+			return wrapResponse(opts.Selections), nil
 		},
 	}
 
@@ -1469,7 +1468,7 @@ func TestBeaconCommitteeSelections(t *testing.T) {
 	)
 	require.NoError(t, err)
 
-	selections := []*eth2exp.BeaconCommitteeSelection{
+	selections := []*eth2v1.BeaconCommitteeSelection{
 		{
 			Slot:           slotA,
 			ValidatorIndex: vIdxA,
@@ -1488,9 +1487,9 @@ func TestBeaconCommitteeSelections(t *testing.T) {
 	}
 
 	eth2Cl := eth2wrap.AdaptEth2HTTP(eth2Svc.(*eth2http.Service), nil, time.Second)
-	actual, err := eth2Cl.AggregateBeaconCommitteeSelections(ctx, selections)
+	actual, err := eth2Cl.BeaconCommitteeSelections(ctx, &eth2api.BeaconCommitteeSelectionsOpts{Selections: selections})
 	require.NoError(t, err)
-	require.Equal(t, selections, actual)
+	require.Equal(t, selections, actual.Data)
 }
 
 func TestSubmitAggregateAttestations(t *testing.T) {
@@ -1990,26 +1989,26 @@ type testHandler struct {
 	Handler
 	eth2client.BeaconStateProvider
 
-	ProxyHandler                           http.HandlerFunc
-	AggregateSyncCommitteeSelectionsFunc   func(ctx context.Context, partialSelections []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error)
-	AttestationDataFunc                    func(ctx context.Context, opts *eth2api.AttestationDataOpts) (*eth2api.Response[*eth2p0.AttestationData], error)
-	AttesterDutiesFunc                     func(ctx context.Context, opts *eth2api.AttesterDutiesOpts) (*eth2api.Response[[]*eth2v1.AttesterDuty], error)
-	SubmitAttestationsFunc                 func(ctx context.Context, opts *eth2api.SubmitAttestationsOpts) error
-	ProposalFunc                           func(ctx context.Context, opts *eth2api.ProposalOpts) (*eth2api.Response[*eth2api.VersionedProposal], error)
-	SubmitProposalFunc                     func(ctx context.Context, proposal *eth2api.SubmitProposalOpts) error
-	SubmitBlindedProposalFunc              func(ctx context.Context, proposal *eth2api.SubmitBlindedProposalOpts) error
-	ProposerDutiesFunc                     func(ctx context.Context, opts *eth2api.ProposerDutiesOpts) (*eth2api.Response[[]*eth2v1.ProposerDuty], error)
-	NodeVersionFunc                        func(ctx context.Context, opts *eth2api.NodeVersionOpts) (*eth2api.Response[string], error)
-	ValidatorsFunc                         func(ctx context.Context, opts *eth2api.ValidatorsOpts) (*eth2api.Response[map[eth2p0.ValidatorIndex]*eth2v1.Validator], error)
-	BeaconStateFunc                        func(ctx context.Context, stateId string) (*eth2spec.VersionedBeaconState, error)
-	ValidatorsByPubKeyFunc                 func(ctx context.Context, stateID string, pubkeys []eth2p0.BLSPubKey) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error)
-	SubmitVoluntaryExitFunc                func(ctx context.Context, exit *eth2p0.SignedVoluntaryExit) error
-	SubmitValidatorRegistrationsFunc       func(ctx context.Context, registrations []*eth2api.VersionedSignedValidatorRegistration) error
-	AggregateBeaconCommitteeSelectionsFunc func(ctx context.Context, selections []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error)
-	SubmitAggregateAttestationsFunc        func(ctx context.Context, opts *eth2api.SubmitAggregateAttestationsOpts) error
-	SubmitSyncCommitteeMessagesFunc        func(ctx context.Context, messages []*altair.SyncCommitteeMessage) error
-	SyncCommitteeDutiesFunc                func(ctx context.Context, opts *eth2api.SyncCommitteeDutiesOpts) (*eth2api.Response[[]*eth2v1.SyncCommitteeDuty], error)
-	SyncCommitteeContributionFunc          func(ctx context.Context, opts *eth2api.SyncCommitteeContributionOpts) (*eth2api.Response[*altair.SyncCommitteeContribution], error)
+	ProxyHandler                     http.HandlerFunc
+	SyncCommitteeSelectionsFunc      func(ctx context.Context, opts *eth2api.SyncCommitteeSelectionsOpts) (*eth2api.Response[[]*eth2v1.SyncCommitteeSelection], error)
+	AttestationDataFunc              func(ctx context.Context, opts *eth2api.AttestationDataOpts) (*eth2api.Response[*eth2p0.AttestationData], error)
+	AttesterDutiesFunc               func(ctx context.Context, opts *eth2api.AttesterDutiesOpts) (*eth2api.Response[[]*eth2v1.AttesterDuty], error)
+	SubmitAttestationsFunc           func(ctx context.Context, opts *eth2api.SubmitAttestationsOpts) error
+	ProposalFunc                     func(ctx context.Context, opts *eth2api.ProposalOpts) (*eth2api.Response[*eth2api.VersionedProposal], error)
+	SubmitProposalFunc               func(ctx context.Context, proposal *eth2api.SubmitProposalOpts) error
+	SubmitBlindedProposalFunc        func(ctx context.Context, proposal *eth2api.SubmitBlindedProposalOpts) error
+	ProposerDutiesFunc               func(ctx context.Context, opts *eth2api.ProposerDutiesOpts) (*eth2api.Response[[]*eth2v1.ProposerDuty], error)
+	NodeVersionFunc                  func(ctx context.Context, opts *eth2api.NodeVersionOpts) (*eth2api.Response[string], error)
+	ValidatorsFunc                   func(ctx context.Context, opts *eth2api.ValidatorsOpts) (*eth2api.Response[map[eth2p0.ValidatorIndex]*eth2v1.Validator], error)
+	BeaconStateFunc                  func(ctx context.Context, stateId string) (*eth2spec.VersionedBeaconState, error)
+	ValidatorsByPubKeyFunc           func(ctx context.Context, stateID string, pubkeys []eth2p0.BLSPubKey) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error)
+	SubmitVoluntaryExitFunc          func(ctx context.Context, exit *eth2p0.SignedVoluntaryExit) error
+	SubmitValidatorRegistrationsFunc func(ctx context.Context, registrations []*eth2api.VersionedSignedValidatorRegistration) error
+	BeaconCommitteeSelectionsFunc    func(ctx context.Context, opts *eth2api.BeaconCommitteeSelectionsOpts) (*eth2api.Response[[]*eth2v1.BeaconCommitteeSelection], error)
+	SubmitAggregateAttestationsFunc  func(ctx context.Context, opts *eth2api.SubmitAggregateAttestationsOpts) error
+	SubmitSyncCommitteeMessagesFunc  func(ctx context.Context, messages []*altair.SyncCommitteeMessage) error
+	SyncCommitteeDutiesFunc          func(ctx context.Context, opts *eth2api.SyncCommitteeDutiesOpts) (*eth2api.Response[[]*eth2v1.SyncCommitteeDuty], error)
+	SyncCommitteeContributionFunc    func(ctx context.Context, opts *eth2api.SyncCommitteeContributionOpts) (*eth2api.Response[*altair.SyncCommitteeContribution], error)
 }
 
 func (h testHandler) AttestationData(ctx context.Context, opts *eth2api.AttestationDataOpts) (*eth2api.Response[*eth2p0.AttestationData], error) {
@@ -2068,8 +2067,8 @@ func (h testHandler) SubmitValidatorRegistrations(ctx context.Context, registrat
 	return h.SubmitValidatorRegistrationsFunc(ctx, registrations)
 }
 
-func (h testHandler) AggregateBeaconCommitteeSelections(ctx context.Context, selections []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error) {
-	return h.AggregateBeaconCommitteeSelectionsFunc(ctx, selections)
+func (h testHandler) BeaconCommitteeSelections(ctx context.Context, opts *eth2api.BeaconCommitteeSelectionsOpts) (*eth2api.Response[[]*eth2v1.BeaconCommitteeSelection], error) {
+	return h.BeaconCommitteeSelectionsFunc(ctx, opts)
 }
 
 func (h testHandler) SubmitAggregateAttestations(ctx context.Context, opts *eth2api.SubmitAggregateAttestationsOpts) error {
@@ -2088,8 +2087,8 @@ func (h testHandler) SyncCommitteeContribution(ctx context.Context, opts *eth2ap
 	return h.SyncCommitteeContributionFunc(ctx, opts)
 }
 
-func (h testHandler) AggregateSyncCommitteeSelections(ctx context.Context, partialSelections []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error) {
-	return h.AggregateSyncCommitteeSelectionsFunc(ctx, partialSelections)
+func (h testHandler) SyncCommitteeSelections(ctx context.Context, opts *eth2api.SyncCommitteeSelectionsOpts) (*eth2api.Response[[]*eth2v1.SyncCommitteeSelection], error) {
+	return h.SyncCommitteeSelectionsFunc(ctx, opts)
 }
 
 // newBeaconHandler returns a mock beacon node handler. It registers a few mock handlers required by the
diff --git a/core/validatorapi/validatorapi_test.go b/core/validatorapi/validatorapi_test.go
index fa268a29..4b7bcd86 100644
--- a/core/validatorapi/validatorapi_test.go
+++ b/core/validatorapi/validatorapi_test.go
@@ -7,7 +7,6 @@ import (
 	"fmt"
 	"maps"
 	"sort"
-	"strconv"
 	"sync"
 	"testing"
 	"time"
@@ -33,7 +32,6 @@ import (
 	"github.com/obolnetwork/charon/core"
 	"github.com/obolnetwork/charon/core/validatorapi"
 	"github.com/obolnetwork/charon/eth2util"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
 	"github.com/obolnetwork/charon/eth2util/signing"
 	"github.com/obolnetwork/charon/tbls"
 	"github.com/obolnetwork/charon/tbls/tblsconv"
@@ -145,7 +143,7 @@ func TestComponent_ValidSubmitAttestations(t *testing.T) {
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
 			ctx := t.Context()
-			eth2Cl, err := beaconmock.New()
+			eth2Cl, err := beaconmock.New(t.Context())
 			require.NoError(t, err)
 
 			vPKA := testutil.RandomCorePubKey(t)
@@ -217,7 +215,7 @@ func TestComponent_ValidSubmitAttestations(t *testing.T) {
 
 func TestComponent_InvalidSubmitAttestations(t *testing.T) {
 	ctx := context.Background()
-	eth2Cl, err := beaconmock.New()
+	eth2Cl, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	const (
@@ -287,6 +285,7 @@ func TestSubmitAttestations_Verify(t *testing.T) {
 
 	// Configure beacon mock
 	bmock, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithValidatorSet(beaconmock.ValidatorSet{vIdx: validator}),
 		beaconmock.WithDeterministicAttesterDuties(0), // All duties in first slot of epoch.
 	)
@@ -349,6 +348,7 @@ func TestSignAndVerify(t *testing.T) {
         	"epoch": "0"
       	}]}`
 	bmock, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithEndpoint("/eth/v1/config/fork_schedule", forkSchedule),
 		beaconmock.WithGenesisValidatorsRoot([32]byte{0x01, 0x02}))
 	require.NoError(t, err)
@@ -472,7 +472,7 @@ func padTo(b []byte, size int) []byte {
 
 func TestComponent_Proposal(t *testing.T) {
 	ctx := context.Background()
-	eth2Cl, err := beaconmock.New()
+	eth2Cl, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	const (
@@ -564,7 +564,7 @@ func TestComponent_SubmitProposalsWithWrongVCData(t *testing.T) {
 	allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{corePubKey: {shareIdx: pubkey}} // Maps self to self since not tbls
 
 	// Configure beacon mock
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// Construct the validator api component
@@ -781,7 +781,7 @@ func TestComponent_SubmitProposal(t *testing.T) {
 			allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{corePubKey: {shareIdx: pubkey}} // Maps self to self since not tbls
 
 			// Configure beacon mock
-			bmock, err := beaconmock.New()
+			bmock, err := beaconmock.New(t.Context())
 			require.NoError(t, err)
 
 			// Construct the validator api component
@@ -862,7 +862,7 @@ func TestComponent_SubmitProposal(t *testing.T) {
 // 	allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{corePubKey: {shareIdx: pubkey}} // Maps self to self since not tbls
 
 // 	// Configure beacon mock
-// 	bmock, err := beaconmock.New()
+// 	bmock, err := beaconmock.New(t.Context())
 // 	require.NoError(t, err)
 
 // Construct the validator api component
@@ -960,7 +960,7 @@ func TestComponent_SubmitProposalInvalidSignature(t *testing.T) {
 	allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{corePubKey: {shareIdx: pubkey}} // Maps self to self since not tbls
 
 	// Configure beacon mock
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// Construct the validator api component
@@ -1031,7 +1031,7 @@ func TestComponent_SubmitProposalInvalidBlock(t *testing.T) {
 	allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{pubkey: {shareIdx: tblsPubkey}} // Maps self to self since not tbls
 
 	// Configure beacon mock
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// Construct the validator api component
@@ -1262,7 +1262,7 @@ func TestComponent_SubmitBlindedProposal(t *testing.T) {
 			allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{corePubKey: {shareIdx: pubkey}} // Maps self to self since not tbls
 
 			// Configure beacon mock
-			bmock, err := beaconmock.New()
+			bmock, err := beaconmock.New(t.Context())
 			require.NoError(t, err)
 
 			// Construct the validator api component
@@ -1340,7 +1340,7 @@ func TestComponent_SubmitBlindedProposalInvalidSignature(t *testing.T) {
 	allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{corePubKey: {shareIdx: pubkey}} // Maps self to self since not tbls
 
 	// Configure beacon mock
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// Construct the validator api component
@@ -1414,7 +1414,7 @@ func TestComponent_SubmitBlindedProposalInvalidBlock(t *testing.T) {
 	allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{pubkey: {shareIdx: *(*tbls.PublicKey)(pkb)}} // Maps self to self since not tbls
 
 	// Configure beacon mock
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// Construct the validator api component
@@ -1545,7 +1545,7 @@ func TestComponent_SubmitVoluntaryExit(t *testing.T) {
 	require.NoError(t, err)
 
 	// Configure beacon mock
-	bmock, err := beaconmock.New(beaconmock.WithValidatorSet(beaconmock.ValidatorSetA))
+	bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(beaconmock.ValidatorSetA))
 	require.NoError(t, err)
 
 	// Construct the validator api component
@@ -1617,7 +1617,7 @@ func TestComponent_SubmitVoluntaryExitInvalidSignature(t *testing.T) {
 	require.NoError(t, err)
 
 	// Configure beacon mock
-	bmock, err := beaconmock.New(beaconmock.WithValidatorSet(beaconmock.ValidatorSetA))
+	bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(beaconmock.ValidatorSetA))
 	require.NoError(t, err)
 
 	// Construct the validator api component
@@ -1662,7 +1662,7 @@ func TestComponent_Duties(t *testing.T) {
 
 	allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{corePubKey: {shareIdx: pubshare}}
 	// Configure beacon mock
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	t.Run("proposer_duties", func(t *testing.T) {
@@ -1765,7 +1765,7 @@ func TestComponent_SubmitValidatorRegistration(t *testing.T) {
 	allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{corePubKey: {shareIdx: pubkey}} // Maps self to self since not tbls
 
 	// Configure beacon mock
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// Construct the validator api component
@@ -1848,7 +1848,7 @@ func TestComponent_SubmitValidatorRegistrationInvalidSignature(t *testing.T) {
 	allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{corePubKey: {shareIdx: pubkey}} // Maps self to self since not tbls
 
 	// Configure beacon mock
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// Construct the validator api component
@@ -1884,93 +1884,19 @@ func TestComponent_SubmitValidatorRegistrationInvalidSignature(t *testing.T) {
 	require.ErrorContains(t, err, "signature not verified")
 }
 
-func TestComponent_TekuProposerConfig(t *testing.T) {
-	ctx := context.Background()
-
-	const (
-		zeroAddr     = "0x0000000000000000000000000000000000000000"
-		feeRecipient = "0x123456"
-		shareIdx     = 1
-	)
-	// Create keys (just use normal keys, not split tbls)
-	secret, err := tbls.GenerateSecretKey()
-	require.NoError(t, err)
-
-	pubkey, err := tbls.SecretToPublicKey(secret)
-	require.NoError(t, err)
-
-	// Convert pubkey
-	corePubKey, err := core.PubKeyFromBytes(pubkey[:])
-	require.NoError(t, err)
-
-	allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{corePubKey: {shareIdx: pubkey}} // Maps self to self since not tbls
-
-	// Configure beacon mock
-	bmock, err := beaconmock.New()
-	require.NoError(t, err)
-
-	// Construct the validator api component
-	vapi, err := validatorapi.NewComponent(bmock, allPubSharesByKey, shareIdx, func(core.PubKey) string {
-		return feeRecipient
-	}, true, 30000000, nil)
-	require.NoError(t, err)
-
-	resp, err := vapi.ProposerConfig(ctx)
-	require.NoError(t, err)
-
-	pk, err := core.PubKeyFromBytes(pubkey[:])
-	require.NoError(t, err)
-
-	genesis, err := bmock.Genesis(ctx, &eth2api.GenesisOpts{})
-	require.NoError(t, err)
-
-	genesisTime := genesis.Data.GenesisTime
-	respSpec, err := bmock.Spec(ctx, &eth2api.SpecOpts{})
-	require.NoError(t, err)
-
-	slotDuration, ok := respSpec.Data["SECONDS_PER_SLOT"].(time.Duration)
-	require.True(t, ok)
-
-	eth2pk, err := pk.ToETH2()
-	require.NoError(t, err)
-
-	require.Equal(t, &eth2exp.ProposerConfigResponse{
-		Proposers: map[eth2p0.BLSPubKey]eth2exp.ProposerConfig{
-			eth2pk: {
-				FeeRecipient: feeRecipient,
-				Builder: eth2exp.Builder{
-					Enabled:  true,
-					GasLimit: 30000000,
-					Overrides: map[string]string{
-						"timestamp":  strconv.FormatInt(genesisTime.Add(slotDuration).Unix(), 10),
-						"public_key": string(pk),
-					},
-				},
-			},
-		},
-		Default: eth2exp.ProposerConfig{
-			FeeRecipient: zeroAddr,
-			Builder: eth2exp.Builder{
-				Enabled:  false,
-				GasLimit: 30000000,
-			},
-		},
-	}, resp)
-}
-
 func TestComponent_AggregateBeaconCommitteeSelections(t *testing.T) {
 	ctx := context.Background()
 
 	const slot = 99
 
 	valSet := beaconmock.ValidatorSetA
-	eth2Cl, err := beaconmock.New(beaconmock.WithValidatorSet(valSet))
+	eth2Cl, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(valSet))
 	require.NoError(t, err)
 
 	vapi, err := validatorapi.NewComponentInsecure(t, eth2Cl, 0)
 	require.NoError(t, err)
 
-	selections := []*eth2exp.BeaconCommitteeSelection{
+	selections := []*eth2v1.BeaconCommitteeSelection{
 		{
 			ValidatorIndex: valSet[1].Index,
 			Slot:           slot,
@@ -2003,9 +1929,11 @@ func TestComponent_AggregateBeaconCommitteeSelections(t *testing.T) {
 		return nil, errors.New("unknown public key")
 	})
 
-	actual, err := vapi.AggregateBeaconCommitteeSelections(ctx, selections)
+	eth2Resp, err := vapi.BeaconCommitteeSelections(ctx, &eth2api.BeaconCommitteeSelectionsOpts{Selections: selections})
 	require.NoError(t, err)
 
+	actual := eth2Resp.Data
+
 	// Sort by VIdx before comparing
 	sort.Slice(actual, func(i, j int) bool {
 		return actual[i].ValidatorIndex < actual[j].ValidatorIndex
@@ -2035,7 +1963,7 @@ func TestComponent_SubmitAggregateAttestations(t *testing.T) {
 
 	pubkey := beaconmock.ValidatorSetA[vIdx].Validator.PublicKey
 
-	bmock, err := beaconmock.New(beaconmock.WithValidatorSet(beaconmock.ValidatorSetA))
+	bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(beaconmock.ValidatorSetA))
 	require.NoError(t, err)
 
 	vapi, err := validatorapi.NewComponentInsecure(t, bmock, 0)
@@ -2069,7 +1997,7 @@ func TestComponent_SubmitSyncCommitteeMessages(t *testing.T) {
 
 	msg.ValidatorIndex = vIdx
 
-	bmock, err := beaconmock.New(beaconmock.WithValidatorSet(beaconmock.ValidatorSetA))
+	bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(beaconmock.ValidatorSetA))
 	require.NoError(t, err)
 
 	vapi, err := validatorapi.NewComponentInsecure(t, bmock, 0)
@@ -2107,7 +2035,7 @@ func TestComponent_SubmitSyncCommitteeContributions(t *testing.T) {
 
 	contrib.Message.AggregatorIndex = vIdx
 
-	bmock, err := beaconmock.New(beaconmock.WithValidatorSet(beaconmock.ValidatorSetA))
+	bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(beaconmock.ValidatorSetA))
 	require.NoError(t, err)
 
 	vapi, err := validatorapi.NewComponentInsecure(t, bmock, 0)
@@ -2139,7 +2067,7 @@ func TestComponent_SubmitSyncCommitteeContributionsVerify(t *testing.T) {
 		ctx        = context.Background()
 		val        = testutil.RandomValidator(t)
 		slot       = eth2p0.Slot(50)
-		subcommIdx = eth2p0.CommitteeIndex(1)
+		subcommIdx = uint64(1)
 	)
 
 	// Create keys (just use normal keys, not split tbls).
@@ -2156,7 +2084,7 @@ func TestComponent_SubmitSyncCommitteeContributionsVerify(t *testing.T) {
 
 	val.Validator.PublicKey = eth2p0.BLSPubKey(pubkey)
 
-	bmock, err := beaconmock.New(beaconmock.WithValidatorSet(beaconmock.ValidatorSet{val.Index: val}))
+	bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(beaconmock.ValidatorSet{val.Index: val}))
 	require.NoError(t, err)
 
 	// Create contribution and proof.
@@ -2165,7 +2093,7 @@ func TestComponent_SubmitSyncCommitteeContributionsVerify(t *testing.T) {
 		Contribution:    testutil.RandomSyncCommitteeContribution(),
 	}
 	contribAndProof.Contribution.Slot = slot
-	contribAndProof.Contribution.SubcommitteeIndex = uint64(subcommIdx)
+	contribAndProof.Contribution.SubcommitteeIndex = subcommIdx
 	contribAndProof.SelectionProof = syncCommSelectionProof(t, bmock, secret, slot, subcommIdx)
 
 	signedContribAndProof := &altair.SignedContributionAndProof{
@@ -2211,7 +2139,7 @@ func TestComponent_ValidatorCache(t *testing.T) {
 		valByPubkey[val.Validator.PublicKey] = val
 	}
 
-	bmock, err := beaconmock.New(beaconmock.WithValidatorSet(baseValSet))
+	bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(baseValSet))
 	require.NoError(t, err)
 
 	bmock.CachedValidatorsFunc = func(ctx context.Context) (eth2wrap.ActiveValidators, eth2wrap.CompleteValidators, error) {
@@ -2326,7 +2254,7 @@ func TestComponent_GetAllValidators(t *testing.T) {
 		}
 	}
 
-	bmock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet))
+	bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(validatorSet))
 	require.NoError(t, err)
 
 	// Construct validatorapi component.
@@ -2365,7 +2293,7 @@ func TestComponent_GetClusterValidatorsWithError(t *testing.T) {
 		indices = append(indices, vidx)
 	}
 
-	bmock, err := beaconmock.New(beaconmock.WithValidatorSet(validatorSet))
+	bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(validatorSet))
 	require.NoError(t, err)
 
 	// Construct validatorapi component.
@@ -2426,13 +2354,13 @@ func TestComponent_AggregateSyncCommitteeSelectionsVerify(t *testing.T) {
 	selection2.Slot = slot
 
 	// Construct beaconmock.
-	bmock, err := beaconmock.New(beaconmock.WithValidatorSet(valSet))
+	bmock, err := beaconmock.New(t.Context(), beaconmock.WithValidatorSet(valSet))
 	require.NoError(t, err)
 
 	selection1.SelectionProof = syncCommSelectionProof(t, bmock, secret1, slot, selection1.SubcommitteeIndex)
 	selection2.SelectionProof = syncCommSelectionProof(t, bmock, secret2, slot, selection2.SubcommitteeIndex)
 
-	selections := []*eth2exp.SyncCommitteeSelection{selection1, selection2}
+	selections := []*eth2v1.SyncCommitteeSelection{selection1, selection2}
 
 	// Populate all pubshares map.
 	corePubKey1, err := core.PubKeyFromBytes(pubkey1[:])
@@ -2485,9 +2413,11 @@ func TestComponent_AggregateSyncCommitteeSelectionsVerify(t *testing.T) {
 		return nil
 	})
 
-	got, err := vapi.AggregateSyncCommitteeSelections(ctx, selections)
+	eth2Resp, err := vapi.SyncCommitteeSelections(ctx, &eth2api.SyncCommitteeSelectionsOpts{Selections: selections})
 	require.NoError(t, err)
 
+	got := eth2Resp.Data
+
 	// Sort by VIdx before comparing.
 	sort.Slice(got, func(i, j int) bool {
 		return got[i].ValidatorIndex < got[j].ValidatorIndex
@@ -2498,7 +2428,7 @@ func TestComponent_AggregateSyncCommitteeSelectionsVerify(t *testing.T) {
 
 // syncCommSelectionProof returns the selection_proof corresponding to the provided altair.ContributionAndProof.
 // Refer get_sync_committee_selection_proof from https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/validator.md#aggregation-selection.
-func syncCommSelectionProof(t *testing.T, eth2Cl eth2wrap.Client, secret tbls.PrivateKey, slot eth2p0.Slot, subcommIdx eth2p0.CommitteeIndex) eth2p0.BLSSignature {
+func syncCommSelectionProof(t *testing.T, eth2Cl eth2wrap.Client, secret tbls.PrivateKey, slot eth2p0.Slot, subcommIdx uint64) eth2p0.BLSSignature {
 	t.Helper()
 
 	epoch, err := eth2util.EpochFromSlot(context.Background(), eth2Cl, slot)
@@ -2506,7 +2436,7 @@ func syncCommSelectionProof(t *testing.T, eth2Cl eth2wrap.Client, secret tbls.Pr
 
 	data := altair.SyncAggregatorSelectionData{
 		Slot:              slot,
-		SubcommitteeIndex: uint64(subcommIdx),
+		SubcommitteeIndex: subcommIdx,
 	}
 
 	sigRoot, err := data.HashTreeRoot()
@@ -2623,7 +2553,7 @@ func TestSlotFromTimestamp(t *testing.T) {
 			require.NoError(t, err)
 
 			ctx := context.Background()
-			eth2Cl, err := beaconmock.New(beaconmock.WithGenesisTime(genesis))
+			eth2Cl, err := beaconmock.New(t.Context(), beaconmock.WithGenesisTime(genesis))
 			require.NoError(t, err)
 
 			got, err := validatorapi.SlotFromTimestamp(ctx, eth2Cl, tt.timestamp)
diff --git a/dkg/dkg_test.go b/dkg/dkg_test.go
index 03b8f355..1d5f1fdc 100644
--- a/dkg/dkg_test.go
+++ b/dkg/dkg_test.go
@@ -57,13 +57,7 @@ const (
 )
 
 func isAnyVersion(version string, list ...string) bool {
-	for _, v := range list {
-		if version == v {
-			return true
-		}
-	}
-
-	return false
+	return slices.Contains(list, version)
 }
 
 func TestDKG(t *testing.T) {
@@ -674,7 +668,7 @@ func peerCtx(ctx context.Context, idx int) context.Context {
 
 func getConfigs(t *testing.T, def cluster.Definition, keys []*k1.PrivateKey, dir, bootnode string) []dkg.Config {
 	t.Helper()
-	tcpNodeCallback := testutil.NewTCPNodeCallback(t, dkgsync.Protocols()...)
+	p2pNodeCallback := testutil.NewP2PNodeCallback(t, dkgsync.Protocols()...)
 
 	var configs []dkg.Config
 	for i := range len(def.Operators) {
@@ -690,7 +684,7 @@ func getConfigs(t *testing.T, def cluster.Definition, keys []*k1.PrivateKey, dir
 				StoreKeysFunc: func(secrets []tbls.PrivateKey, dir string) error {
 					return keystore.StoreKeysInsecure(secrets, dir, keystore.ConfirmInsecureKeys)
 				},
-				TCPNodeCallback: tcpNodeCallback,
+				P2PNodeCallback: p2pNodeCallback,
 			},
 			Timeout: 8 * time.Second,
 		}
diff --git a/dkg/sync/server_internal_test.go b/dkg/sync/server_internal_test.go
index 19857fb5..9ee40118 100644
--- a/dkg/sync/server_internal_test.go
+++ b/dkg/sync/server_internal_test.go
@@ -18,7 +18,6 @@ func TestUpdateStep(t *testing.T) {
 
 	server := &Server{
 		defHash:   testutil.RandomBytes32(),
-		tcpNode:   nil,
 		allCount:  1,
 		shutdown:  make(map[peer.ID]struct{}),
 		connected: make(map[peer.ID]struct{}),
diff --git a/eth2util/eth2exp/attagg_test.go b/eth2util/eth2exp/attagg_test.go
index e2d4960b..748c1686 100644
--- a/eth2util/eth2exp/attagg_test.go
+++ b/eth2util/eth2exp/attagg_test.go
@@ -18,7 +18,7 @@ import (
 func TestIsAttAggregator(t *testing.T) {
 	ctx := context.Background()
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// https://github.com/prysmaticlabs/prysm/blob/8627fe72e80009ae162430140bcfff6f209d7a32/beacon-chain/core/helpers/attestation_test.go#L28
@@ -47,7 +47,7 @@ func TestIsAttAggregator(t *testing.T) {
 func TestIsSyncCommAggregator(t *testing.T) {
 	ctx := context.Background()
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// The non-aggregator tests (isAgg: false) are taken from https://github.com/prysmaticlabs/prysm/blob/39a7988e9edbed5b517229b4d66c2a8aab7c7b4d/beacon-chain/sync/validate_sync_contribution_proof_test.go#L336.
diff --git a/eth2util/registration/registration_test.go b/eth2util/registration/registration_test.go
index 4d4f2776..6fa54d11 100644
--- a/eth2util/registration/registration_test.go
+++ b/eth2util/registration/registration_test.go
@@ -136,7 +136,7 @@ func TestVerifySignedRegistration(t *testing.T) {
 	})
 	require.NoError(t, err)
 
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	epoch, err := signedRegistration.Epoch(ctx, bmock)
diff --git a/eth2util/signing/signing_test.go b/eth2util/signing/signing_test.go
index 62a6e306..01a499f8 100644
--- a/eth2util/signing/signing_test.go
+++ b/eth2util/signing/signing_test.go
@@ -22,7 +22,7 @@ import (
 )
 
 func TestVerifyRegistrationReference(t *testing.T) {
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	// Test data obtained from teku.
@@ -89,7 +89,7 @@ func TestConstantApplicationBuilder(t *testing.T) {
 	getDomain := func(t *testing.T, i int) eth2p0.Domain {
 		t.Helper()
 
-		eth2Cl, err := beaconmock.New()
+		eth2Cl, err := beaconmock.New(t.Context())
 		require.NoError(t, err)
 
 		eth2Cl.ForkScheduleFunc = func(ctx context.Context, opts *eth2api.ForkScheduleOpts) ([]*eth2p0.Fork, error) {
diff --git a/p2p/config_internal_test.go b/p2p/config_internal_test.go
index 2aa46657..fd1149c8 100644
--- a/p2p/config_internal_test.go
+++ b/p2p/config_internal_test.go
@@ -18,7 +18,7 @@ func TestResolveListenAddr(t *testing.T) {
 	}{
 		{
 			input: ":1234",
-			err:   `p2p bind IP not specified`,
+			err:   `p2p bind TCP IP not specified`,
 		},
 		{
 			input: "10.4.3.3:1234",
@@ -28,7 +28,7 @@ func TestResolveListenAddr(t *testing.T) {
 	}
 	for _, test := range tests {
 		t.Run(test.input, func(t *testing.T) {
-			addr, err := resolveListenAddr(test.input)
+			addr, err := resolveListenTCPAddr(test.input)
 			if test.err != "" {
 				if err != nil {
 					require.Error(t, err)
@@ -50,18 +50,31 @@ func TestConfig_Multiaddrs(t *testing.T) {
 			"10.0.0.2:0",
 			"[" + net.IPv6linklocalallnodes.String() + "]:0",
 		},
+		UDPAddrs: []string{
+			"10.0.0.2:0",
+			"[" + net.IPv6linklocalallnodes.String() + "]:0",
+		},
 	}
 
-	maddrs, err := c.Multiaddrs()
+	tcpMaddrs, err := c.TCPMultiaddrs()
 	require.NoError(t, err)
 
-	maddrStrs := make([]string, len(maddrs))
-	for i, ma := range maddrs {
+	udpMaddrs, err := c.UDPMultiaddrs()
+	require.NoError(t, err)
+
+	maddrStrs := make([]string, (len(tcpMaddrs) + len(udpMaddrs)))
+	for i, ma := range tcpMaddrs {
 		maddrStrs[i] = ma.String()
 	}
 
+	for i, ma := range udpMaddrs {
+		maddrStrs[i+len(tcpMaddrs)] = ma.String()
+	}
+
 	require.Equal(t, []string{
 		"/ip4/10.0.0.2/tcp/0",
 		"/ip6/ff02::1/tcp/0",
+		"/ip4/10.0.0.2/udp/0/quic-v1",
+		"/ip6/ff02::1/udp/0/quic-v1",
 	}, maddrStrs)
 }
diff --git a/p2p/errors_internal_test.go b/p2p/errors_internal_test.go
index 15744517..8edf514c 100644
--- a/p2p/errors_internal_test.go
+++ b/p2p/errors_internal_test.go
@@ -29,6 +29,7 @@ func TestNamedAddr(t *testing.T) {
 		"/ip4/127.0.0.1/tcp/1234",
 		"/ip4/127.0.0.1/tcp/1234/",
 		"/ip4/127.0.0.1/udp/1234/quic",
+		"/ip4/127.0.0.1/udp/1234/quic-v1", // Manually added
 		"/ip4/127.0.0.1/udp/1234/quic/webtransport",
 		"/ip4/127.0.0.1/udp/1234/quic/webtransport/certhash/b2uaraocy6yrdblb4sfptaddgimjmmpy",
 		"/ip4/127.0.0.1/udp/1234/quic/webtransport/certhash/b2uaraocy6yrdblb4sfptaddgimjmmpy/certhash/zQmbWTwYGcmdyK9CYfNBcfs9nhZs17a6FQ4Y8oea278xx41",
diff --git a/p2p/p2p_internal_test.go b/p2p/p2p_internal_test.go
index 3a000a49..fada285f 100644
--- a/p2p/p2p_internal_test.go
+++ b/p2p/p2p_internal_test.go
@@ -82,3 +82,52 @@ func TestFilterAdvertisedAddrs(t *testing.T) {
 		})
 	}
 }
+
+func TestAddrProtocol(t *testing.T) {
+	tests := []struct {
+		name     string
+		addr     string
+		expected string
+	}{
+		{
+			name:     "tcp address",
+			addr:     "/ip4/127.0.0.1/tcp/8080",
+			expected: "tcp",
+		},
+		{
+			name:     "quic address",
+			addr:     "/ip4/127.0.0.1/udp/8080/quic",
+			expected: "quic",
+		},
+		{
+			name:     "quic-v1 address",
+			addr:     "/ip4/127.0.0.1/udp/8080/quic-v1",
+			expected: "quic",
+		},
+		{
+			name:     "relay over tcp",
+			addr:     "/ip4/172.16.0.7/tcp/8080/p2p/16Uiu2HAm1bSDxrCubda6Esz3NkXamvzEjQh4jzMp1PdckJwwMcuw/p2p-circuit",
+			expected: "tcp",
+		},
+		{
+			name:     "relay over quic",
+			addr:     "/ip4/172.16.0.7/udp/8080/quic-v1/p2p/16Uiu2HAm1bSDxrCubda6Esz3NkXamvzEjQh4jzMp1PdckJwwMcuw/p2p-circuit",
+			expected: "quic",
+		},
+		{
+			name:     "unknown protocol",
+			addr:     "/ip4/127.0.0.1",
+			expected: "unknown",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			addr, err := ma.NewMultiaddr(tt.addr)
+			require.NoError(t, err)
+
+			result := addrProtocol(addr)
+			require.Equal(t, tt.expected, result)
+		})
+	}
+}
diff --git a/p2p/peer_test.go b/p2p/peer_test.go
index 1846210a..e11ed65f 100644
--- a/p2p/peer_test.go
+++ b/p2p/peer_test.go
@@ -28,11 +28,19 @@ func TestNewPeer(t *testing.T) {
 	require.Equal(t, "16Uiu2HAkzdQ5Y9SYT91K1ue5SxXwgmajXntfScGnLYeip5hHyWmT", p.ID.String())
 }
 
-func TestNewHost(t *testing.T) {
+func TestNewTCPHost(t *testing.T) {
 	privKey, err := k1.GeneratePrivateKey()
 	require.NoError(t, err)
 
-	_, err = p2p.NewTCPNode(context.Background(), p2p.Config{}, privKey, p2p.NewOpenGater(), false)
+	_, err = p2p.NewNode(context.Background(), p2p.Config{}, privKey, p2p.NewOpenGater(), false, p2p.NodeTypeTCP)
+	require.NoError(t, err)
+}
+
+func TestNewQUICHost(t *testing.T) {
+	privKey, err := k1.GeneratePrivateKey()
+	require.NoError(t, err)
+
+	_, err = p2p.NewNode(context.Background(), p2p.Config{}, privKey, p2p.NewOpenGater(), false, p2p.NodeTypeQUIC)
 	require.NoError(t, err)
 }
 
diff --git a/p2p/receive_test.go b/p2p/receive_test.go
index cbf16f41..b17dd2f2 100644
--- a/p2p/receive_test.go
+++ b/p2p/receive_test.go
@@ -6,6 +6,7 @@ import (
 	"context"
 	"testing"
 
+	"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"
@@ -20,52 +21,56 @@ import (
 
 func TestSendReceive(t *testing.T) {
 	var (
-		pID    = protocol.ID("delimited")
-		ctx    = context.Background()
-		server = testutil.CreateHost(t, testutil.AvailableAddr(t))
-		client = testutil.CreateHost(t, testutil.AvailableAddr(t))
+		pID     = protocol.ID("delimited")
+		ctx     = context.Background()
+		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))}
 	)
 
-	client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL)
+	for i := range len(servers) {
+		client, server := clients[i], servers[i]
 
-	// Register the server handler that either:
-	//  - Errors if slot is negative
-	//  - Echos the duty request if slot is even
-	//  - Returns nothing is slot is odd
-	p2p.RegisterHandler("server", server, pID,
-		func() proto.Message { return new(pbv1.Duty) },
-		func(ctx context.Context, peerID peer.ID, req proto.Message) (proto.Message, bool, error) {
-			log.Info(ctx, "See protocol logging field")
+		client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL)
 
-			require.Equal(t, client.ID(), peerID)
+		// Register the server handler that either:
+		//  - Errors if slot is negative
+		//  - Echos the duty request if slot is even
+		//  - Returns nothing is slot is odd
+		p2p.RegisterHandler("server", server, pID,
+			func() proto.Message { return new(pbv1.Duty) },
+			func(ctx context.Context, peerID peer.ID, req proto.Message) (proto.Message, bool, error) {
+				log.Info(ctx, "See protocol logging field")
 
-			duty, ok := req.(*pbv1.Duty)
-			require.True(t, ok)
+				require.Equal(t, client.ID(), peerID)
 
-			if duty.GetSlot()%2 == 0 {
-				return duty, true, nil
-			} else {
-				return nil, false, nil
-			}
-		},
-	)
+				duty, ok := req.(*pbv1.Duty)
+				require.True(t, ok)
 
-	sendReceive := func(slot uint64) (*pbv1.Duty, error) {
-		resp := new(pbv1.Duty)
-		err := p2p.SendReceive(ctx, client, server.ID(), &pbv1.Duty{Slot: slot}, resp, pID)
+				if duty.GetSlot()%2 == 0 {
+					return duty, true, nil
+				} else {
+					return nil, false, nil
+				}
+			},
+		)
 
-		return resp, err
-	}
+		sendReceive := func(slot uint64) (*pbv1.Duty, error) {
+			resp := new(pbv1.Duty)
+			err := p2p.SendReceive(ctx, client, server.ID(), &pbv1.Duty{Slot: slot}, resp, pID)
 
-	t.Run("ok", func(t *testing.T) {
-		slot := uint64(100)
-		resp, err := sendReceive(slot)
-		require.NoError(t, err)
-		require.Equal(t, slot, resp.GetSlot())
-	})
+			return resp, err
+		}
 
-	t.Run("empty response", func(t *testing.T) {
-		_, err := sendReceive(101)
-		require.ErrorContains(t, err, "read response: EOF")
-	})
+		t.Run("ok", func(t *testing.T) {
+			slot := uint64(100)
+			resp, err := sendReceive(slot)
+			require.NoError(t, err)
+			require.Equal(t, slot, resp.GetSlot())
+		})
+
+		t.Run("empty response", func(t *testing.T) {
+			_, err := sendReceive(101)
+			require.ErrorContains(t, err, "read response: EOF")
+		})
+	}
 }
diff --git a/p2p/sender_test.go b/p2p/sender_test.go
index 8c81fadb..e72a3027 100644
--- a/p2p/sender_test.go
+++ b/p2p/sender_test.go
@@ -7,6 +7,7 @@ import (
 	"testing"
 	"time"
 
+	"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"
@@ -20,43 +21,52 @@ import (
 )
 
 func TestWithReceiveTimeout(t *testing.T) {
-	server := testutil.CreateHost(t, testutil.AvailableAddr(t))
-	client := testutil.CreateHost(t, testutil.AvailableAddr(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))}
 
-	client.Peerstore().AddAddrs(server.ID(), server.Addrs(), time.Hour)
+	for i := range len(servers) {
+		client, server := clients[i], servers[i]
 
-	protocolID := protocol.ID("testprotocol")
-	p2p.RegisterHandler("test", server, protocolID, func() proto.Message { return new(pbv1.Duty) },
-		func(ctx context.Context, peerID peer.ID, req proto.Message) (proto.Message, bool, error) {
-			require.Error(t, ctx.Err()) // Assert the context has been closed already since 0 timeout.
-			return nil, false, nil
-		}, p2p.WithReceiveTimeout(0))
+		client.Peerstore().AddAddrs(server.ID(), server.Addrs(), time.Hour)
 
-	err := p2p.SendReceive(context.Background(), client, server.ID(), new(pbv1.Duty), new(pbv1.Duty), protocolID)
-	require.Error(t, err)
-	require.ErrorContains(t, err, "read response: EOF")
+		protocolID := protocol.ID("testprotocol")
+		p2p.RegisterHandler("test", server, protocolID, func() proto.Message { return new(pbv1.Duty) },
+			func(ctx context.Context, peerID peer.ID, req proto.Message) (proto.Message, bool, error) {
+				require.Error(t, ctx.Err()) // Assert the context has been closed already since 0 timeout.
+				return nil, false, nil
+			}, p2p.WithReceiveTimeout(0))
+
+		err := p2p.SendReceive(context.Background(), client, server.ID(), new(pbv1.Duty), new(pbv1.Duty), protocolID)
+		require.Error(t, err)
+		require.ErrorContains(t, err, "read response: EOF")
+	}
 }
 
 func TestWithSendTimeout(t *testing.T) {
-	server := testutil.CreateHost(t, testutil.AvailableAddr(t))
-	client := testutil.CreateHost(t, testutil.AvailableAddr(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"}
 
-	client.Peerstore().AddAddrs(server.ID(), server.Addrs(), time.Hour)
+	for i := range len(servers) {
+		client, server, errorStr := clients[i], servers[i], errors[i]
 
-	protocolID := protocol.ID("testprotocol")
-	sendTimeout := time.Millisecond
+		client.Peerstore().AddAddrs(server.ID(), server.Addrs(), time.Hour)
 
-	p2p.RegisterHandler("test", server, protocolID, func() proto.Message { return new(pbv1.Duty) },
-		func(ctx context.Context, peerID peer.ID, req proto.Message) (proto.Message, bool, error) {
-			// The delay must be much greater than the send timeout to trigger the deadline error.
-			time.Sleep(10 * sendTimeout)
-			return nil, false, nil
-		})
+		protocolID := protocol.ID("testprotocol")
+		sendTimeout := time.Millisecond
+
+		p2p.RegisterHandler("test", server, protocolID, func() proto.Message { return new(pbv1.Duty) },
+			func(ctx context.Context, peerID peer.ID, req proto.Message) (proto.Message, bool, error) {
+				// The delay must be much greater than the send timeout to trigger the deadline error.
+				time.Sleep(10 * sendTimeout)
+				return nil, false, nil
+			})
 
-	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, "deadline reached")
+		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)
+	}
 }
 
 func TestSend(t *testing.T) {
@@ -147,9 +157,9 @@ func testSend(t *testing.T, clientBasicProtoID, serverBasicProtoID, delimitedID
 	t.Helper()
 
 	var (
-		ctx    = context.Background()
-		server = testutil.CreateHost(t, testutil.AvailableAddr(t))
-		client = testutil.CreateHost(t, testutil.AvailableAddr(t))
+		ctx     = context.Background()
+		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))}
 	)
 
 	var serverOpt []p2p.SendRecvOption
@@ -162,56 +172,60 @@ func testSend(t *testing.T, clientBasicProtoID, serverBasicProtoID, delimitedID
 		clientOpt = append(clientOpt, p2p.WithDelimitedProtocol(delimitedID))
 	}
 
-	client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL)
+	for i := range len(servers) {
+		client, server := clients[i], servers[i]
 
-	// Catch server errors.
-	serverErrChan := make(chan error)
+		client.Peerstore().AddAddrs(server.ID(), server.Addrs(), peerstore.PermanentAddrTTL)
 
-	// Register the server handler that either:
-	//  - Errors if slot is negative
-	//  - Returns nothing otherwise
-	p2p.RegisterHandler("server", server, serverBasicProtoID,
-		func() proto.Message { return new(pbv1.Duty) },
-		func(ctx context.Context, peerID peer.ID, req proto.Message) (proto.Message, bool, error) {
-			log.Info(ctx, "See protocol logging field")
+		// Catch server errors.
+		serverErrChan := make(chan error)
 
-			require.Equal(t, client.ID(), peerID)
+		// Register the server handler that either:
+		//  - Errors if slot is negative
+		//  - Returns nothing otherwise
+		p2p.RegisterHandler("server", server, serverBasicProtoID,
+			func() proto.Message { return new(pbv1.Duty) },
+			func(ctx context.Context, peerID peer.ID, req proto.Message) (proto.Message, bool, error) {
+				log.Info(ctx, "See protocol logging field")
 
-			var err error
+				require.Equal(t, client.ID(), peerID)
 
-			defer func() {
-				serverErrChan <- err
-			}()
+				var err error
 
-			return nil, false, err
-		},
-		serverOpt...,
-	)
+				defer func() {
+					serverErrChan <- err
+				}()
 
-	protocolNotSupported := func() bool {
-		// Client supports ONLY delimited protocol while Server supports ONLY non-delimited protocol.
-		if clientBasicProtoID == delimitedID && !delimitedServer {
-			return true
-		}
+				return nil, false, err
+			},
+			serverOpt...,
+		)
+
+		protocolNotSupported := func() bool {
+			// Client supports ONLY delimited protocol while Server supports ONLY non-delimited protocol.
+			if clientBasicProtoID == delimitedID && !delimitedServer {
+				return true
+			}
 
-		// Server supports ONLY delimited protocol while Client supports ONLY non-delimited protocol.
-		if serverBasicProtoID == delimitedID && !delimitedClient {
-			return true
+			// Server supports ONLY delimited protocol while Client supports ONLY non-delimited protocol.
+			if serverBasicProtoID == delimitedID && !delimitedClient {
+				return true
+			}
+
+			return false
 		}
 
-		return false
-	}
+		if protocolNotSupported() {
+			err := p2p.Send(ctx, client, clientBasicProtoID, server.ID(), &pbv1.Duty{Slot: 100}, clientOpt...)
+			require.ErrorContains(t, err, "protocols not supported")
 
-	if protocolNotSupported() {
-		err := p2p.Send(ctx, client, clientBasicProtoID, server.ID(), &pbv1.Duty{Slot: 100}, clientOpt...)
-		require.ErrorContains(t, err, "protocols not supported")
+			return
+		}
 
-		return
+		t.Run("ok", func(t *testing.T) {
+			err := p2p.Send(ctx, client, clientBasicProtoID, server.ID(), &pbv1.Duty{Slot: 100}, clientOpt...)
+			require.NoError(t, err)
+			require.NoError(t, <-serverErrChan)
+		})
 	}
-
-	t.Run("ok", func(t *testing.T) {
-		err := p2p.Send(ctx, client, clientBasicProtoID, server.ID(), &pbv1.Duty{Slot: 100}, clientOpt...)
-		require.NoError(t, err)
-		require.NoError(t, <-serverErrChan)
-	})
 }
diff --git a/p2p/testdata/TestNamedAddr.golden b/p2p/testdata/TestNamedAddr.golden
index ff3fa145..ac2d5a31 100644
--- a/p2p/testdata/TestNamedAddr.golden
+++ b/p2p/testdata/TestNamedAddr.golden
@@ -6,6 +6,7 @@
  "/ip4/127.0.0.1/tcp/1234",
  "/ip4/127.0.0.1/tcp/1234",
  "/ip4/127.0.0.1/udp/1234/quic",
+ "/ip4/127.0.0.1/udp/1234/quic-v1",
  "/ip4/127.0.0.1/udp/1234/quic/webtransport",
  "/ip4/127.0.0.1/udp/1234/quic/webtransport/certhash/u1QEQOFj2IjCsPJFfMAxmQxLGPw",
  "/ip4/127.0.0.1/udp/1234/quic/webtransport/certhash/u1QEQOFj2IjCsPJFfMAxmQxLGPw/certhash/uEiDDq4_xNyDorZBH3TlGazyJdOWSwvo4PUo5YHFMrvDE8g",
diff --git a/testutil/beaconmock/beaconmock.go b/testutil/beaconmock/beaconmock.go
index 3ebebe63..645240f5 100644
--- a/testutil/beaconmock/beaconmock.go
+++ b/testutil/beaconmock/beaconmock.go
@@ -38,8 +38,6 @@ import (
 
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/eth2wrap"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
-	"github.com/obolnetwork/charon/eth2util/statecomm"
 )
 
 // Interface assertions.
@@ -49,7 +47,7 @@ var (
 )
 
 // New returns a new beacon client mock configured with the default and provided options.
-func New(opts ...Option) (Mock, error) {
+func New(ctx context.Context, opts ...Option) (Mock, error) {
 	// Configure http mock first.
 	temp := defaultHTTPMock()
 	for _, opt := range opts {
@@ -58,7 +56,7 @@ func New(opts ...Option) (Mock, error) {
 
 	headProducer := newHeadProducer()
 
-	httpMock, httpServer, err := newHTTPMock(headProducer.Handlers(), temp.overrides...)
+	httpMock, httpServer, err := newHTTPMock(ctx, headProducer.Handlers(), temp.overrides...)
 	if err != nil {
 		return Mock{}, err
 	}
@@ -193,10 +191,10 @@ type Mock struct {
 	CachedValidatorsFunc                   func(ctx context.Context) (eth2wrap.ActiveValidators, eth2wrap.CompleteValidators, error)
 	AttestationDataFunc                    func(context.Context, eth2p0.Slot, eth2p0.CommitteeIndex) (*eth2p0.AttestationData, error)
 	AttesterDutiesFunc                     func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.AttesterDuty, error)
-	BlockAttestationsFunc                  func(ctx context.Context, stateID string) ([]*eth2spec.VersionedAttestation, error)
 	BlockFunc                              func(ctx context.Context, stateID string) (*eth2spec.VersionedSignedBeaconBlock, error)
-	BeaconStateCommitteesFunc              func(ctx context.Context, slot uint64) ([]*statecomm.StateCommittee, error)
-	NodePeerCountFunc                      func(ctx context.Context) (int, error)
+	BeaconBlockAttestationsFunc            func(context.Context, *eth2api.BeaconBlockAttestationsOpts) ([]*eth2spec.VersionedAttestation, error)
+	BeaconCommitteesFunc                   func(ctx context.Context, opts *eth2api.BeaconCommitteesOpts) ([]*eth2v1.BeaconCommittee, error)
+	NodePeerCountFunc                      func(ctx context.Context, opts *eth2api.NodePeerCountOpts) (*eth2v1.PeerCount, error)
 	ProposalFunc                           func(ctx context.Context, opts *eth2api.ProposalOpts) (*eth2api.VersionedProposal, error)
 	SignedBeaconBlockFunc                  func(ctx context.Context, blockID string) (*eth2spec.VersionedSignedBeaconBlock, error)
 	ProposerDutiesFunc                     func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error)
@@ -210,8 +208,8 @@ type Mock struct {
 	NodeSyncingFunc                        func(context.Context, *eth2api.NodeSyncingOpts) (*eth2v1.SyncState, error)
 	SubmitValidatorRegistrationsFunc       func(context.Context, []*eth2api.VersionedSignedValidatorRegistration) error
 	SlotsPerEpochFunc                      func(context.Context) (uint64, error)
-	AggregateBeaconCommitteeSelectionsFunc func(context.Context, []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error)
-	AggregateSyncCommitteeSelectionsFunc   func(context.Context, []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error)
+	BeaconCommitteeSelectionsFunc          func(context.Context, *eth2api.BeaconCommitteeSelectionsOpts) ([]*eth2v1.BeaconCommitteeSelection, error)
+	SyncCommitteeSelectionsFunc            func(context.Context, *eth2api.SyncCommitteeSelectionsOpts) ([]*eth2v1.SyncCommitteeSelection, error)
 	SubmitBeaconCommitteeSubscriptionsFunc func(ctx context.Context, subscriptions []*eth2v1.BeaconCommitteeSubscription) error
 	AggregateAttestationFunc               func(ctx context.Context, slot eth2p0.Slot, attestationDataRoot eth2p0.Root) (*eth2spec.VersionedAttestation, error)
 	SubmitAggregateAttestationsFunc        func(ctx context.Context, aggregateAndProofs *eth2api.SubmitAggregateAttestationsOpts) error
@@ -222,7 +220,6 @@ type Mock struct {
 	SubmitSyncCommitteeSubscriptionsFunc   func(ctx context.Context, subscriptions []*eth2v1.SyncCommitteeSubscription) error
 	SubmitProposalPreparationsFunc         func(ctx context.Context, preparations []*eth2v1.ProposalPreparation) error
 	ForkScheduleFunc                       func(context.Context, *eth2api.ForkScheduleOpts) ([]*eth2p0.Fork, error)
-	ProposerConfigFunc                     func(context.Context) (*eth2exp.ProposerConfigResponse, error)
 	NodeVersionFunc                        func(context.Context, *eth2api.NodeVersionOpts) (*eth2api.Response[string], error)
 }
 
@@ -356,20 +353,49 @@ func (m Mock) Genesis(ctx context.Context, opts *eth2api.GenesisOpts) (*eth2api.
 	return wrapResponse(genesis), nil
 }
 
-func (m Mock) BlockAttestations(ctx context.Context, stateID string) ([]*eth2spec.VersionedAttestation, error) {
-	return m.BlockAttestationsFunc(ctx, stateID)
+func (m Mock) BeaconBlockAttestations(ctx context.Context, opts *eth2api.BeaconBlockAttestationsOpts) (*eth2api.Response[[]*eth2spec.VersionedAttestation], error) {
+	atts, err := m.BeaconBlockAttestationsFunc(ctx, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return wrapResponse(atts), nil
+}
+
+func (m Mock) BeaconCommitteeSelections(ctx context.Context, opts *eth2api.BeaconCommitteeSelectionsOpts) (*eth2api.Response[[]*eth2v1.BeaconCommitteeSelection], error) {
+	selections, err := m.BeaconCommitteeSelectionsFunc(ctx, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return wrapResponse(selections), nil
 }
 
-func (m Mock) Block(ctx context.Context, stateID string) (*eth2spec.VersionedSignedBeaconBlock, error) {
-	return m.BlockFunc(ctx, stateID)
+func (m Mock) BeaconCommittees(ctx context.Context, opts *eth2api.BeaconCommitteesOpts) (*eth2api.Response[[]*eth2v1.BeaconCommittee], error) {
+	beaconCommittees, err := m.BeaconCommitteesFunc(ctx, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return wrapResponse(beaconCommittees), nil
 }
 
-func (m Mock) BeaconStateCommittees(ctx context.Context, slot uint64) ([]*statecomm.StateCommittee, error) {
-	return m.BeaconStateCommitteesFunc(ctx, slot)
+func (m Mock) SyncCommitteeSelections(ctx context.Context, opts *eth2api.SyncCommitteeSelectionsOpts) (*eth2api.Response[[]*eth2v1.SyncCommitteeSelection], error) {
+	selections, err := m.SyncCommitteeSelectionsFunc(ctx, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return wrapResponse(selections), nil
 }
 
-func (m Mock) NodePeerCount(ctx context.Context) (int, error) {
-	return m.NodePeerCountFunc(ctx)
+func (m Mock) NodePeerCount(ctx context.Context, opts *eth2api.NodePeerCountOpts) (*eth2api.Response[*eth2v1.PeerCount], error) {
+	peerCount, err := m.NodePeerCountFunc(ctx, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return wrapResponse(peerCount), nil
 }
 
 func (m Mock) SubmitAttestations(ctx context.Context, opts *eth2api.SubmitAttestationsOpts) error {
@@ -388,14 +414,6 @@ func (m Mock) SubmitValidatorRegistrations(ctx context.Context, registrations []
 	return m.SubmitValidatorRegistrationsFunc(ctx, registrations)
 }
 
-func (m Mock) AggregateBeaconCommitteeSelections(ctx context.Context, selections []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error) {
-	return m.AggregateBeaconCommitteeSelectionsFunc(ctx, selections)
-}
-
-func (m Mock) AggregateSyncCommitteeSelections(ctx context.Context, selections []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error) {
-	return m.AggregateSyncCommitteeSelectionsFunc(ctx, selections)
-}
-
 func (m Mock) SubmitBeaconCommitteeSubscriptions(ctx context.Context, subscriptions []*eth2v1.BeaconCommitteeSubscription) error {
 	return m.SubmitBeaconCommitteeSubscriptionsFunc(ctx, subscriptions)
 }
@@ -424,10 +442,6 @@ func (m Mock) SlotsPerEpoch(ctx context.Context) (uint64, error) {
 	return m.SlotsPerEpochFunc(ctx)
 }
 
-func (m Mock) ProposerConfig(ctx context.Context) (*eth2exp.ProposerConfigResponse, error) {
-	return m.ProposerConfigFunc(ctx)
-}
-
 func (m Mock) NodeVersion(ctx context.Context, opts *eth2api.NodeVersionOpts) (*eth2api.Response[string], error) {
 	return m.NodeVersionFunc(ctx, opts)
 }
diff --git a/testutil/beaconmock/beaconmock_fuzz.go b/testutil/beaconmock/beaconmock_fuzz.go
index db3fad20..cb4e53eb 100644
--- a/testutil/beaconmock/beaconmock_fuzz.go
+++ b/testutil/beaconmock/beaconmock_fuzz.go
@@ -132,11 +132,11 @@ func WithBeaconMockFuzzer() Option {
 			return vals, nil
 		}
 
-		mock.NodePeerCountFunc = func(context.Context) (int, error) {
-			var count int
+		mock.NodePeerCountFunc = func(context.Context, *eth2api.NodePeerCountOpts) (*eth2v1.PeerCount, error) {
+			var count uint64
 			fuzz.New().Fuzz(&count)
 
-			return count, nil
+			return &eth2v1.PeerCount{Connected: count}, nil
 		}
 
 		mock.ValidatorsByPubKeyFunc = func(_ context.Context, _ string, pubkeys []eth2p0.BLSPubKey) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error) {
@@ -179,7 +179,7 @@ func WithBeaconMockFuzzer() Option {
 			return contribution, nil
 		}
 
-		mock.BlockAttestationsFunc = func(context.Context, string) ([]*eth2spec.VersionedAttestation, error) {
+		mock.BeaconBlockAttestationsFunc = func(context.Context, *eth2api.BeaconBlockAttestationsOpts) ([]*eth2spec.VersionedAttestation, error) {
 			var atts []*eth2spec.VersionedAttestation
 			fuzz.New().Fuzz(&atts)
 
diff --git a/testutil/beaconmock/beaconmock_test.go b/testutil/beaconmock/beaconmock_test.go
index ff539ee7..8e1168ca 100644
--- a/testutil/beaconmock/beaconmock_test.go
+++ b/testutil/beaconmock/beaconmock_test.go
@@ -18,6 +18,7 @@ import (
 
 func TestDeterministicAttesterDuties(t *testing.T) {
 	bmock, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithValidatorSet(beaconmock.ValidatorSetA),
 		beaconmock.WithDeterministicAttesterDuties(1),
 	)
@@ -34,6 +35,7 @@ func TestDeterministicAttesterDuties(t *testing.T) {
 
 func TestDeterministicProposerDuties(t *testing.T) {
 	bmock, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithValidatorSet(beaconmock.ValidatorSetA),
 		beaconmock.WithDeterministicProposerDuties(1),
 	)
@@ -49,7 +51,7 @@ func TestDeterministicProposerDuties(t *testing.T) {
 }
 
 func TestAttestationStore(t *testing.T) {
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	ctx := context.Background()
diff --git a/testutil/beaconmock/headproducer_internal_test.go b/testutil/beaconmock/headproducer_internal_test.go
index 2ba55d41..e2773a31 100644
--- a/testutil/beaconmock/headproducer_internal_test.go
+++ b/testutil/beaconmock/headproducer_internal_test.go
@@ -19,7 +19,7 @@ import (
 )
 
 func TestHeadProducer(t *testing.T) {
-	bmock, err := New()
+	bmock, err := New(t.Context())
 	require.NoError(t, err)
 
 	defer bmock.Close()
diff --git a/testutil/beaconmock/options.go b/testutil/beaconmock/options.go
index d74edd24..ba85804c 100644
--- a/testutil/beaconmock/options.go
+++ b/testutil/beaconmock/options.go
@@ -28,7 +28,6 @@ import (
 	"github.com/obolnetwork/charon/app/eth2wrap"
 	"github.com/obolnetwork/charon/app/log"
 	"github.com/obolnetwork/charon/core"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
 	"github.com/obolnetwork/charon/testutil"
 )
 
@@ -555,14 +554,14 @@ func defaultMock(httpMock HTTPMock, httpServer *http.Server, clock clockwork.Clo
 		AttesterDutiesFunc: func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.AttesterDuty, error) {
 			return []*eth2v1.AttesterDuty{}, nil
 		},
-		BlockAttestationsFunc: func(context.Context, string) ([]*eth2spec.VersionedAttestation, error) {
+		BeaconBlockAttestationsFunc: func(context.Context, *eth2api.BeaconBlockAttestationsOpts) ([]*eth2spec.VersionedAttestation, error) {
 			return []*eth2spec.VersionedAttestation{}, nil
 		},
 		BlockFunc: func(context.Context, string) (*eth2spec.VersionedSignedBeaconBlock, error) {
 			return &eth2spec.VersionedSignedBeaconBlock{}, nil
 		},
-		NodePeerCountFunc: func(context.Context) (int, error) {
-			return 80, nil
+		NodePeerCountFunc: func(context.Context, *eth2api.NodePeerCountOpts) (*eth2v1.PeerCount, error) {
+			return &eth2v1.PeerCount{Connected: 80}, nil
 		},
 		AttestationDataFunc: func(ctx context.Context, slot eth2p0.Slot, index eth2p0.CommitteeIndex) (*eth2p0.AttestationData, error) {
 			return attStore.NewAttestationData(ctx, slot, index)
@@ -626,8 +625,8 @@ func defaultMock(httpMock HTTPMock, httpServer *http.Server, clock clockwork.Clo
 		SubmitValidatorRegistrationsFunc: func(context.Context, []*eth2api.VersionedSignedValidatorRegistration) error {
 			return nil
 		},
-		AggregateBeaconCommitteeSelectionsFunc: func(_ context.Context, selections []*eth2exp.BeaconCommitteeSelection) ([]*eth2exp.BeaconCommitteeSelection, error) {
-			return selections, nil
+		BeaconCommitteeSelectionsFunc: func(_ context.Context, opts *eth2api.BeaconCommitteeSelectionsOpts) ([]*eth2v1.BeaconCommitteeSelection, error) {
+			return opts.Selections, nil
 		},
 		SubmitAggregateAttestationsFunc: func(context.Context, *eth2api.SubmitAggregateAttestationsOpts) error {
 			return nil
@@ -650,8 +649,8 @@ func defaultMock(httpMock HTTPMock, httpServer *http.Server, clock clockwork.Clo
 		SyncCommitteeDutiesFunc: func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.SyncCommitteeDuty, error) {
 			return []*eth2v1.SyncCommitteeDuty{}, nil
 		},
-		AggregateSyncCommitteeSelectionsFunc: func(_ context.Context, selections []*eth2exp.SyncCommitteeSelection) ([]*eth2exp.SyncCommitteeSelection, error) {
-			return selections, nil
+		SyncCommitteeSelectionsFunc: func(_ context.Context, opts *eth2api.SyncCommitteeSelectionsOpts) ([]*eth2v1.SyncCommitteeSelection, error) {
+			return opts.Selections, nil
 		},
 		SubmitSyncCommitteeMessagesFunc: func(context.Context, []*altair.SyncCommitteeMessage) error {
 			return nil
@@ -682,9 +681,6 @@ func defaultMock(httpMock HTTPMock, httpServer *http.Server, clock clockwork.Clo
 
 			return eth2Resp.Data, nil
 		},
-		ProposerConfigFunc: func(context.Context) (*eth2exp.ProposerConfigResponse, error) {
-			return nil, nil
-		},
 		NodeVersionFunc: func(_ context.Context, _ *eth2api.NodeVersionOpts) (*eth2api.Response[string], error) {
 			return &eth2api.Response[string]{Data: "charon/static_beacon_mock"}, nil
 		},
diff --git a/testutil/beaconmock/server.go b/testutil/beaconmock/server.go
index d10962ab..a7a67f55 100644
--- a/testutil/beaconmock/server.go
+++ b/testutil/beaconmock/server.go
@@ -6,6 +6,7 @@ import (
 	"context"
 	_ "embed"
 	"encoding/json"
+	"maps"
 	"net"
 	"net/http"
 	"os"
@@ -75,7 +76,7 @@ func newHTTPServer(addr string, optionalHandlers map[string]http.HandlerFunc, ov
 			_, _ = w.Write([]byte(`{"data": {"version": "charon/static_beacon_mock"}}`))
 		},
 		"/eth/v1/node/syncing": func(w http.ResponseWriter, _ *http.Request) {
-			_, _ = w.Write([]byte(`{"data": {"head_slot": "1","sync_distance": "0","is_syncing": false}}`))
+			_, _ = w.Write([]byte(`{"data": {"head_slot": "1","sync_distance": "0","is_syncing": false,"is_optimistic": false,"el_offline": false}}`))
 		},
 		"/eth/v1/beacon/headers/head": func(w http.ResponseWriter, _ *http.Request) {
 			_, _ = w.Write([]byte(`{"data": {"header": {"message": {"slot": "1"}}}}`))
@@ -109,9 +110,7 @@ func newHTTPServer(addr string, optionalHandlers map[string]http.HandlerFunc, ov
 		},
 	}
 
-	for path, handler := range optionalHandlers {
-		endpoints[path] = handler
-	}
+	maps.Copy(endpoints, optionalHandlers)
 
 	r := mux.NewRouter()
 
@@ -175,8 +174,10 @@ func newHTTPServer(addr string, optionalHandlers map[string]http.HandlerFunc, ov
 }
 
 // newHTTPMock starts and returns a static beacon mock http server and client.
-func newHTTPMock(optionalHandlers map[string]http.HandlerFunc, overrides ...staticOverride) (HTTPMock, *http.Server, error) {
-	l, err := net.Listen("tcp", "localhost:0")
+func newHTTPMock(ctx context.Context, optionalHandlers map[string]http.HandlerFunc, overrides ...staticOverride) (HTTPMock, *http.Server, error) {
+	lc := net.ListenConfig{}
+
+	l, err := lc.Listen(ctx, "tcp", "localhost:0")
 	if err != nil {
 		return nil, nil, errors.Wrap(err, "listen")
 	}
diff --git a/testutil/beaconmock/server_test.go b/testutil/beaconmock/server_test.go
index 3c5a686a..8ddc5587 100644
--- a/testutil/beaconmock/server_test.go
+++ b/testutil/beaconmock/server_test.go
@@ -16,7 +16,7 @@ import (
 func TestStatic(t *testing.T) {
 	ctx := context.Background()
 
-	eth2Cl, err := beaconmock.New()
+	eth2Cl, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	genesisResp, err := eth2Cl.Genesis(ctx, &eth2api.GenesisOpts{})
@@ -50,7 +50,7 @@ func TestGenesisTimeOverride(t *testing.T) {
 	ctx := context.Background()
 
 	t0 := time.Now().Truncate(time.Second)
-	eth2Cl, err := beaconmock.New(beaconmock.WithGenesisTime(t0))
+	eth2Cl, err := beaconmock.New(t.Context(), beaconmock.WithGenesisTime(t0))
 	require.NoError(t, err)
 
 	genesisResp, err := eth2Cl.Genesis(ctx, &eth2api.GenesisOpts{})
@@ -62,7 +62,7 @@ func TestSlotsPerEpochOverride(t *testing.T) {
 	ctx := context.Background()
 
 	expect := 5
-	eth2Cl, err := beaconmock.New(beaconmock.WithSlotsPerEpoch(expect))
+	eth2Cl, err := beaconmock.New(t.Context(), beaconmock.WithSlotsPerEpoch(expect))
 	require.NoError(t, err)
 
 	actual, err := eth2Cl.SlotsPerEpoch(ctx)
@@ -78,7 +78,7 @@ func TestSlotsDurationOverride(t *testing.T) {
 	ctx := context.Background()
 
 	expect := time.Second
-	eth2Cl, err := beaconmock.New(beaconmock.WithSlotDuration(expect))
+	eth2Cl, err := beaconmock.New(t.Context(), beaconmock.WithSlotDuration(expect))
 	require.NoError(t, err)
 
 	eth2Resp, err := eth2Cl.Spec(ctx, &eth2api.SpecOpts{})
@@ -104,6 +104,7 @@ func TestEndpointOverride(t *testing.T) {
         	"epoch": "0"
       	}]}`
 	bmock, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithEndpoint("/eth/v1/config/fork_schedule", forkSchedule),
 	)
 	require.NoError(t, err)
@@ -117,7 +118,7 @@ func TestEndpointOverride(t *testing.T) {
 
 func TestDefaultOverrides(t *testing.T) {
 	ctx := context.Background()
-	bmock, err := beaconmock.New()
+	bmock, err := beaconmock.New(t.Context())
 	require.NoError(t, err)
 
 	resp, err := bmock.Spec(ctx, &eth2api.SpecOpts{})
diff --git a/testutil/compose/config.go b/testutil/compose/config.go
index 70b08e42..e1990b8d 100644
--- a/testutil/compose/config.go
+++ b/testutil/compose/config.go
@@ -144,7 +144,7 @@ func NewDefaultConfig() Config {
 		Threshold:               defaultThreshold,
 		NumValidators:           defaultNumVals,
 		ImageTag:                defaultImageTag,
-		VCs:                     []VCType{VCTeku, VCLighthouse, VCMock},
+		VCs:                     []VCType{VCLighthouse, VCLighthouse, VCMock},
 		KeyGen:                  defaultKeyGen,
 		BeaconNodes:             defaultBeaconNode,
 		Step:                    stepNew,
diff --git a/testutil/compose/smoke/smoke_test.go b/testutil/compose/smoke/smoke_test.go
index c25c86a9..d24b22cd 100644
--- a/testutil/compose/smoke/smoke_test.go
+++ b/testutil/compose/smoke/smoke_test.go
@@ -31,7 +31,7 @@ func TestSmoke(t *testing.T) {
 		t.Skip("Skipping smoke integration test")
 	}
 
-	const defaultTimeout = time.Second * 45
+	const defaultTimeout = time.Minute
 
 	tests := []struct {
 		Name           string
@@ -105,18 +105,18 @@ func TestSmoke(t *testing.T) {
 				pegImageTag(data.Nodes, 3, nth(version.Supported(), 1)+".0-rc1")
 			},
 		},
-		{
-			Name: "teku_versions",
-			ConfigFunc: func(conf *compose.Config) {
-				conf.VCs = []compose.VCType{compose.VCTeku}
-			},
-			RunTmplFunc: func(data *compose.TmplData) {
-				data.VCs[0].Image = "consensys/teku:latest"
-				data.VCs[1].Image = "consensys/teku:25.4.1"
-				data.VCs[2].Image = "consensys/teku:25.4.0"
-				data.VCs[3].Image = "consensys/teku:25.3.0"
-			},
-		},
+		// {
+		// 	Name: "teku_versions",
+		// 	ConfigFunc: func(conf *compose.Config) {
+		// 		conf.VCs = []compose.VCType{compose.VCTeku}
+		// 	},
+		// 	RunTmplFunc: func(data *compose.TmplData) {
+		// 		data.VCs[0].Image = "consensys/teku:latest"
+		// 		data.VCs[1].Image = "consensys/teku:25.4.1"
+		// 		data.VCs[2].Image = "consensys/teku:25.4.0"
+		// 		data.VCs[3].Image = "consensys/teku:25.3.0"
+		// 	},
+		// },
 		{
 			Name: "1_of_4_down",
 			RunTmplFunc: func(data *compose.TmplData) {
@@ -161,13 +161,13 @@ func TestSmoke(t *testing.T) {
 				conf.BuilderAPI = true
 			},
 		},
-		{
-			Name: "blinded_blocks_teku",
-			ConfigFunc: func(conf *compose.Config) {
-				conf.BuilderAPI = true
-				conf.VCs = []compose.VCType{compose.VCTeku}
-			},
-		},
+		// {
+		// 	Name: "blinded_blocks_teku",
+		// 	ConfigFunc: func(conf *compose.Config) {
+		// 		conf.BuilderAPI = true
+		// 		conf.VCs = []compose.VCType{compose.VCTeku}
+		// 	},
+		// },
 	}
 
 	for _, test := range tests {
diff --git a/testutil/compose/testdata/TestDockerCompose_define_create_yml.golden b/testutil/compose/testdata/TestDockerCompose_define_create_yml.golden
index acf22d99..feb6b363 100644
--- a/testutil/compose/testdata/TestDockerCompose_define_create_yml.golden
+++ b/testutil/compose/testdata/TestDockerCompose_define_create_yml.golden
@@ -9,6 +9,7 @@ x-node-base: &node-base
 services:
   node0:
     <<: *node-base
+    container_name: node0
     
 
   
diff --git a/testutil/compose/testdata/TestDockerCompose_define_dkg_yml.golden b/testutil/compose/testdata/TestDockerCompose_define_dkg_yml.golden
index 25294fff..75984835 100644
--- a/testutil/compose/testdata/TestDockerCompose_define_dkg_yml.golden
+++ b/testutil/compose/testdata/TestDockerCompose_define_dkg_yml.golden
@@ -8,6 +8,7 @@ x-node-base: &node-base
 services:
   node0:
     <<: *node-base
+    container_name: node0
     
     environment:
       CHARON_NAME: compose
diff --git a/testutil/compose/testdata/TestDockerCompose_lock_create_yml.golden b/testutil/compose/testdata/TestDockerCompose_lock_create_yml.golden
index 55e03bbd..f485f9f5 100644
--- a/testutil/compose/testdata/TestDockerCompose_lock_create_yml.golden
+++ b/testutil/compose/testdata/TestDockerCompose_lock_create_yml.golden
@@ -8,6 +8,7 @@ x-node-base: &node-base
 services:
   node0:
     <<: *node-base
+    container_name: node0
     
     environment:
       CHARON_NAME: compose-4-1
diff --git a/testutil/compose/testdata/TestDockerCompose_lock_dkg_yml.golden b/testutil/compose/testdata/TestDockerCompose_lock_dkg_yml.golden
index 8c3a62af..b22294e5 100644
--- a/testutil/compose/testdata/TestDockerCompose_lock_dkg_yml.golden
+++ b/testutil/compose/testdata/TestDockerCompose_lock_dkg_yml.golden
@@ -8,6 +8,7 @@ x-node-base: &node-base
 services:
   node0:
     <<: *node-base
+    container_name: node0
     entrypoint: sh
     command: [-c,'/usr/local/bin/charon dkg && sleep 2']
     
@@ -26,6 +27,7 @@ services:
     
   node1:
     <<: *node-base
+    container_name: node1
     entrypoint: sh
     command: [-c,'/usr/local/bin/charon dkg && sleep 2']
     
@@ -44,6 +46,7 @@ services:
     
   node2:
     <<: *node-base
+    container_name: node2
     entrypoint: sh
     command: [-c,'/usr/local/bin/charon dkg && sleep 2']
     
@@ -62,6 +65,7 @@ services:
     
   node3:
     <<: *node-base
+    container_name: node3
     entrypoint: sh
     command: [-c,'/usr/local/bin/charon dkg && sleep 2']
     
@@ -80,6 +84,7 @@ services:
     
   relay:
     <<: *node-base
+    container_name: relay
     command: relay
     depends_on: []
     environment:
diff --git a/testutil/compose/testdata/TestDockerCompose_run_template.golden b/testutil/compose/testdata/TestDockerCompose_run_template.golden
index e4142ba7..f94512d9 100644
--- a/testutil/compose/testdata/TestDockerCompose_run_template.golden
+++ b/testutil/compose/testdata/TestDockerCompose_run_template.golden
@@ -459,10 +459,10 @@
  ],
  "VCs": [
   {
-   "Label": "teku",
-   "Image": "consensys/teku:latest",
-   "Build": "",
-   "Command": "|\n      validator-client\n      --network=auto\n      --beacon-node-api-endpoint=\"http://node0:3600\"\n      --validator-keys=\"/compose/node0/validator_keys/keystore-0.json:/compose/node0/validator_keys/keystore-0.txt\"\n      --validator-keys=\"/compose/node0/validator_keys/keystore-1.json:/compose/node0/validator_keys/keystore-1.txt\"\n      --validators-proposer-default-fee-recipient=\"0x0000000000000000000000000000000000000000\"\n      --validators-proposer-blinded-blocks-enabled=false",
+   "Label": "lighthouse",
+   "Image": "",
+   "Build": "lighthouse",
+   "Command": "",
    "Ports": null
   },
   {
@@ -480,10 +480,10 @@
    "Ports": null
   },
   {
-   "Label": "teku",
-   "Image": "consensys/teku:latest",
-   "Build": "",
-   "Command": "|\n      validator-client\n      --network=auto\n      --beacon-node-api-endpoint=\"http://node3:3600\"\n      --validator-keys=\"/compose/node3/validator_keys/keystore-0.json:/compose/node3/validator_keys/keystore-0.txt\"\n      --validator-keys=\"/compose/node3/validator_keys/keystore-1.json:/compose/node3/validator_keys/keystore-1.txt\"\n      --validators-proposer-default-fee-recipient=\"0x0000000000000000000000000000000000000000\"\n      --validators-proposer-blinded-blocks-enabled=false",
+   "Label": "lighthouse",
+   "Image": "",
+   "Build": "lighthouse",
+   "Command": "",
    "Ports": null
   }
  ],
diff --git a/testutil/compose/testdata/TestDockerCompose_run_yml.golden b/testutil/compose/testdata/TestDockerCompose_run_yml.golden
index 9c9dc7dd..e4514036 100644
--- a/testutil/compose/testdata/TestDockerCompose_run_yml.golden
+++ b/testutil/compose/testdata/TestDockerCompose_run_yml.golden
@@ -8,6 +8,7 @@ x-node-base: &node-base
 services:
   node0:
     <<: *node-base
+    container_name: node0
     
     environment:
       CHARON_PRIVATE_KEY_FILE: /compose/node0/charon-enr-private-key
@@ -44,6 +45,7 @@ services:
       
   node1:
     <<: *node-base
+    container_name: node1
     
     environment:
       CHARON_PRIVATE_KEY_FILE: /compose/node1/charon-enr-private-key
@@ -80,6 +82,7 @@ services:
       
   node2:
     <<: *node-base
+    container_name: node2
     
     environment:
       CHARON_PRIVATE_KEY_FILE: /compose/node2/charon-enr-private-key
@@ -116,6 +119,7 @@ services:
       
   node3:
     <<: *node-base
+    container_name: node3
     
     environment:
       CHARON_PRIVATE_KEY_FILE: /compose/node3/charon-enr-private-key
@@ -152,6 +156,7 @@ services:
       
   relay:
     <<: *node-base
+    container_name: relay
     command: relay
     depends_on: []
     environment:
@@ -165,16 +170,9 @@ services:
       CHARON_P2P_ADVERTISE_PRIVATE_ADDRESSES: "true"
       CHARON_LOKI_ADDRESS: http://loki:3100/loki/api/v1/push
   
-  vc0-teku:
-    image: consensys/teku:latest
-    command: |
-      validator-client
-      --network=auto
-      --beacon-node-api-endpoint="http://node0:3600"
-      --validator-keys="/compose/node0/validator_keys/keystore-0.json:/compose/node0/validator_keys/keystore-0.txt"
-      --validator-keys="/compose/node0/validator_keys/keystore-1.json:/compose/node0/validator_keys/keystore-1.txt"
-      --validators-proposer-default-fee-recipient="0x0000000000000000000000000000000000000000"
-      --validators-proposer-blinded-blocks-enabled=false
+  vc0-lighthouse:
+    container_name: vc0-lighthouse
+    build: lighthouse
     networks: [compose]
     depends_on: [node0]
     environment:
@@ -183,6 +181,7 @@ services:
       - .:/compose
   
   vc1-lighthouse:
+    container_name: vc1-lighthouse
     build: lighthouse
     networks: [compose]
     depends_on: [node1]
@@ -191,16 +190,9 @@ services:
     volumes:
       - .:/compose
   
-  vc3-teku:
-    image: consensys/teku:latest
-    command: |
-      validator-client
-      --network=auto
-      --beacon-node-api-endpoint="http://node3:3600"
-      --validator-keys="/compose/node3/validator_keys/keystore-0.json:/compose/node3/validator_keys/keystore-0.txt"
-      --validator-keys="/compose/node3/validator_keys/keystore-1.json:/compose/node3/validator_keys/keystore-1.txt"
-      --validators-proposer-default-fee-recipient="0x0000000000000000000000000000000000000000"
-      --validators-proposer-blinded-blocks-enabled=false
+  vc3-lighthouse:
+    container_name: vc3-lighthouse
+    build: lighthouse
     networks: [compose]
     depends_on: [node3]
     environment:
@@ -209,12 +201,14 @@ services:
       - .:/compose
   
   curl:
+    container_name: curl
     # Can be used to curl services; e.g. docker compose exec curl curl http://prometheus:9090/api/v1/rules\?type\=alert
     image: curlimages/curl:latest
     command: sleep 1d
     networks: [compose]
 
   prometheus:
+    container_name: prometheus
     image: prom/prometheus:${PROMETHEUS_VERSION:-v2.50.1}
     ports:
       - "9090:9090"
@@ -226,6 +220,7 @@ services:
 
   
   grafana:
+    container_name: grafana
     image: grafana/grafana:${GRAFANA_VERSION:-10.4.2}
     ports:
       - "3000:3000"
@@ -240,6 +235,7 @@ services:
       - ./grafana/dash_alerts.json:/etc/dashboards/dash_alerts.json
 
   tempo:
+    container_name: tempo
     image: grafana/tempo:${TEMPO_VERSION:-2.7.1}
     networks: [compose]
     user: ":"
@@ -248,6 +244,7 @@ services:
       - ./tempo:/opt/tempo
 
   loki:
+    container_name: loki
     image: grafana/loki:${LOKI_VERSION:-2.8.2}
     networks: [compose]
     user: ":"
diff --git a/testutil/compose/testdata/TestNewDefaultConfig.golden b/testutil/compose/testdata/TestNewDefaultConfig.golden
index d6f9494c..a1783561 100644
--- a/testutil/compose/testdata/TestNewDefaultConfig.golden
+++ b/testutil/compose/testdata/TestNewDefaultConfig.golden
@@ -11,7 +11,7 @@
  "beacon_nodes": "mock",
  "external_relay": "",
  "validator_clients": [
-  "teku",
+  "lighthouse",
   "lighthouse",
   "mock"
  ],
diff --git a/testutil/genchangelog/main.go b/testutil/genchangelog/main.go
index 08b6a05c..8ed04810 100644
--- a/testutil/genchangelog/main.go
+++ b/testutil/genchangelog/main.go
@@ -112,6 +112,8 @@ type tplPR struct {
 }
 
 func main() {
+	ctx := context.Background()
+
 	flag.Parse()
 
 	token, ok := os.LookupEnv("GITHUB_TOKEN")
@@ -121,7 +123,7 @@ func main() {
 		fmt.Println("GITHUB_TOKEN env var not set, using unauthenticated API")
 	}
 
-	err := run(*rangeFlag, *outputFlag, token)
+	err := run(ctx, *rangeFlag, *outputFlag, token)
 	if err != nil {
 		applog.Error(context.Background(), "Run error", err)
 		os.Exit(1)
@@ -129,9 +131,9 @@ func main() {
 }
 
 // run runs the command.
-func run(gitRange string, output string, token string) error {
+func run(ctx context.Context, gitRange string, output string, token string) error {
 	if gitRange == "" {
-		tags, err := getLatestTags(2)
+		tags, err := getLatestTags(ctx, 2)
 		if err != nil {
 			return err
 		}
@@ -140,7 +142,7 @@ func run(gitRange string, output string, token string) error {
 		fmt.Printf("Flag --range empty, defaulting to %s\n", gitRange)
 	}
 
-	prs, err := parsePRs(gitRange)
+	prs, err := parsePRs(ctx, gitRange)
 	if err != nil {
 		return err
 	}
@@ -340,11 +342,11 @@ func selectCategory(current, candidate string) string {
 }
 
 // parsePRs returns parsed PRs by query the git logs for the provided range.
-func parsePRs(gitRange string) ([]pullRequest, error) {
+func parsePRs(ctx context.Context, gitRange string) ([]pullRequest, error) {
 	// Custom json encoding of git log output.
 	const format = `--pretty=format:{¬commit¬: ¬%h¬,¬body¬: ¬%b¬,¬subject¬: ¬%s¬,¬author¬: ¬%aE¬}†`
 
-	b, err := exec.Command("git", "log", format, gitRange).CombinedOutput()
+	b, err := exec.CommandContext(ctx, "git", "log", format, gitRange).CombinedOutput()
 	if err != nil {
 		return nil, errors.Wrap(err, "git log")
 	}
@@ -460,13 +462,13 @@ func getFirstMatch(r *regexp.Regexp, s string) (string, bool) {
 }
 
 // getLatestTags returns the latest N git tags.
-func getLatestTags(n int) ([]string, error) {
-	out, err := exec.Command("git", "fetch", "--tags", "-f").CombinedOutput()
+func getLatestTags(ctx context.Context, n int) ([]string, error) {
+	out, err := exec.CommandContext(ctx, "git", "fetch", "--tags", "-f").CombinedOutput()
 	if err != nil {
 		return nil, errors.Wrap(err, "git fetch", z.Str("out", string(out)))
 	}
 
-	out, err = exec.Command("git", "tag", "--sort=v:refname").CombinedOutput()
+	out, err = exec.CommandContext(ctx, "git", "tag", "--sort=v:refname").CombinedOutput()
 	if err != nil {
 		return nil, errors.Wrap(err, "git tag", z.Str("out", string(out)))
 	}
diff --git a/testutil/genchangelog/main_internal_test.go b/testutil/genchangelog/main_internal_test.go
index d003a62b..2de650b6 100644
--- a/testutil/genchangelog/main_internal_test.go
+++ b/testutil/genchangelog/main_internal_test.go
@@ -107,7 +107,7 @@ func TestLatestTags(t *testing.T) {
 		return
 	}
 
-	tags, err := getLatestTags(2)
+	tags, err := getLatestTags(t.Context(), 2)
 	require.NoError(t, err)
 	require.Len(t, tags, 2)
 	t.Log(tags)
@@ -122,7 +122,7 @@ func TestParsePRs(t *testing.T) {
 	}
 
 	gitRange := "v0.12.0..v0.13.0"
-	prs, err := parsePRs(gitRange)
+	prs, err := parsePRs(t.Context(), gitRange)
 	require.NoError(t, err)
 
 	data, err := tplDataFromPRs(prs, gitRange, func(i int) (string, string, error) {
diff --git a/testutil/helpers.go b/testutil/helpers.go
index 40efb9d8..2a78eeaf 100644
--- a/testutil/helpers.go
+++ b/testutil/helpers.go
@@ -13,30 +13,30 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-// NewTCPNodeCallback returns a callback that can be used to connect a TCP node to all other TCP nodes.
-func NewTCPNodeCallback(t *testing.T, protocols ...protocol.ID) func(host host.Host) {
+// NewP2PNodeCallback returns a callback that can be used to connect a P2P node to all other P2P nodes.
+func NewP2PNodeCallback(t *testing.T, protocols ...protocol.ID) func(host host.Host) {
 	t.Helper()
 
 	var (
-		tcpNodesLock sync.Mutex
-		tcpNodes     []host.Host
+		p2pNodesLock sync.Mutex
+		p2pNodes     []host.Host
 	)
 
-	return func(tcpNode host.Host) {
-		tcpNodesLock.Lock()
-		defer tcpNodesLock.Unlock()
+	return func(p2pNode host.Host) {
+		p2pNodesLock.Lock()
+		defer p2pNodesLock.Unlock()
 
-		for _, other := range tcpNodes {
-			other.Peerstore().AddAddrs(tcpNode.ID(), tcpNode.Addrs(), peerstore.PermanentAddrTTL)
-			err := other.Peerstore().AddProtocols(tcpNode.ID(), protocols...)
+		for _, other := range p2pNodes {
+			other.Peerstore().AddAddrs(p2pNode.ID(), p2pNode.Addrs(), peerstore.PermanentAddrTTL)
+			err := other.Peerstore().AddProtocols(p2pNode.ID(), protocols...)
 			require.NoError(t, err)
 
-			tcpNode.Peerstore().AddAddrs(other.ID(), other.Addrs(), peerstore.PermanentAddrTTL)
-			err = tcpNode.Peerstore().AddProtocols(other.ID(), protocols...)
+			p2pNode.Peerstore().AddAddrs(other.ID(), other.Addrs(), peerstore.PermanentAddrTTL)
+			err = p2pNode.Peerstore().AddProtocols(other.ID(), protocols...)
 			require.NoError(t, err)
 		}
 
-		tcpNodes = append(tcpNodes, tcpNode)
+		p2pNodes = append(p2pNodes, p2pNode)
 	}
 }
 
diff --git a/testutil/integration/infosync_test.go b/testutil/integration/infosync_test.go
index 095e431c..3d628f33 100644
--- a/testutil/integration/infosync_test.go
+++ b/testutil/integration/infosync_test.go
@@ -43,7 +43,7 @@ func TestInfoSync(t *testing.T) {
 		N:        n,
 	}
 
-	tcpNodeCallback := testutil.NewTCPNodeCallback(t, priority.Protocols()...)
+	p2pNodeCallback := testutil.NewP2PNodeCallback(t, priority.Protocols()...)
 
 	var eg errgroup.Group
 
@@ -58,7 +58,7 @@ func TestInfoSync(t *testing.T) {
 				PrioritiseCallback: asserter.Callback(t, i),
 				Lock:               &lock,
 				P2PKey:             p2pKeys[i],
-				TCPNodeCallback:    tcpNodeCallback,
+				P2PNodeCallback:    p2pNodeCallback,
 				SimnetBMockOpts: []beaconmock.Option{
 					beaconmock.WithNoAttesterDuties(),
 					beaconmock.WithNoProposerDuties(),
diff --git a/testutil/integration/ping_test.go b/testutil/integration/ping_test.go
index 1a9d3a33..62ddbb03 100644
--- a/testutil/integration/ping_test.go
+++ b/testutil/integration/ping_test.go
@@ -202,10 +202,10 @@ func (a *pingAsserter) Callback(t *testing.T, i int) func(peer.ID, host.Host) {
 	peerIDs, err := a.Lock.PeerIDs()
 	require.NoError(t, err)
 
-	return func(target peer.ID, tcpNode host.Host) {
+	return func(target peer.ID, p2pNode host.Host) {
 		var foundDirect bool
 
-		for _, conn := range tcpNode.Network().ConnsToPeer(target) {
+		for _, conn := range p2pNode.Network().ConnsToPeer(target) {
 			directConn := !p2p.IsRelayAddr(conn.RemoteMultiaddr())
 			if !directConn {
 				require.NoError(t, conn.Close()) // Close relay connections so direct connections are established.
diff --git a/testutil/obolapimock/obolapi_exit.go b/testutil/obolapimock/obolapi_exit.go
index 8e847dce..f2048e2c 100644
--- a/testutil/obolapimock/obolapi_exit.go
+++ b/testutil/obolapimock/obolapi_exit.go
@@ -8,6 +8,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"slices"
 	"sort"
 	"strconv"
 	"strings"
@@ -29,13 +30,16 @@ import (
 )
 
 const (
-	lockHashPath     = "{lock_hash}"
-	valPubkeyPath    = "{validator_pubkey}"
-	shareIndexPath   = "{share_index}"
-	fullExitBaseTmpl = "/exp/exit"
-	fullExitEndTmp   = "/" + lockHashPath + "/" + shareIndexPath + "/" + valPubkeyPath
+	lockHashPath   = "{lock_hash}"
+	valPubkeyPath  = "{validator_pubkey}"
+	shareIndexPath = "{share_index}"
 
-	partialExitTmpl = "/exp/partial_exits/" + lockHashPath
+	expPartialExits = "/exp/partial_exits"
+	expExit         = "/exp/exit"
+
+	submitPartialExitTmpl = "/" + lockHashPath
+	deletePartialExitTmpl = "/" + lockHashPath + "/" + shareIndexPath + "/" + valPubkeyPath
+	fetchFullExitTmpl     = "/" + lockHashPath + "/" + shareIndexPath + "/" + valPubkeyPath
 )
 
 type contextKey string
@@ -92,7 +96,7 @@ func (ts *testServer) addLockFiles(lock cluster.Lock) {
 	ts.lockFiles["0x"+hex.EncodeToString(lock.LockHash)] = lock
 }
 
-func (ts *testServer) HandlePartialExit(writer http.ResponseWriter, request *http.Request) {
+func (ts *testServer) HandleSubmitPartialExit(writer http.ResponseWriter, request *http.Request) {
 	ts.lock.Lock()
 	defer ts.lock.Unlock()
 
@@ -185,7 +189,7 @@ func (ts *testServer) HandlePartialExit(writer http.ResponseWriter, request *htt
 	writer.WriteHeader(http.StatusCreated)
 }
 
-func (ts *testServer) HandleFullExit(writer http.ResponseWriter, request *http.Request) {
+func (ts *testServer) HandleGetFullExit(writer http.ResponseWriter, request *http.Request) {
 	ts.lock.Lock()
 	defer ts.lock.Unlock()
 
@@ -282,6 +286,94 @@ func (ts *testServer) HandleFullExit(writer http.ResponseWriter, request *http.R
 	}
 }
 
+func (ts *testServer) HandleDeletePartialExit(writer http.ResponseWriter, request *http.Request) {
+	ts.lock.Lock()
+	defer ts.lock.Unlock()
+
+	authToken, ok := request.Context().Value(tokenContextKey).([]byte)
+	if !ok {
+		log.Error(request.Context(), "received context without token, that's impossible!", nil)
+		return
+	}
+
+	vars := mux.Vars(request)
+
+	valPubkey := vars[cleanTmpl(valPubkeyPath)]
+	lockHash := vars[cleanTmpl(lockHashPath)]
+	shareIndexStr := vars[cleanTmpl(shareIndexPath)]
+
+	shareIndex, err := strconv.ParseUint(shareIndexStr, 10, 64)
+	if err != nil {
+		writeErr(writer, http.StatusBadRequest, "malformed share index")
+		return
+	}
+
+	valPubkeyBytes, err := from0x(valPubkey, 48)
+	if err != nil {
+		writeErr(writer, http.StatusBadRequest, "invalid public key")
+		return
+	}
+
+	lockHashBytes, err := from0x(lockHash, 32)
+	if err != nil {
+		writeErr(writer, http.StatusBadRequest, "invalid lock hash")
+		return
+	}
+
+	lock, ok := ts.lockFiles[lockHash]
+	if !ok {
+		writeErr(writer, http.StatusNotFound, "lock not found")
+		return
+	}
+
+	partialExits, ok := ts.partialExits[valPubkey]
+	if !ok {
+		writeErr(writer, http.StatusNotFound, "validator not found")
+		return
+	}
+
+	// check that data has been signed with ShareIdx-th identity key
+	if shareIndex == 0 || shareIndex > uint64(len(lock.Operators)) {
+		writeErr(writer, http.StatusBadRequest, "invalid share index")
+		return
+	}
+
+	exitAuthData := obolapi.FullExitAuthBlob{
+		LockHash:        lockHashBytes,
+		ValidatorPubkey: valPubkeyBytes,
+		ShareIndex:      shareIndex,
+	}
+
+	exitAuthDataRoot, err := exitAuthData.HashTreeRoot()
+	if err != nil {
+		writeErr(writer, http.StatusInternalServerError, "cannot calculate exit auth data root")
+		return
+	}
+
+	if err := verifyIdentitySignature(lock.Operators[shareIndex-1], authToken, exitAuthDataRoot[:]); err != nil {
+		writeErr(writer, http.StatusBadRequest, "cannot verify signature: "+err.Error())
+		return
+	}
+
+	found := false
+
+	for idx, pExit := range partialExits {
+		if pExit.shareIdx == shareIndex {
+			partialExits = slices.Delete(partialExits, idx, idx+1)
+			found = true
+
+			break
+		}
+	}
+
+	if !found {
+		writeErr(writer, http.StatusNotFound, "share index not found for validator")
+		return
+	}
+
+	ts.partialExits[valPubkey] = partialExits
+}
+
 func (ts *testServer) partialExitsMatch(newOne obolapi.ExitBlob) bool {
 	// get the last one
 	exitsLen := len(ts.partialExits[newOne.PublicKey])
@@ -331,11 +423,15 @@ func MockServer(dropOnePsig bool, beacon eth2wrap.Client) (http.Handler, func(lo
 
 	router := mux.NewRouter()
 
-	full := router.PathPrefix(fullExitBaseTmpl).Subrouter()
-	full.Use(authMiddleware)
-	full.HandleFunc(fullExitEndTmp, ts.HandleFullExit).Methods(http.MethodGet)
+	getFull := router.PathPrefix(expExit).Subrouter()
+	getFull.Use(authMiddleware)
+	getFull.HandleFunc(fetchFullExitTmpl, ts.HandleGetFullExit).Methods(http.MethodGet)
+
+	deletePartial := router.PathPrefix(expPartialExits).Subrouter()
+	deletePartial.Use(authMiddleware)
+	deletePartial.HandleFunc(deletePartialExitTmpl, ts.HandleDeletePartialExit).Methods(http.MethodDelete)
 
-	router.HandleFunc(partialExitTmpl, ts.HandlePartialExit).Methods(http.MethodPost)
+	router.HandleFunc(expPartialExits+submitPartialExitTmpl, ts.HandleSubmitPartialExit).Methods(http.MethodPost)
 
 	return router, ts.addLockFiles
 }
diff --git a/testutil/random.go b/testutil/random.go
index f7970a0b..07b778c7 100644
--- a/testutil/random.go
+++ b/testutil/random.go
@@ -32,6 +32,7 @@ import (
 	"github.com/libp2p/go-libp2p"
 	p2pcrypto "github.com/libp2p/go-libp2p/core/crypto"
 	"github.com/libp2p/go-libp2p/core/host"
+	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/prysmaticlabs/go-bitfield"
@@ -40,7 +41,6 @@ import (
 	"github.com/obolnetwork/charon/core"
 	"github.com/obolnetwork/charon/eth2util"
 	"github.com/obolnetwork/charon/eth2util/enr"
-	"github.com/obolnetwork/charon/eth2util/eth2exp"
 	"github.com/obolnetwork/charon/tbls"
 )
 
@@ -847,8 +847,8 @@ func RandomVersionedSignedValidatorRegistration(t *testing.T) *eth2api.Versioned
 	}
 }
 
-func RandomBeaconCommitteeSelection() *eth2exp.BeaconCommitteeSelection {
-	return &eth2exp.BeaconCommitteeSelection{
+func RandomBeaconCommitteeSelection() *eth2v1.BeaconCommitteeSelection {
+	return &eth2v1.BeaconCommitteeSelection{
 		ValidatorIndex: RandomVIdx(),
 		Slot:           RandomSlot(),
 		SelectionProof: RandomEth2Signature(),
@@ -944,11 +944,11 @@ func RandomSyncCommittee(t *testing.T) *altair.SyncCommittee {
 	}
 }
 
-func RandomSyncCommitteeSelection() *eth2exp.SyncCommitteeSelection {
-	return &eth2exp.SyncCommitteeSelection{
+func RandomSyncCommitteeSelection() *eth2v1.SyncCommitteeSelection {
+	return &eth2v1.SyncCommitteeSelection{
 		ValidatorIndex:    RandomVIdx(),
 		Slot:              RandomSlot(),
-		SubcommitteeIndex: RandomCommIdx(),
+		SubcommitteeIndex: uint64(rand.Intn(4)),
 		SelectionProof:    RandomEth2Signature(),
 	}
 }
@@ -1262,7 +1262,7 @@ func RandomChecksummedETHAddress(t *testing.T, seed int) string {
 
 func RandomBytesAsString(length int) string {
 	b := make([]byte, length)
-	_, _ = NewSeedRand().Read(b)
+	_, _ = crand.Read(b)
 
 	return string(b)
 }
@@ -1397,7 +1397,8 @@ func SkipIfBindErr(t *testing.T, err error) {
 func AvailableAddr(t *testing.T) *net.TCPAddr {
 	t.Helper()
 
-	l, err := net.Listen("tcp", "localhost:0")
+	lc := net.ListenConfig{}
+	l, err := lc.Listen(t.Context(), "tcp", "localhost:0")
 	require.NoError(t, err)
 
 	defer l.Close()
@@ -1408,6 +1409,25 @@ func AvailableAddr(t *testing.T) *net.TCPAddr {
 	return addr
 }
 
+// AvailableUDPAddr returns an available local udp address.
+//
+// Note that this is unfortunately only best-effort. Since the port is not
+// "locked" or "reserved", other processes sometimes grab the port.
+// Remember to call SkipIfBindErr as workaround for this issue.
+func AvailableUDPAddr(t *testing.T) *net.UDPAddr {
+	t.Helper()
+
+	l, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
+	require.NoError(t, err)
+
+	defer l.Close()
+
+	addr, err := net.ResolveUDPAddr("udp", l.LocalAddr().String())
+	require.NoError(t, err)
+
+	return addr
+}
+
 // AvailableMultiAddr returns an available local tcp address as a multiaddr.
 //
 // Note that this is unfortunately only best-effort. Since the port is not
@@ -1416,7 +1436,8 @@ func AvailableAddr(t *testing.T) *net.TCPAddr {
 func AvailableMultiAddr(t *testing.T) multiaddr.Multiaddr {
 	t.Helper()
 
-	l, err := net.Listen("tcp", "localhost:0")
+	lc := net.ListenConfig{}
+	l, err := lc.Listen(t.Context(), "tcp", "localhost:0")
 	require.NoError(t, err)
 
 	defer l.Close()
@@ -1460,6 +1481,36 @@ func CreateHostWithIdentity(t *testing.T, addr *net.TCPAddr, secret *k1.PrivateK
 	return h
 }
 
+func CreateQUICHost(t *testing.T, addr *net.UDPAddr, opts ...libp2p.Option) host.Host {
+	t.Helper()
+
+	pkey, err := k1.GeneratePrivateKey()
+	require.NoError(t, err)
+
+	return CreateQUICHostWithIdentity(t, addr, pkey, opts...)
+}
+
+func CreateQUICHostWithIdentity(t *testing.T, addr *net.UDPAddr, secret *k1.PrivateKey, opts ...libp2p.Option) host.Host {
+	t.Helper()
+
+	addrs, err := multiaddr.NewMultiaddr(fmt.Sprintf("/ip4/%s/udp/%d/quic-v1", addr.IP, addr.Port))
+	require.NoError(t, err)
+
+	opts2 := []libp2p.Option{
+		libp2p.Identity((*p2pcrypto.Secp256k1PrivateKey)(secret)),
+		libp2p.ListenAddrs(addrs),
+		libp2p.Transport(quic.NewTransport),
+	}
+	opts2 = append(opts2, opts...)
+
+	h, err := libp2p.New(opts2...)
+
+	SkipIfBindErr(t, err)
+	require.NoError(t, err)
+
+	return h
+}
+
 func RandomENR(t *testing.T, seed int) (*k1.PrivateKey, enr.Record) {
 	t.Helper()
 
diff --git a/testutil/validatormock/attest.go b/testutil/validatormock/attest.go
index 2106579d..7c10287b 100644
--- a/testutil/validatormock/attest.go
+++ b/testutil/validatormock/attest.go
@@ -25,7 +25,7 @@ import (
 // Type aliases for concise function signatures.
 type (
 	attDuties     []*eth2v1.AttesterDuty
-	attSelections []*eth2exp.BeaconCommitteeSelection
+	attSelections []*eth2v1.BeaconCommitteeSelection
 	attDatas      []*eth2p0.AttestationData
 )
 
@@ -250,7 +250,7 @@ func prepareAggregators(ctx context.Context, eth2Cl eth2wrap.Client, signFunc Si
 	}
 
 	var (
-		partials    []*eth2exp.BeaconCommitteeSelection
+		partials    []*eth2v1.BeaconCommitteeSelection
 		commLengths = make(map[eth2p0.ValidatorIndex]uint64)
 	)
 	for _, duty := range duties {
@@ -266,18 +266,20 @@ func prepareAggregators(ctx context.Context, eth2Cl eth2wrap.Client, signFunc Si
 
 		commLengths[duty.ValidatorIndex] = duty.CommitteeLength
 
-		partials = append(partials, &eth2exp.BeaconCommitteeSelection{
+		partials = append(partials, &eth2v1.BeaconCommitteeSelection{
 			ValidatorIndex: duty.ValidatorIndex,
 			Slot:           duty.Slot,
 			SelectionProof: slotSig,
 		})
 	}
 
-	aggregateSelections, err := eth2Cl.AggregateBeaconCommitteeSelections(ctx, partials)
+	eth2Resp, err := eth2Cl.BeaconCommitteeSelections(ctx, &eth2api.BeaconCommitteeSelectionsOpts{Selections: partials})
 	if err != nil {
 		return nil, err
 	}
 
+	aggregateSelections := eth2Resp.Data
+
 	var selections attSelections
 
 	for _, selection := range aggregateSelections {
diff --git a/testutil/validatormock/component.go b/testutil/validatormock/component.go
index 1b87b39e..eb7433fd 100644
--- a/testutil/validatormock/component.go
+++ b/testutil/validatormock/component.go
@@ -278,22 +278,6 @@ func (m *Component) runDuty(ctx context.Context, duty core.Duty) error {
 		}
 	case core.DutyBuilderProposer:
 		return core.ErrDeprecatedDutyBuilderProposer
-	case core.DutyBuilderRegistration:
-		if !m.builderAPI {
-			return nil
-		}
-
-		regs, err := RegistrationsFromProposerConfig(ctx, eth2Cl)
-		if err != nil {
-			return err
-		}
-
-		for pubshare, reg := range regs {
-			err = Register(ctx, eth2Cl, m.signFunc, reg, pubshare)
-			if err != nil {
-				return err
-			}
-		}
 	case core.DutyPrepareSyncContribution:
 		if syncComm == nil {
 			return errors.New("syncomm is nil", z.Str("duty", duty.String()))
diff --git a/testutil/validatormock/propose.go b/testutil/validatormock/propose.go
index 3df43dc6..72e808f0 100644
--- a/testutil/validatormock/propose.go
+++ b/testutil/validatormock/propose.go
@@ -5,8 +5,6 @@ package validatormock
 
 import (
 	"context"
-	"encoding/hex"
-	"strings"
 
 	eth2api "github.com/attestantio/go-eth2-client/api"
 	eth2v1 "github.com/attestantio/go-eth2-client/api/v1"
@@ -219,59 +217,6 @@ func ProposeBlock(ctx context.Context, eth2Cl eth2wrap.Client, signFunc SignFunc
 	return eth2Cl.SubmitProposal(ctx, &eth2api.SubmitProposalOpts{Proposal: signedBlock})
 }
 
-// RegistrationsFromProposerConfig returns all enabled builder-API registrations from upstream proposer config.
-func RegistrationsFromProposerConfig(ctx context.Context, eth2Cl eth2wrap.Client) (map[eth2p0.BLSPubKey]*eth2api.VersionedValidatorRegistration, error) {
-	confs, err := eth2Cl.ProposerConfig(ctx)
-	if err != nil {
-		return nil, errors.Wrap(err, "get proposer config")
-	}
-
-	resp := make(map[eth2p0.BLSPubKey]*eth2api.VersionedValidatorRegistration)
-
-	for pubshare, conf := range confs.Proposers {
-		if !conf.Builder.Enabled {
-			continue
-		}
-
-		ts, ok, err := conf.Builder.TimestampOverride()
-		if err != nil {
-			return nil, errors.Wrap(err, "get builder timestamp override")
-		} else if !ok {
-			return nil, errors.Wrap(err, "no builder timestamp override")
-		}
-
-		pubkey, ok, err := conf.Builder.PublicKeyOverride()
-		if err != nil {
-			return nil, errors.Wrap(err, "get builder public key override")
-		} else if !ok {
-			return nil, errors.Wrap(err, "no builder public key override")
-		}
-
-		if conf.Builder.GasLimit == 0 {
-			return nil, errors.New("invalid builder gas limit")
-		}
-
-		feeRecipientBytes, err := hex.DecodeString(strings.TrimPrefix(conf.FeeRecipient, "0x"))
-		if err != nil {
-			return nil, errors.Wrap(err, "decode fee recipient")
-		} else if len(feeRecipientBytes) != 20 {
-			return nil, errors.New("invalid fee recipient")
-		}
-
-		resp[pubshare] = &eth2api.VersionedValidatorRegistration{
-			Version: eth2spec.BuilderVersionV1,
-			V1: &eth2v1.ValidatorRegistration{
-				FeeRecipient: bellatrix.ExecutionAddress(feeRecipientBytes),
-				GasLimit:     uint64(conf.Builder.GasLimit),
-				Timestamp:    ts,
-				Pubkey:       pubkey,
-			},
-		}
-	}
-
-	return resp, nil
-}
-
 // Register signs and submits the validator builder registration to the validator API.
 func Register(ctx context.Context, eth2Cl eth2wrap.Client, signFunc SignFunc,
 	registration *eth2api.VersionedValidatorRegistration, pubshare eth2p0.BLSPubKey,
diff --git a/testutil/validatormock/propose_test.go b/testutil/validatormock/propose_test.go
index b1ea57bf..03cfcc06 100644
--- a/testutil/validatormock/propose_test.go
+++ b/testutil/validatormock/propose_test.go
@@ -52,6 +52,7 @@ func TestAttest(t *testing.T) {
 			// Configure beacon mock
 			valSet := beaconmock.ValidatorSetA
 			beaconMock, err := beaconmock.New(
+				t.Context(),
 				beaconmock.WithClock(clock),
 				beaconmock.WithValidatorSet(valSet),
 				beaconmock.WithDeterministicAttesterDuties(test.DutyFactor),
@@ -279,6 +280,7 @@ func TestProposeBlock(t *testing.T) {
 			// Configure beacon mock
 			valSet := beaconmock.ValidatorSetA
 			beaconMock, err := beaconmock.New(
+				t.Context(),
 				beaconmock.WithValidatorSet(valSet),
 				beaconmock.WithDeterministicProposerDuties(0),
 			)
@@ -328,6 +330,7 @@ func TestProposeBlindedBlock(t *testing.T) {
 	// Configure beacon mock
 	valSet := beaconmock.ValidatorSetA
 	beaconMock, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithValidatorSet(valSet),
 		beaconmock.WithDeterministicProposerDuties(0),
 	)
diff --git a/testutil/validatormock/synccomm.go b/testutil/validatormock/synccomm.go
index 6394269f..f123dd5d 100644
--- a/testutil/validatormock/synccomm.go
+++ b/testutil/validatormock/synccomm.go
@@ -23,7 +23,7 @@ import (
 
 type (
 	syncDuties     []*eth2v1.SyncCommitteeDuty
-	syncSelections []*eth2exp.SyncCommitteeSelection
+	syncSelections []*eth2v1.SyncCommitteeSelection
 )
 
 func NewSyncCommMember(eth2Cl eth2wrap.Client, epoch eth2p0.Epoch, signFunc SignFunc, pubkeys []eth2p0.BLSPubKey) *SyncCommMember {
@@ -314,7 +314,7 @@ func prepareSyncSelections(ctx context.Context, eth2Cl eth2wrap.Client, signFunc
 		return nil, err
 	}
 
-	var partials []*eth2exp.SyncCommitteeSelection
+	var partials []*eth2v1.SyncCommitteeSelection
 
 	for _, duty := range duties {
 		subcommIdxs, err := getSubcommittees(ctx, eth2Cl, duty)
@@ -343,20 +343,22 @@ func prepareSyncSelections(ctx context.Context, eth2Cl eth2wrap.Client, signFunc
 				return nil, err
 			}
 
-			partials = append(partials, &eth2exp.SyncCommitteeSelection{
+			partials = append(partials, &eth2v1.SyncCommitteeSelection{
 				ValidatorIndex:    duty.ValidatorIndex,
 				Slot:              slot,
-				SubcommitteeIndex: subcommIdx,
+				SubcommitteeIndex: uint64(subcommIdx),
 				SelectionProof:    sig,
 			})
 		}
 	}
 
-	aggregateSelections, err := eth2Cl.AggregateSyncCommitteeSelections(ctx, partials)
+	eth2Resp, err := eth2Cl.SyncCommitteeSelections(ctx, &eth2api.SyncCommitteeSelectionsOpts{Selections: partials})
 	if err != nil {
 		return nil, err
 	}
 
+	aggregateSelections := eth2Resp.Data
+
 	var selections syncSelections
 
 	for _, selection := range aggregateSelections {
@@ -466,7 +468,7 @@ func aggContributions(ctx context.Context, eth2Cl eth2wrap.Client, signFunc Sign
 		// Query BN to get sync committee contribution.
 		opts := &eth2api.SyncCommitteeContributionOpts{
 			Slot:              selection.Slot,
-			SubcommitteeIndex: uint64(selection.SubcommitteeIndex),
+			SubcommitteeIndex: selection.SubcommitteeIndex,
 			BeaconBlockRoot:   blockRoot,
 		}
 
diff --git a/testutil/validatormock/synccomm_internal_test.go b/testutil/validatormock/synccomm_internal_test.go
index 4b9c41e4..f4a110e2 100644
--- a/testutil/validatormock/synccomm_internal_test.go
+++ b/testutil/validatormock/synccomm_internal_test.go
@@ -17,6 +17,7 @@ import (
 func TestGetSubcommittees(t *testing.T) {
 	ctx := context.Background()
 	bmock, err := beaconmock.New(
+		t.Context(),
 		beaconmock.WithSyncCommitteeSize(512),
 		beaconmock.WithSyncCommitteeSubnetCount(4),
 	)