Repository: ObolNetwork/charon

Issue:
Put QUIC behind a feature flag
# 🎯 Problem to be solved

Big clusters have high consensus duration without many proposer timeouts. This might be due to message sizes increasing beyond the TCP packet size. 

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

Turn on quic as a supported/preferred libp2p protocol when a feature flag is enabled. Observe a change in consensus duration. 

Expose the protocol in use in debug logging. 

# 🧪 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:

Solution patch:
diff --git a/.pre-commit/run_linter.sh b/.pre-commit/run_linter.sh
index 55addb7b..f611f7a8 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="2.2.2"
 
 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..aa0c9d94 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
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/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/monitoringapi.go b/app/monitoringapi.go
index 472311f4..de3dbc78 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
@@ -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/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_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..6160f2e0 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
@@ -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/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..52f72536 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]
@@ -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/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/validatorapi/router.go b/core/validatorapi/router.go
index 04acead2..3331e04b 100644
--- a/core/validatorapi/router.go
+++ b/core/validatorapi/router.go
@@ -132,7 +132,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 +160,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",
@@ -244,7 +244,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 +258,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",
diff --git a/core/validatorapi/validatorapi.go b/core/validatorapi/validatorapi.go
index 8d5dbafc..38243c35 100644
--- a/core/validatorapi/validatorapi.go
+++ b/core/validatorapi/validatorapi.go
@@ -5,6 +5,7 @@ package validatorapi
 import (
 	"context"
 	"fmt"
+	"maps"
 	"math/big"
 	"runtime"
 	"strconv"
@@ -1228,9 +1229,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)))
 	}
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..29735154 100644
--- a/eth2util/enr/enr.go
+++ b/eth2util/enr/enr.go
@@ -117,7 +117,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/go.mod b/go.mod
index 86f7f7d0..5ba80271 100644
--- a/go.mod
+++ b/go.mod
@@ -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
 )
@@ -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
diff --git a/go.sum b/go.sum
index af591821..460d066d 100644
--- a/go.sum
+++ b/go.sum
@@ -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/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..f21b49a4 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.32.0
 
 RUN apt-get update && apt-get install -y curl jq wget
 



Test patch:
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/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_delete_internal_test.go b/cmd/exit_delete_internal_test.go
new file mode 100644
index 00000000..f771b23e
--- /dev/null
+++ b/cmd/exit_delete_internal_test.go
@@ -0,0 +1,181 @@
+// 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(
+		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/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/testpeers.go b/cmd/testpeers.go
index f0aa042f..39b4ebbf 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))))
 	}
@@ -846,7 +846,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 +867,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 +883,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/deadline_test.go b/core/deadline_test.go
index a6573049..eb7efd51 100644
--- a/core/deadline_test.go
+++ b/core/deadline_test.go
@@ -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/dkg/dkg_test.go b/dkg/dkg_test.go
index 03b8f355..55c3093b 100644
--- a/dkg/dkg_test.go
+++ b/dkg/dkg_test.go
@@ -674,7 +674,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 +690,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/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/server.go b/testutil/beaconmock/server.go
index d10962ab..bb5b4b91 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()
 
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/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..91e1bbb5 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"
@@ -1262,7 +1263,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)
 }
@@ -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
@@ -1460,6 +1480,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()