Repository: ObolNetwork/charon

Issue:
`charon deposit sign` && `charon deposit fetch`
# 🎯 Problem to be solved

We want to sign deposits for new validators, but `add validators` is a bit high friction as it requires restarting the prod validators. Some orgs maintain a strict separation of concern between the people that manage the private key generation and the people that run the nodes. 

# 🛠️ Proposed solution

Like we have `charon exit sign` and `charon exit fetch`. Lets make `charon deposit sign` and `charon deposit fetch`. 

You should be able to specify `--public-keys` from the cluster-lock as a comma separated array of pubkeys to sign deposits for.

You should be able to specify --compounding, which makes the withdrawal address 0x02, otherwise 0x01

You must specify one withdrawal address or as many withdrawal addresses as your `--public-keys` array.

The default amount is 1 + 32 eth, but you can modify it with `--deposit-amounts`.

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


# 👐 Additional acceptance criteria

The Obol API needs to support this behavior

# ❌ Out of Scope

Modifying fee-recipients of these validators. For now an editable splitter should be used. 

Hints from issue comments:

Solution patch:
diff --git a/.github/workflows/build-release-binaries.yml b/.github/workflows/build-release-binaries.yml
index 6edd12cb..1349633c 100644
--- a/.github/workflows/build-release-binaries.yml
+++ b/.github/workflows/build-release-binaries.yml
@@ -85,7 +85,7 @@ jobs:
         sha256sum charon-$RELEASE_VERSION-linux-arm64.tar.gz >> checksums.txt
 
     - name: Upload artifacts
-      uses: actions/upload-artifact@v4
+      uses: actions/upload-artifact@v5
       with:
         name: charon-${{ env.RELEASE_VERSION }}-binaries
         path: |
diff --git a/.github/workflows/compose-tests.yml b/.github/workflows/compose-tests.yml
index 9e441528..402b7afa 100644
--- a/.github/workflows/compose-tests.yml
+++ b/.github/workflows/compose-tests.yml
@@ -33,7 +33,7 @@ jobs:
           echo "CHARON_REPO=$(pwd)" >> $GITHUB_ENV
           echo "DOCKER_BUILDKIT=1" >> $GITHUB_ENV
       - run: go test -race github.com/obolnetwork/charon/testutil/compose/smoke -v -integration -sudo-perms -timeout=20m -log-dir=.
-      - uses: actions/upload-artifact@v4
+      - uses: actions/upload-artifact@v5
         if: always()
         with:
           name: smoke-test-logs
diff --git a/.github/workflows/nightly-tests.yml b/.github/workflows/nightly-tests.yml
index 6e8e9845..023ff85d 100644
--- a/.github/workflows/nightly-tests.yml
+++ b/.github/workflows/nightly-tests.yml
@@ -43,7 +43,7 @@ jobs:
           echo "CHARON_REPO=$(pwd)" >> $GITHUB_ENV
           echo "DOCKER_BUILDKIT=1" >> $GITHUB_ENV
       - run: go test -race github.com/obolnetwork/charon/testutil/compose/fuzz -v -fuzzer -sudo-perms -timeout=45m -log-dir=.
-      - uses: actions/upload-artifact@v4
+      - uses: actions/upload-artifact@v5
         if: always()
         with:
           name: fuzz-test-logs
diff --git a/app/app.go b/app/app.go
index b30c51a3..247f7b40 100644
--- a/app/app.go
+++ b/app/app.go
@@ -42,7 +42,7 @@ import (
 	"github.com/obolnetwork/charon/app/tracer"
 	"github.com/obolnetwork/charon/app/version"
 	"github.com/obolnetwork/charon/app/z"
-	"github.com/obolnetwork/charon/cluster"
+	clusterpkg "github.com/obolnetwork/charon/cluster"
 	"github.com/obolnetwork/charon/cluster/manifest"
 	manifestpb "github.com/obolnetwork/charon/cluster/manifestpb/v1"
 	"github.com/obolnetwork/charon/core"
@@ -118,7 +118,7 @@ type TestConfig struct {
 	p2p.TestPingConfig
 
 	// Lock provides the lock explicitly, skips loading from disk.
-	Lock *cluster.Lock
+	Lock *clusterpkg.Lock
 	// P2PKey provides the p2p privkey explicitly, skips loading from keystore on disk.
 	P2PKey *k1.PrivateKey
 	// ParSigExFunc provides an in-memory partial signature exchange.
@@ -398,14 +398,14 @@ func wireP2P(ctx context.Context, life *lifecycle.Manager, conf Config,
 
 // wireCoreWorkflow wires the core workflow components.
 func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config,
-	cluster *manifestpb.Cluster, nodeIdx cluster.NodeIdx, p2pNode host.Host, p2pKey *k1.PrivateKey,
+	cluster *manifestpb.Cluster, nodeIdx clusterpkg.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(),
 ) error {
 	// Convert and prep public keys and public shares
 	var (
-		corePubkeys                  []core.PubKey
+		builderRegistrations         []clusterpkg.BuilderRegistration
 		eth2Pubkeys                  []eth2p0.BLSPubKey
 		pubshares                    []eth2p0.BLSPubKey
 		allPubSharesByKey            = make(map[core.PubKey]map[int]tbls.PublicKey) // map[pubkey]map[shareIdx]pubshare
@@ -445,10 +445,16 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config,
 		eth2Pubkey := eth2p0.BLSPubKey(pubkey)
 
 		eth2Pubkeys = append(eth2Pubkeys, eth2Pubkey)
-		corePubkeys = append(corePubkeys, corePubkey)
 		pubshares = append(pubshares, eth2Share)
 		allPubSharesByKey[corePubkey] = allPubShares
 		feeRecipientAddrByCorePubkey[corePubkey] = val.GetFeeRecipientAddress()
+
+		var builderRegistration clusterpkg.BuilderRegistration
+		if err := json.Unmarshal(val.GetBuilderRegistrationJson(), &builderRegistration); err != nil {
+			return errors.Wrap(err, "unmarshal builder registration")
+		}
+
+		builderRegistrations = append(builderRegistrations, builderRegistration)
 	}
 
 	peers, err := manifest.ClusterPeers(cluster)
@@ -465,7 +471,7 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config,
 		return core.NewDeadliner(ctx, label, deadlineFunc)
 	}
 
-	sched, err := scheduler.New(corePubkeys, eth2Cl, conf.BuilderAPI)
+	sched, err := scheduler.New(builderRegistrations, eth2Cl, conf.BuilderAPI)
 	if err != nil {
 		return err
 	}
@@ -625,11 +631,6 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config,
 		return err
 	}
 
-	if err = wireRecaster(ctx, eth2Cl, sched, sigAgg, broadcaster, cluster.GetValidators(),
-		conf.BuilderAPI, conf.TestConfig.BroadcastCallback); err != nil {
-		return errors.Wrap(err, "wire recaster")
-	}
-
 	track, err := newTracker(ctx, life, deadlineFunc, peers, eth2Cl)
 	if err != nil {
 		return err
@@ -749,76 +750,6 @@ func wirePrioritise(ctx context.Context, conf Config, life *lifecycle.Manager, p
 	return nil
 }
 
-// wireRecaster wires the rebroadcaster component to scheduler, sigAgg and broadcaster.
-// This is not done in core.Wire since recaster isn't really part of the official core workflow (yet).
-func wireRecaster(ctx context.Context, eth2Cl eth2wrap.Client, sched core.Scheduler, sigAgg core.SigAgg,
-	broadcaster core.Broadcaster, validators []*manifestpb.Validator, builderAPI bool,
-	callback func(context.Context, core.Duty, core.SignedDataSet) error,
-) error {
-	recaster, err := bcast.NewRecaster(func(ctx context.Context) (map[eth2p0.BLSPubKey]struct{}, error) {
-		valList, err := eth2Cl.ActiveValidators(ctx)
-		if err != nil {
-			return nil, err
-		}
-
-		ret := make(map[eth2p0.BLSPubKey]struct{})
-
-		for _, v := range valList {
-			ret[v] = struct{}{}
-		}
-
-		return ret, nil
-	})
-	if err != nil {
-		return errors.Wrap(err, "recaster init")
-	}
-
-	sched.SubscribeSlots(recaster.SlotTicked)
-	sigAgg.Subscribe(recaster.Store)
-	recaster.Subscribe(broadcaster.Broadcast)
-
-	if callback != nil {
-		recaster.Subscribe(callback)
-	}
-
-	if !builderAPI {
-		return nil
-	}
-
-	for _, val := range validators {
-		// Check if the current cluster manifest supports pre-generate validator registrations.
-		if len(val.GetBuilderRegistrationJson()) == 0 {
-			continue
-		}
-
-		reg := new(eth2api.VersionedSignedValidatorRegistration)
-		if err := json.Unmarshal(val.GetBuilderRegistrationJson(), reg); err != nil {
-			return errors.Wrap(err, "unmarshal validator registration")
-		}
-
-		pubkey, err := core.PubKeyFromBytes(val.GetPublicKey())
-		if err != nil {
-			return errors.Wrap(err, "core pubkey from bytes")
-		}
-
-		signedData, err := core.NewVersionedSignedValidatorRegistration(reg)
-		if err != nil {
-			return errors.Wrap(err, "new versioned signed validator registration")
-		}
-
-		slot, err := validatorapi.SlotFromTimestamp(ctx, eth2Cl, reg.V1.Message.Timestamp)
-		if err != nil {
-			return errors.Wrap(err, "calculate slot from timestamp")
-		}
-
-		if err = recaster.Store(ctx, core.NewBuilderRegistrationDuty(uint64(slot)), core.SignedDataSet{pubkey: signedData}); err != nil {
-			return errors.Wrap(err, "recaster store registration")
-		}
-	}
-
-	return nil
-}
-
 // newTracker creates and starts a new tracker instance.
 func newTracker(ctx context.Context, life *lifecycle.Manager, deadlineFunc func(duty core.Duty) (time.Time, bool),
 	peers []p2p.Peer, eth2Cl eth2wrap.Client,
diff --git a/app/health/checks.go b/app/health/checks.go
index 53ab553c..bac20ab3 100644
--- a/app/health/checks.go
+++ b/app/health/checks.go
@@ -127,7 +127,7 @@ var checks = []check{
 		Description: "High rate of failed validator registrations. Please check the logs for more details.",
 		Severity:    severityWarning,
 		Func: func(q query, _ Metadata) (bool, error) {
-			increase, err := q("core_bcast_recast_errors_total", sumLabels(), increase)
+			increase, err := q("core_scheduler_submit_registration_errors_total", sumLabels(), increase)
 			if err != nil {
 				return false, err
 			}
diff --git a/app/obolapi/api.go b/app/obolapi/api.go
index da4e744a..ec684420 100644
--- a/app/obolapi/api.go
+++ b/app/obolapi/api.go
@@ -129,7 +129,7 @@ func httpGet(ctx context.Context, url *url.URL, headers map[string]string) (io.R
 
 	if res.StatusCode/100 != 2 {
 		if res.StatusCode == http.StatusNotFound {
-			return nil, ErrNoExit
+			return nil, ErrNoValue
 		}
 
 		data, err := io.ReadAll(res.Body)
@@ -161,7 +161,7 @@ func httpDelete(ctx context.Context, url *url.URL, headers map[string]string) er
 
 	if res.StatusCode/100 != 2 {
 		if res.StatusCode == http.StatusNotFound {
-			return ErrNoExit
+			return ErrNoValue
 		}
 
 		return errors.New("http DELETE failed", z.Int("status", res.StatusCode))
diff --git a/app/obolapi/deposit.go b/app/obolapi/deposit.go
new file mode 100644
index 00000000..64a37303
--- /dev/null
+++ b/app/obolapi/deposit.go
@@ -0,0 +1,172 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package obolapi
+
+import (
+	"context"
+	"encoding/hex"
+	"encoding/json"
+	"net/url"
+	"strconv"
+	"strings"
+
+	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
+
+	"github.com/obolnetwork/charon/app/errors"
+	"github.com/obolnetwork/charon/app/z"
+	"github.com/obolnetwork/charon/tbls"
+	"github.com/obolnetwork/charon/tbls/tblsconv"
+)
+
+const (
+	submitPartialDepositTmpl = "/deposit_data/partial_deposits/" + lockHashPath + "/" + shareIndexPath
+	fetchFullDepositTmpl     = "/deposit_data/" + lockHashPath + "/" + valPubkeyPath
+)
+
+// submitPartialDepositURL returns the partial deposit Obol API URL for a given lock hash.
+func submitPartialDepositURL(lockHash string, shareIndex uint64) string {
+	return strings.NewReplacer(
+		lockHashPath,
+		lockHash,
+		shareIndexPath,
+		strconv.FormatUint(shareIndex, 10),
+	).Replace(submitPartialDepositTmpl)
+}
+
+// fetchFullDepositURL returns the full deposit Obol API URL for a given validator public key.
+func fetchFullDepositURL(valPubkey, lockHash string) string {
+	return strings.NewReplacer(
+		valPubkeyPath,
+		valPubkey,
+		lockHashPath,
+		lockHash,
+	).Replace(fetchFullDepositTmpl)
+}
+
+// PostPartialDeposits POSTs the set of msg's to the Obol API, for a given lock hash.
+// It respects the timeout specified in the Client instance.
+func (c Client) PostPartialDeposits(ctx context.Context, lockHash []byte, shareIndex uint64, depositBlobs []eth2p0.DepositData) error {
+	lockHashStr := "0x" + hex.EncodeToString(lockHash)
+
+	path := submitPartialDepositURL(lockHashStr, shareIndex)
+
+	u, err := url.ParseRequestURI(c.baseURL)
+	if err != nil {
+		return errors.Wrap(err, "bad Obol API url")
+	}
+
+	u.Path = path
+
+	apiDepositWrap := PartialDepositRequest{PartialDepositData: depositBlobs}
+
+	data, err := json.Marshal(apiDepositWrap)
+	if err != nil {
+		return errors.Wrap(err, "json marshal error")
+	}
+
+	ctx, cancel := context.WithTimeout(ctx, c.reqTimeout)
+	defer cancel()
+
+	err = httpPost(ctx, u, data, nil)
+	if err != nil {
+		return errors.Wrap(err, "http Obol API POST request")
+	}
+
+	return nil
+}
+
+// GetFullDeposit gets the full deposit message for a given validator public key, lock hash and share index.
+// It respects the timeout specified in the Client instance.
+func (c Client) GetFullDeposit(ctx context.Context, valPubkey string, lockHash []byte, threshold int) ([]eth2p0.DepositData, error) {
+	valPubkeyBytes, err := from0x(valPubkey, len(eth2p0.BLSPubKey{}))
+	if err != nil {
+		return []eth2p0.DepositData{}, errors.Wrap(err, "validator pubkey to bytes")
+	}
+
+	path := fetchFullDepositURL(valPubkey, "0x"+hex.EncodeToString(lockHash))
+
+	u, err := url.ParseRequestURI(c.baseURL)
+	if err != nil {
+		return []eth2p0.DepositData{}, errors.Wrap(err, "bad Obol API url")
+	}
+
+	u.Path = path
+
+	ctx, cancel := context.WithTimeout(ctx, c.reqTimeout)
+	defer cancel()
+
+	respBody, err := httpGet(ctx, u, map[string]string{})
+	if err != nil {
+		return []eth2p0.DepositData{}, errors.Wrap(err, "http Obol API GET request")
+	}
+
+	defer respBody.Close()
+
+	var dr FullDepositResponse
+	if err := json.NewDecoder(respBody).Decode(&dr); err != nil {
+		return []eth2p0.DepositData{}, errors.Wrap(err, "json unmarshal error")
+	}
+
+	withdrawalCredentialsBytes, err := hex.DecodeString(strings.TrimPrefix(dr.WithdrawalCredentials, "0x"))
+	if err != nil {
+		return []eth2p0.DepositData{}, errors.Wrap(err, "withdrawal credentials to bytes")
+	}
+
+	// do aggregation
+	fullDeposits := []eth2p0.DepositData{}
+
+	for _, am := range dr.Amounts {
+		rawSignatures := make(map[int]tbls.Signature)
+
+		if len(am.Partials) < threshold {
+			submittedPubKeys := []string{}
+			for _, sigStr := range am.Partials {
+				submittedPubKeys = append(submittedPubKeys, sigStr.PartialPublicKey)
+			}
+
+			return []eth2p0.DepositData{}, errors.New("not enough partial signatures to meet threshold", z.Any("submitted_public_keys", submittedPubKeys), z.Int("submitted_public_keys_length", len(submittedPubKeys)), z.Int("required_threshold", threshold))
+		}
+
+		for sigIdx, sigStr := range am.Partials {
+			if len(sigStr.PartialDepositSignature) == 0 {
+				// ignore, the associated share index didn't push a partial signature yet
+				continue
+			}
+
+			if len(sigStr.PartialDepositSignature) < 2 {
+				return []eth2p0.DepositData{}, errors.New("signature string has invalid size", z.Int("size", len(sigStr.PartialDepositSignature)))
+			}
+
+			sigBytes, err := from0x(sigStr.PartialDepositSignature, 96) // a signature is 96 bytes long
+			if err != nil {
+				return []eth2p0.DepositData{}, errors.Wrap(err, "partial signature unmarshal")
+			}
+
+			sig, err := tblsconv.SignatureFromBytes(sigBytes)
+			if err != nil {
+				return []eth2p0.DepositData{}, errors.Wrap(err, "invalid partial signature")
+			}
+
+			rawSignatures[sigIdx+1] = sig
+		}
+
+		fullSig, err := tbls.ThresholdAggregate(rawSignatures)
+		if err != nil {
+			return []eth2p0.DepositData{}, errors.Wrap(err, "partial signatures threshold aggregate")
+		}
+
+		amountUint, err := strconv.ParseUint(am.Amount, 10, 64)
+		if err != nil {
+			return []eth2p0.DepositData{}, errors.Wrap(err, "parse amount to uint")
+		}
+
+		fullDeposits = append(fullDeposits, eth2p0.DepositData{
+			PublicKey:             eth2p0.BLSPubKey(valPubkeyBytes),
+			WithdrawalCredentials: withdrawalCredentialsBytes,
+			Amount:                eth2p0.Gwei(amountUint),
+			Signature:             eth2p0.BLSSignature(fullSig),
+		})
+	}
+
+	return fullDeposits, nil
+}
diff --git a/app/obolapi/deposit_model.go b/app/obolapi/deposit_model.go
new file mode 100644
index 00000000..aa328475
--- /dev/null
+++ b/app/obolapi/deposit_model.go
@@ -0,0 +1,30 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package obolapi
+
+import (
+	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
+)
+
+type PartialDepositRequest struct {
+	PartialDepositData []eth2p0.DepositData `json:"partial_deposit_data"`
+}
+
+// FullDepositResponse contains all partial signatures, public key, amounts and withdrawal credentials to construct
+// a full deposit message for a validator.
+// Signatures are ordered by share index.
+type FullDepositResponse struct {
+	PublicKey             string   `json:"public_key"`
+	WithdrawalCredentials string   `json:"withdrawal_credentials"`
+	Amounts               []Amount `json:"amounts"`
+}
+
+type Amount struct {
+	Amount   string    `json:"amount"`
+	Partials []Partial `json:"partials"`
+}
+
+type Partial struct {
+	PartialPublicKey        string `json:"partial_public_key"`
+	PartialDepositSignature string `json:"partial_deposit_signature"`
+}
diff --git a/app/obolapi/exit.go b/app/obolapi/exit.go
index 263aaff9..e7b691f6 100644
--- a/app/obolapi/exit.go
+++ b/app/obolapi/exit.go
@@ -32,7 +32,7 @@ const (
 	fetchFullExitTmpl     = "/exp/exit/" + lockHashPath + "/" + shareIndexPath + "/" + valPubkeyPath
 )
 
-var ErrNoExit = errors.New("no exit for the given validator public key")
+var ErrNoValue = errors.New("no value for the given validator public key")
 
 // bearerString returns the bearer token authentication string given a token.
 func bearerString(data []byte) string {
diff --git a/app/peerinfo/peerinfo.go b/app/peerinfo/peerinfo.go
index 46f37bb7..7653c20c 100644
--- a/app/peerinfo/peerinfo.go
+++ b/app/peerinfo/peerinfo.go
@@ -218,8 +218,8 @@ func (p *PeerInfo) sendOnce(ctx context.Context, now time.Time) {
 			p.nicknamesMu.Unlock()
 
 			// Validator git hash with regex.
-			if !gitHashMatch.MatchString(resp.GetGitHash()) {
-				log.Warn(ctx, "Invalid peer git hash", nil, z.Str("peer", name))
+			if !gitHashMatch.MatchString(resp.GetGitHash()) && resp.GetGitHash() != version.Unknown {
+				log.Warn(ctx, "Invalid peer git hash", nil, z.Str("peer", name), z.Str("peer_git_hash", resp.GetGitHash()))
 				return
 			}
 
diff --git a/app/version/version.go b/app/version/version.go
index 798a6fc3..b18b4cb5 100644
--- a/app/version/version.go
+++ b/app/version/version.go
@@ -28,6 +28,10 @@ var (
 	vcsTime     = ""
 )
 
+const (
+	Unknown = "unknown"
+)
+
 // Supported returns the supported minor versions in order of precedence.
 func Supported() []SemVer {
 	return []SemVer{
@@ -49,7 +53,7 @@ func GitCommit() (hash string, timestamp string) {
 		return vcsRevision, vcsTime
 	}
 
-	hash, timestamp = "unknown", "unknown"
+	hash, timestamp = Unknown, Unknown
 	hashLen := 7
 
 	info, ok := debug.ReadBuildInfo()
diff --git a/cmd/cmd.go b/cmd/cmd.go
index 1381b4dd..33d4bf15 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -69,6 +69,10 @@ func New() *cobra.Command {
 			newFetchExitCmd(runFetchExit),
 			newDeleteExitCmd(runDeleteExit),
 		),
+		newDepositCmd(
+			newDepositSignCmd(runDepositSign),
+			newDepositFetchCmd(runDepositFetch),
+		),
 		newUnsafeCmd(newRunCmd(app.Run, true)),
 	)
 }
diff --git a/cmd/deposit.go b/cmd/deposit.go
new file mode 100644
index 00000000..df3f5d94
--- /dev/null
+++ b/cmd/deposit.go
@@ -0,0 +1,42 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package cmd
+
+import (
+	"time"
+
+	"github.com/spf13/cobra"
+
+	"github.com/obolnetwork/charon/app/log"
+)
+
+type depositConfig struct {
+	ValidatorPublicKeys []string
+	PrivateKeyPath      string
+	LockFilePath        string
+	ValidatorKeysDir    string
+	PublishAddress      string
+	PublishTimeout      time.Duration
+	Log                 log.Config
+}
+
+func newDepositCmd(cmds ...*cobra.Command) *cobra.Command {
+	root := &cobra.Command{
+		Use:   "deposit",
+		Short: "Sign and fetch a new partial deposit.",
+		Long:  "Sign and fetch new deposit messages for unactivated validators using a remote API, enabling the modification of a withdrawal address after creation but before activation.",
+	}
+
+	root.AddCommand(cmds...)
+
+	return root
+}
+
+func bindDepositFlags(cmd *cobra.Command, config *depositConfig) {
+	cmd.Flags().StringSliceVar(&config.ValidatorPublicKeys, "validator-public-keys", []string{}, "[REQUIRED] List of validator public keys for which new deposits will be signed.")
+	cmd.Flags().StringVar(&config.PrivateKeyPath, privateKeyPath.String(), ".charon/charon-enr-private-key", "Path to the charon enr private key file.")
+	cmd.Flags().StringVar(&config.ValidatorKeysDir, validatorKeysDir.String(), ".charon/validator_keys", "Path to the directory containing the validator private key share files and passwords.")
+	cmd.Flags().StringVar(&config.LockFilePath, lockFilePath.String(), ".charon/cluster-lock.json", "Path to the cluster lock file defining the distributed validator cluster.")
+	cmd.Flags().StringVar(&config.PublishAddress, publishAddress.String(), "https://api.obol.tech/v1", "The URL of the remote API.")
+	cmd.Flags().DurationVar(&config.PublishTimeout, publishTimeout.String(), 5*time.Minute, "Timeout for publishing a signed deposit to the publish-address API.")
+}
diff --git a/cmd/depositfetch.go b/cmd/depositfetch.go
new file mode 100644
index 00000000..7f39c167
--- /dev/null
+++ b/cmd/depositfetch.go
@@ -0,0 +1,110 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package cmd
+
+import (
+	"context"
+	"os"
+	"strings"
+	"time"
+
+	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
+	"github.com/spf13/cobra"
+
+	"github.com/obolnetwork/charon/app/errors"
+	"github.com/obolnetwork/charon/app/log"
+	"github.com/obolnetwork/charon/app/obolapi"
+	"github.com/obolnetwork/charon/app/z"
+	"github.com/obolnetwork/charon/eth2util"
+	"github.com/obolnetwork/charon/eth2util/deposit"
+)
+
+type depositFetchConfig struct {
+	depositConfig
+
+	DepositDataDir string
+}
+
+const defaultDepositDataDir = ".charon/deposit-data-<TIMESTAMP>"
+
+func newDepositFetchCmd(runFunc func(context.Context, depositFetchConfig) error) *cobra.Command {
+	var config depositFetchConfig
+
+	cmd := &cobra.Command{
+		Use:   "fetch",
+		Short: "Fetch a full deposit message.",
+		Long:  "Fetch full validator deposit messages using a remote API.",
+		Args:  cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			return runFunc(cmd.Context(), config)
+		},
+	}
+
+	bindDepositFlags(cmd, &config.depositConfig)
+	bindDepositFetchFlags(cmd, &config)
+
+	wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error {
+		mustMarkFlagRequired(cmd, "validator-public-keys")
+		return nil
+	})
+
+	return cmd
+}
+
+func bindDepositFetchFlags(cmd *cobra.Command, config *depositFetchConfig) {
+	cmd.Flags().StringVar(&config.DepositDataDir, "deposit-data-dir", defaultDepositDataDir, "Path to the directory in which fetched deposit data will be stored.")
+}
+
+func runDepositFetch(ctx context.Context, config depositFetchConfig) error {
+	cl, err := loadClusterLock(config.LockFilePath)
+	if err != nil {
+		return err
+	}
+
+	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))
+	}
+
+	depositDatas := map[eth2p0.Gwei][]eth2p0.DepositData{}
+
+	for _, pubkey := range config.ValidatorPublicKeys {
+		log.Info(ctx, "Fetching full deposit message", z.Str("validator_pubkey", pubkey))
+
+		dd, err := oAPI.GetFullDeposit(ctx, pubkey, cl.GetInitialMutationHash(), int(cl.GetThreshold()))
+		if err != nil {
+			return errors.Wrap(err, "fetch full deposit data from Obol API")
+		}
+
+		for _, d := range dd {
+			log.Info(ctx, "Fetched full deposit message", z.Str("validator_pubkey", pubkey), z.U64("amount", uint64(d.Amount)))
+			depositDatas[d.Amount] = append(depositDatas[d.Amount], d)
+		}
+	}
+
+	var path string
+	if config.DepositDataDir == defaultDepositDataDir {
+		path = strings.Replace(config.DepositDataDir, "<TIMESTAMP>", time.Now().Format(time.RFC3339), 1)
+	} else {
+		path = config.DepositDataDir
+	}
+
+	err = os.MkdirAll(path, 0o755)
+	if err != nil && !os.IsExist(err) {
+		return errors.Wrap(err, "create deposit data dir")
+	}
+
+	network, err := eth2util.ForkVersionToNetwork(cl.GetForkVersion())
+	if err != nil {
+		return err
+	}
+
+	for _, depositDatas := range depositDatas {
+		err = deposit.WriteDepositDataFile(depositDatas, network, path)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/cmd/depositsign.go b/cmd/depositsign.go
new file mode 100644
index 00000000..02cdd232
--- /dev/null
+++ b/cmd/depositsign.go
@@ -0,0 +1,196 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package cmd
+
+import (
+	"context"
+	"encoding/hex"
+	"fmt"
+	"strings"
+
+	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
+	"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/deposit"
+	"github.com/obolnetwork/charon/eth2util/keystore"
+	"github.com/obolnetwork/charon/tbls"
+)
+
+type depositSignConfig struct {
+	depositConfig
+
+	WithdrawalAddresses []string
+	DepositAmounts      []uint
+}
+
+func newDepositSignCmd(runFunc func(context.Context, depositSignConfig) error) *cobra.Command {
+	var config depositSignConfig
+
+	cmd := &cobra.Command{
+		Use:   "sign",
+		Short: "Sign a new partial deposit message.",
+		Long:  "Signs new partial validator deposit messages using a remote API.",
+		Args:  cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, _ []string) error {
+			return runFunc(cmd.Context(), config)
+		},
+	}
+
+	bindDepositFlags(cmd, &config.depositConfig)
+	bindDepositSignFlags(cmd, &config)
+
+	wrapPreRunE(cmd, func(cmd *cobra.Command, _ []string) error {
+		mustMarkFlagRequired(cmd, "withdrawal-addresses")
+		mustMarkFlagRequired(cmd, "validator-public-keys")
+
+		return nil
+	})
+
+	return cmd
+}
+
+func bindDepositSignFlags(cmd *cobra.Command, config *depositSignConfig) {
+	cmd.Flags().StringSliceVar(&config.WithdrawalAddresses, "withdrawal-addresses", []string{}, "[REQUIRED] Withdrawal addresses for which the new deposits will be signed. Either a single address for all specified validator-public-keys or one address per key should be specified.")
+	cmd.Flags().UintSliceVar(&config.DepositAmounts, "deposit-amounts", []uint{32}, "Comma separated list of partial deposit amounts (integers) in ETH.")
+}
+
+func runDepositSign(ctx context.Context, config depositSignConfig) error {
+	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 := loadClusterLock(config.LockFilePath)
+	if err != nil {
+		return err
+	}
+
+	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")
+	}
+
+	singleWithdrawalAddresses := len(config.WithdrawalAddresses) == 1
+
+	if !singleWithdrawalAddresses && len(config.WithdrawalAddresses) != len(config.ValidatorPublicKeys) {
+		return errors.New("either a single withdrawal address for all keys or one per key must be specified",
+			z.Int("withdrawal_addresses", len(config.WithdrawalAddresses)),
+			z.Int("validator_public_keys", len(config.ValidatorPublicKeys)))
+	}
+
+	rawValKeys, err := keystore.LoadFilesUnordered(config.ValidatorKeysDir)
+	if err != nil {
+		return errors.Wrap(err, "load keystore, check if path exists", z.Str("validator_keys_dir", config.ValidatorKeysDir))
+	}
+
+	valKeys, err := rawValKeys.SequencedKeys()
+	if err != nil {
+		return errors.Wrap(err, "load keystore")
+	}
+
+	shares, err := keystore.KeysharesToValidatorPubkey(cl, valKeys)
+	if err != nil {
+		return errors.Wrap(err, "match local validator key shares with their counterparty in cluster lock")
+	}
+
+	pubkeys := []eth2p0.BLSPubKey{}
+
+	for _, valPubKey := range config.ValidatorPublicKeys {
+		pubkey, err := hex.DecodeString(strings.TrimPrefix(valPubKey, "0x"))
+		if err != nil {
+			return errors.Wrap(err, "decode pubkey", z.Str("validator_public_key", valPubKey))
+		}
+
+		blsPK := eth2p0.BLSPubKey(pubkey)
+
+		pubkeys = append(pubkeys, blsPK)
+	}
+
+	withdrawalAddrs := [][]byte{}
+
+	for _, wAddr := range config.WithdrawalAddresses {
+		withdrawalAddr, err := hex.DecodeString(strings.TrimPrefix(wAddr, "0x"))
+		if err != nil {
+			return errors.Wrap(err, "decode withdrawal address", z.Str("withdrawal_address", wAddr))
+		}
+
+		withdrawalAddrs = append(withdrawalAddrs, withdrawalAddr)
+	}
+
+	depositDatas := []eth2p0.DepositData{}
+
+	network, err := eth2util.ForkVersionToNetwork(cl.GetForkVersion())
+	if err != nil {
+		return err
+	}
+
+	for i, pubkey := range pubkeys {
+		for _, amount := range config.DepositAmounts {
+			if !cl.GetCompounding() && (amount < 1 || amount > 32) {
+				return errors.New("deposit amount must be between 1 and 32 ETH", z.U64("amount", uint64(amount)))
+			}
+
+			if cl.GetCompounding() && (amount < 1 || amount > 2048) {
+				return errors.New("deposit amount must be between 1 and 2048 ETH", z.U64("amount", uint64(amount)))
+			}
+
+			depositMsg := eth2p0.DepositMessage{
+				PublicKey: pubkey,
+				Amount:    eth2p0.Gwei(deposit.OneEthInGwei * amount),
+			}
+			if singleWithdrawalAddresses {
+				depositMsg.WithdrawalCredentials = withdrawalAddrs[0]
+			} else {
+				depositMsg.WithdrawalCredentials = withdrawalAddrs[i]
+			}
+
+			sigRoot, err := deposit.GetMessageSigningRoot(depositMsg, network)
+			if err != nil {
+				return errors.Wrap(err, "get signing root for deposit message")
+			}
+
+			corePubkey, err := core.PubKeyFromBytes(pubkey[:])
+			if err != nil {
+				return errors.Wrap(err, "convert pubkey to core pubkey", z.Str("pubkey", fmt.Sprintf("%x", pubkey)))
+			}
+
+			secretShare, ok := shares[corePubkey]
+			if !ok {
+				return errors.New("no key share found for validator pubkey", z.Str("pubkey", fmt.Sprintf("%x", pubkey)))
+			}
+
+			sig, err := tbls.Sign(secretShare.Share, sigRoot[:])
+			if err != nil {
+				return errors.Wrap(err, "sign deposit message")
+			}
+
+			depositDatas = append(depositDatas, eth2p0.DepositData{
+				PublicKey:             depositMsg.PublicKey,
+				WithdrawalCredentials: depositMsg.WithdrawalCredentials,
+				Amount:                depositMsg.Amount,
+				Signature:             eth2p0.BLSSignature(sig),
+			})
+		}
+	}
+
+	log.Info(ctx, "Submitting partial deposit message")
+
+	err = oAPI.PostPartialDeposits(ctx, cl.GetInitialMutationHash(), shareIdx, depositDatas)
+	if err != nil {
+		return errors.Wrap(err, "submit partial deposit data to Obol API")
+	}
+
+	return nil
+}
diff --git a/cmd/exit_broadcast.go b/cmd/exit_broadcast.go
index a631859d..50841413 100644
--- a/cmd/exit_broadcast.go
+++ b/cmd/exit_broadcast.go
@@ -175,7 +175,7 @@ func runBcastFullExit(ctx context.Context, config exitConfig) error {
 
 				exit, err := fetchFullExit(valCtx, "", config, cl, identityKey, validatorPubKeyHex)
 				if err != nil {
-					if errors.Is(err, obolapi.ErrNoExit) {
+					if errors.Is(err, obolapi.ErrNoValue) {
 						log.Warn(ctx, fmt.Sprintf("full exit data from Obol API for validator %v not available (validator may not be activated)", validatorPubKeyHex), nil)
 						continue
 					}
diff --git a/cmd/exit_delete.go b/cmd/exit_delete.go
index 1c37a141..6e09a822 100644
--- a/cmd/exit_delete.go
+++ b/cmd/exit_delete.go
@@ -112,7 +112,7 @@ func runDeleteExit(ctx context.Context, config exitConfig) error {
 
 			err := oAPI.DeletePartialExit(valCtx, validatorPubKeyHex, cl.GetInitialMutationHash(), shareIdx, identityKey)
 			if err != nil {
-				if errors.Is(err, obolapi.ErrNoExit) {
+				if errors.Is(err, obolapi.ErrNoValue) {
 					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
 				}
diff --git a/cmd/exit_fetch.go b/cmd/exit_fetch.go
index f26eb94e..7f3923b4 100644
--- a/cmd/exit_fetch.go
+++ b/cmd/exit_fetch.go
@@ -129,7 +129,7 @@ func runFetchExit(ctx context.Context, config exitConfig) error {
 
 			fullExit, err := oAPI.GetFullExit(valCtx, validatorPubKeyHex, cl.GetInitialMutationHash(), shareIdx, identityKey)
 			if err != nil {
-				if errors.Is(err, obolapi.ErrNoExit) {
+				if errors.Is(err, obolapi.ErrNoValue) {
 					log.Warn(ctx, fmt.Sprintf("full exit data from Obol API for validator %v not available (validator may not be activated)", validatorPubKeyHex), nil)
 					continue
 				}
diff --git a/core/bcast/bcast.go b/core/bcast/bcast.go
index d4c43fa2..6d6c66a3 100644
--- a/core/bcast/bcast.go
+++ b/core/bcast/bcast.go
@@ -213,28 +213,8 @@ func (b Broadcaster) Broadcast(ctx context.Context, duty core.Duty, set core.Sig
 		return core.ErrDeprecatedDutyBuilderProposer
 
 	case core.DutyBuilderRegistration:
-		slot, err := firstSlotInCurrentEpoch(ctx, b.eth2Cl)
-		if err != nil {
-			return errors.Wrap(err, "calculate first slot in epoch")
-		}
-
-		// Use first slot in current epoch for accurate delay calculations while submitting builder registrations.
-		// This is because builder registrations are submitted in first slot of every epoch.
-		duty.Slot = slot
-
-		registrations, err := setToRegistrations(set)
-		if err != nil {
-			return err
-		}
-
-		err = b.eth2Cl.SubmitValidatorRegistrations(ctx, registrations)
-		if err == nil {
-			log.Info(ctx, "Successfully submitted validator registrations to beacon node",
-				z.Any("delay", b.delayFunc(duty.Slot, core.DutyBuilderRegistration)),
-			)
-		}
-
-		return err
+		// Builder registrations are submitted by the scheduler.
+		return nil
 
 	case core.DutyExit:
 		var err error // Try submitting all exits and return last error.
@@ -360,21 +340,6 @@ func setToAggAndProof(set core.SignedDataSet) (*eth2api.SubmitAggregateAttestati
 	return &eth2api.SubmitAggregateAttestationsOpts{SignedAggregateAndProofs: resp}, nil
 }
 
-// setToRegistrations converts a set of signed data into a list of registrations.
-func setToRegistrations(set core.SignedDataSet) ([]*eth2api.VersionedSignedValidatorRegistration, error) {
-	var resp []*eth2api.VersionedSignedValidatorRegistration
-	for _, reg := range set {
-		reg, ok := reg.(core.VersionedSignedValidatorRegistration)
-		if !ok {
-			return nil, errors.New("invalid registration")
-		}
-
-		resp = append(resp, &reg.VersionedSignedValidatorRegistration)
-	}
-
-	return resp, nil
-}
-
 // setToOne converts a set of signed data into a single signed data.
 func setToOne(set core.SignedDataSet) (core.PubKey, core.SignedData, error) {
 	if len(set) != 1 {
@@ -431,25 +396,6 @@ func newDelayFunc(ctx context.Context, eth2Cl eth2wrap.Client) (func(slot uint64
 	}, nil
 }
 
-// firstSlotInCurrentEpoch calculates first slot number of the current ongoing epoch.
-func firstSlotInCurrentEpoch(ctx context.Context, eth2Cl eth2wrap.Client) (uint64, error) {
-	genesisTime, err := eth2wrap.FetchGenesisTime(ctx, eth2Cl)
-	if err != nil {
-		return 0, err
-	}
-
-	slotDuration, slotsPerEpoch, err := eth2wrap.FetchSlotsConfig(ctx, eth2Cl)
-	if err != nil {
-		return 0, err
-	}
-
-	chainAge := time.Since(genesisTime)
-	currentSlot := chainAge / slotDuration
-	currentEpoch := uint64(currentSlot) / slotsPerEpoch
-
-	return currentEpoch * slotsPerEpoch, nil
-}
-
 // resolveActiveValidatorsIndices returns the active validators (including their validator index) for the slot.
 func resolveActiveValidatorsIndices(ctx context.Context, eth2Cl eth2wrap.Client, epoch eth2p0.Epoch) ([]eth2p0.ValidatorIndex, error) {
 	eth2Resp, err := eth2Cl.CompleteValidators(ctx)
diff --git a/core/bcast/metrics.go b/core/bcast/metrics.go
index 18bfdfb4..0ce1e52e 100644
--- a/core/bcast/metrics.go
+++ b/core/bcast/metrics.go
@@ -26,27 +26,6 @@ var (
 		Help:      "Duty broadcast delay since the expected duty submission in seconds by type",
 		Buckets:   []float64{.05, .1, .25, .5, 1, 2.5, 5, 10, 20, 30, 60},
 	}, []string{"duty"})
-
-	recastRegistrationCounter = promauto.NewCounterVec(prometheus.CounterOpts{
-		Namespace: "core",
-		Subsystem: "bcast",
-		Name:      "recast_registration_total",
-		Help:      "The total number of unique validator registration stored in recaster per pubkey",
-	}, []string{"pubkey"})
-
-	recastTotal = promauto.NewCounterVec(prometheus.CounterOpts{
-		Namespace: "core",
-		Subsystem: "bcast",
-		Name:      "recast_total",
-		Help:      "The total count of recasted registrations by source; 'pregen' vs 'downstream'",
-	}, []string{"source"})
-
-	recastErrors = promauto.NewCounterVec(prometheus.CounterOpts{
-		Namespace: "core",
-		Subsystem: "bcast",
-		Name:      "recast_errors_total",
-		Help:      "The total count of failed recasted registrations by source; 'pregen' vs 'downstream'",
-	}, []string{"source"})
 )
 
 // instrumentDuty increments the duty counter.
diff --git a/core/bcast/recast.go b/core/bcast/recast.go
deleted file mode 100644
index 564de4dc..00000000
--- a/core/bcast/recast.go
+++ /dev/null
@@ -1,178 +0,0 @@
-// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
-
-package bcast
-
-import (
-	"context"
-	"sync"
-
-	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
-	"github.com/prometheus/client_golang/prometheus"
-
-	"github.com/obolnetwork/charon/app/errors"
-	"github.com/obolnetwork/charon/app/log"
-	"github.com/obolnetwork/charon/app/z"
-	"github.com/obolnetwork/charon/core"
-)
-
-const (
-	// regSourcePregen defines a pregenerated registration.
-	regSourcePregen = "pregen"
-	// regSourceDownstream defines a registration submitted by a downstream VC.
-	regSourceDownstream = "downstream"
-)
-
-type recastTuple struct {
-	duty    core.Duty
-	aggData core.SignedData
-}
-
-// NewRecaster returns a new recaster.
-func NewRecaster(activeValsFunc func(context.Context) (map[eth2p0.BLSPubKey]struct{}, error)) (*Recaster, error) {
-	if activeValsFunc == nil {
-		return nil, errors.New("active validators provider is nil")
-	}
-
-	return &Recaster{
-		tuples:         make(map[core.PubKey]recastTuple),
-		activeValsFunc: activeValsFunc,
-	}, nil
-}
-
-// Recaster rebroadcasts core.DutyBuilderRegistration aggregate signatures every epoch.
-type Recaster struct {
-	mu             sync.Mutex
-	tuples         map[core.PubKey]recastTuple
-	activeValsFunc func(context.Context) (map[eth2p0.BLSPubKey]struct{}, error)
-	subs           []func(context.Context, core.Duty, core.SignedDataSet) error
-}
-
-// Subscribe subscribes to rebroadcasted duties.
-func (r *Recaster) Subscribe(sub func(context.Context, core.Duty, core.SignedDataSet) error) {
-	r.mu.Lock()
-	defer r.mu.Unlock()
-
-	r.subs = append(r.subs, sub)
-}
-
-// Store stores aggregate signed duty registrations for rebroadcasting.
-func (r *Recaster) Store(ctx context.Context, duty core.Duty,
-	set core.SignedDataSet,
-) error {
-	if duty.Type != core.DutyBuilderRegistration {
-		return nil
-	}
-
-	for pubkey, aggData := range set {
-		if err := r.store(ctx, duty, pubkey, aggData); err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
-
-// store stores aggregate signed duty registrations for rebroadcasting.
-func (r *Recaster) store(_ context.Context, duty core.Duty,
-	pubkey core.PubKey, aggData core.SignedData,
-) error {
-	r.mu.Lock()
-	defer r.mu.Unlock()
-
-	tuple, ok := r.tuples[pubkey]
-	if ok && tuple.duty.Slot >= duty.Slot {
-		// Not storing duplicate or older registration.
-		return nil
-	}
-
-	// Clone before storing.
-	data, err := aggData.Clone()
-	if err != nil {
-		return err
-	}
-
-	r.tuples[pubkey] = recastTuple{
-		duty:    duty,
-		aggData: data,
-	}
-
-	// Add unique registrations count.
-	recastRegistrationCounter.WithLabelValues(pubkey.String()).Inc()
-
-	return nil
-}
-
-// SlotTicked is called when new slots tick.
-func (r *Recaster) SlotTicked(ctx context.Context, slot core.Slot) error {
-	if !slot.FirstInEpoch() {
-		return nil
-	}
-
-	ctx = log.WithTopic(ctx, "bcast")
-
-	activeVals, err := r.activeValsFunc(ctx)
-	if err != nil {
-		return errors.Wrap(err, "get active validator")
-	}
-
-	// Copy locked things before doing IO.
-	var (
-		clonedSets = make(map[core.Duty]map[core.PubKey]core.SignedData)
-		clonedSubs []func(context.Context, core.Duty, core.SignedDataSet) error
-	)
-
-	r.mu.Lock()
-
-	clonedSubs = append(clonedSubs, r.subs...)
-	for pubkey, tuple := range r.tuples {
-		ethPk, err := pubkey.ToETH2()
-		if err != nil {
-			log.Error(ctx, "Failed to convert pubkey to eth2 format", err)
-			continue
-		}
-
-		if _, found := activeVals[ethPk]; !found {
-			continue
-		}
-
-		set, ok := clonedSets[tuple.duty]
-		if !ok {
-			set = make(core.SignedDataSet)
-			clonedSets[tuple.duty] = set
-		}
-
-		set[pubkey] = tuple.aggData
-	}
-
-	r.mu.Unlock()
-
-	for duty, set := range clonedSets {
-		dutyCtx := log.WithCtx(ctx, z.Any("duty", duty))
-
-		for _, sub := range clonedSubs {
-			err := sub(dutyCtx, duty, set)
-			if err != nil {
-				log.Error(dutyCtx, "Failed to rebroadcast duty. Will retry in the next epoch", err)
-				incRegCounter(duty, recastErrors)
-			}
-
-			incRegCounter(duty, recastTotal)
-		}
-	}
-
-	return nil
-}
-
-// incRegCounter increments the registration counter if applicable.
-func incRegCounter(duty core.Duty, counterVec *prometheus.CounterVec) {
-	if duty.Type != core.DutyBuilderRegistration {
-		return
-	}
-
-	source := regSourcePregen
-	if duty.Slot > 0 {
-		source = regSourceDownstream
-	}
-
-	counterVec.WithLabelValues(source).Inc()
-}
diff --git a/core/scheduler/metrics.go b/core/scheduler/metrics.go
index 99aa91d7..87853285 100644
--- a/core/scheduler/metrics.go
+++ b/core/scheduler/metrics.go
@@ -62,6 +62,20 @@ var (
 		Name:      "skipped_slots_total",
 		Help:      "Total number times slots were skipped",
 	})
+
+	submitRegistrationCounter = promauto.NewCounter(prometheus.CounterOpts{
+		Namespace: "core",
+		Subsystem: "scheduler",
+		Name:      "submit_registration_total",
+		Help:      "The total number of submit registration requests",
+	})
+
+	submitRegistrationErrors = promauto.NewCounter(prometheus.CounterOpts{
+		Namespace: "core",
+		Subsystem: "scheduler",
+		Name:      "submit_registration_errors_total",
+		Help:      "The total count of failed submit registration requests",
+	})
 )
 
 // instrumentSlot sets the current slot and epoch metrics.
diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go
index 29e8e0f6..91cb96bc 100644
--- a/core/scheduler/scheduler.go
+++ b/core/scheduler/scheduler.go
@@ -11,6 +11,8 @@ import (
 	"time"
 
 	eth2api "github.com/attestantio/go-eth2-client/api"
+	eth2v1 "github.com/attestantio/go-eth2-client/api/v1"
+	eth2spec "github.com/attestantio/go-eth2-client/spec"
 	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
 	"github.com/jonboulle/clockwork"
 	"github.com/stretchr/testify/require"
@@ -22,6 +24,7 @@ import (
 	"github.com/obolnetwork/charon/app/featureset"
 	"github.com/obolnetwork/charon/app/log"
 	"github.com/obolnetwork/charon/app/z"
+	"github.com/obolnetwork/charon/cluster"
 	"github.com/obolnetwork/charon/core"
 )
 
@@ -35,12 +38,12 @@ type delayFunc func(duty core.Duty, deadline time.Time) <-chan time.Time
 type schedSlotFunc func(ctx context.Context, slot core.Slot)
 
 // NewForT returns a new scheduler for testing using a fake clock.
-func NewForT(t *testing.T, clock clockwork.Clock, delayFunc delayFunc, pubkeys []core.PubKey,
-	eth2Cl eth2wrap.Client, schedSlotFunc schedSlotFunc,
+func NewForT(t *testing.T, clock clockwork.Clock, delayFunc delayFunc, builderRegistrations []cluster.BuilderRegistration,
+	eth2Cl eth2wrap.Client, schedSlotFunc schedSlotFunc, builderEnabled bool,
 ) *Scheduler {
 	t.Helper()
 
-	s, err := New(pubkeys, eth2Cl, false)
+	s, err := New(builderRegistrations, eth2Cl, builderEnabled)
 	require.NoError(t, err)
 
 	s.clock = clock
@@ -51,15 +54,40 @@ func NewForT(t *testing.T, clock clockwork.Clock, delayFunc delayFunc, pubkeys [
 }
 
 // New returns a new scheduler.
-func New(pubkeys []core.PubKey, eth2Cl eth2wrap.Client, builderEnabled bool) (*Scheduler, error) {
+func New(builderRegistrations []cluster.BuilderRegistration, eth2Cl eth2wrap.Client, builderEnabled bool) (*Scheduler, error) {
+	registrations := make([]*eth2api.VersionedSignedValidatorRegistration, 0, len(builderRegistrations))
+
+	for _, builderRegistration := range builderRegistrations {
+		regMessage := &eth2v1.ValidatorRegistration{
+			Timestamp: builderRegistration.Message.Timestamp,
+			GasLimit:  uint64(builderRegistration.Message.GasLimit),
+		}
+
+		copy(regMessage.FeeRecipient[:], builderRegistration.Message.FeeRecipient)
+		copy(regMessage.Pubkey[:], builderRegistration.Message.PubKey)
+
+		var signature eth2p0.BLSSignature
+		copy(signature[:], builderRegistration.Signature)
+
+		registration := &eth2v1.SignedValidatorRegistration{
+			Message:   regMessage,
+			Signature: signature,
+		}
+
+		registrations = append(registrations, &eth2api.VersionedSignedValidatorRegistration{
+			Version: eth2spec.BuilderVersionV1,
+			V1:      registration,
+		})
+	}
+
 	return &Scheduler{
-		eth2Cl:        eth2Cl,
-		pubkeys:       pubkeys,
-		quit:          make(chan struct{}),
-		duties:        make(map[core.Duty]core.DutyDefinitionSet),
-		dutiesByEpoch: make(map[uint64][]core.Duty),
-		epochResolved: make(map[uint64]chan struct{}),
-		clock:         clockwork.NewRealClock(),
+		eth2Cl:               eth2Cl,
+		builderRegistrations: registrations,
+		quit:                 make(chan struct{}),
+		duties:               make(map[core.Duty]core.DutyDefinitionSet),
+		dutiesByEpoch:        make(map[uint64][]core.Duty),
+		epochResolved:        make(map[uint64]chan struct{}),
+		clock:                clockwork.NewRealClock(),
 		delayFunc: func(_ core.Duty, deadline time.Time) <-chan time.Time {
 			return time.After(time.Until(deadline))
 		},
@@ -71,22 +99,23 @@ func New(pubkeys []core.PubKey, eth2Cl eth2wrap.Client, builderEnabled bool) (*S
 }
 
 type Scheduler struct {
-	eth2Cl          eth2wrap.Client
-	pubkeys         []core.PubKey
-	quit            chan struct{}
-	clock           clockwork.Clock
-	delayFunc       delayFunc
-	metricSubmitter metricSubmitter
-	resolvedEpoch   uint64
-	resolvingEpoch  uint64
-	duties          map[core.Duty]core.DutyDefinitionSet
-	dutiesByEpoch   map[uint64][]core.Duty
-	dutiesMutex     sync.RWMutex
-	dutySubs        []func(context.Context, core.Duty, core.DutyDefinitionSet) error
-	slotSubs        []func(context.Context, core.Slot) error
-	builderEnabled  bool
-	schedSlotFunc   schedSlotFunc
-	epochResolved   map[uint64]chan struct{} // Notification channels for epoch resolution
+	eth2Cl                     eth2wrap.Client
+	builderRegistrations       []*eth2api.VersionedSignedValidatorRegistration
+	submittedRegistrationEpoch uint64
+	quit                       chan struct{}
+	clock                      clockwork.Clock
+	delayFunc                  delayFunc
+	metricSubmitter            metricSubmitter
+	resolvedEpoch              uint64
+	resolvingEpoch             uint64
+	duties                     map[core.Duty]core.DutyDefinitionSet
+	dutiesByEpoch              map[uint64][]core.Duty
+	dutiesMutex                sync.RWMutex
+	dutySubs                   []func(context.Context, core.Duty, core.DutyDefinitionSet) error
+	slotSubs                   []func(context.Context, core.Slot) error
+	builderEnabled             bool
+	schedSlotFunc              schedSlotFunc
+	epochResolved              map[uint64]chan struct{} // Notification channels for epoch resolution
 }
 
 // SubscribeDuties subscribes a callback function for triggered duties.
@@ -222,12 +251,18 @@ func (s *Scheduler) scheduleSlot(ctx context.Context, slot core.Slot) {
 	if s.getResolvedEpoch() != slot.Epoch() {
 		log.Debug(ctx, "Resolving duties for slot", z.U64("slot", slot.Slot), z.U64("epoch", slot.Epoch()))
 
-		err := s.resolveDuties(ctx, slot)
-		if err != nil {
+		if err := s.resolveDuties(ctx, slot); err != nil {
 			log.Warn(ctx, "Resolving duties error (retrying next slot)", err, z.U64("slot", slot.Slot))
 		}
 	}
 
+	// Safe to access s.submittedRegistrationEpoch without lock because scheduleSlot is called by the same goroutine.
+	if s.builderEnabled && s.submittedRegistrationEpoch != slot.Epoch() {
+		if err := s.submitValidatorRegistrations(ctx); err == nil {
+			s.submittedRegistrationEpoch = slot.Epoch()
+		}
+	}
+
 	for _, dutyType := range core.AllDutyTypes() {
 		duty := core.Duty{
 			Slot: slot.Slot,
@@ -657,6 +692,22 @@ func (s *Scheduler) trimDuties(epoch uint64) {
 	delete(s.dutiesByEpoch, epoch)
 }
 
+// submitValidatorRegistrations submits the validator registrations for all DVs.
+func (s *Scheduler) submitValidatorRegistrations(ctx context.Context) error {
+	submitRegistrationCounter.Add(1)
+
+	err := s.eth2Cl.SubmitValidatorRegistrations(ctx, s.builderRegistrations)
+	if err != nil {
+		submitRegistrationErrors.Add(1)
+
+		log.Error(ctx, "Failed to submit validator registrations", err)
+	} else {
+		log.Info(ctx, "Submitted validator registrations", z.Int("count", len(s.builderRegistrations)))
+	}
+
+	return err
+}
+
 // newSlotTicker returns a blocking channel that will be populated with new slots in real time.
 // It is also populated with the current slot immediately.
 func newSlotTicker(ctx context.Context, eth2Cl eth2wrap.Client, clock clockwork.Clock) (<-chan core.Slot, error) {
diff --git a/core/validatorapi/eth2types.go b/core/validatorapi/eth2types.go
index d4291d64..dc0a765a 100644
--- a/core/validatorapi/eth2types.go
+++ b/core/validatorapi/eth2types.go
@@ -147,42 +147,3 @@ type nodeVersionResponse struct {
 		Version string `json:"version"`
 	} `json:"data"`
 }
-
-// SignedValidatorRegistrations defines the request body to the submit validator registration endpoint.
-// See: https://ethereum.github.io/beacon-APIs/#/Validator/registerValidator
-// Implements the ssz.Unmarshal interface
-type signedValidatorRegistrations struct {
-	Registrations []*eth2v1.SignedValidatorRegistration
-}
-
-// UnmarshalSSZ unmarshals an array of eth2v1.SignedValidatorRegistration SSZ marshaled objects
-func (s *signedValidatorRegistrations) UnmarshalSSZ(buf []byte) error {
-	const sszObjectSize = 180
-
-	if len(buf)%sszObjectSize != 0 {
-		return errors.New("invalid buffer size")
-	}
-
-	for offset := 0; offset < len(buf); offset += sszObjectSize {
-		registration := new(eth2v1.SignedValidatorRegistration)
-
-		err := registration.UnmarshalSSZ(buf[offset : offset+sszObjectSize])
-		if err != nil {
-			return errors.Wrap(err, "unmarshal wrapped signed validator registrations")
-		}
-
-		s.Registrations = append(s.Registrations, registration)
-	}
-
-	return nil
-}
-
-// UnmarshalJSON unmarshals an array of eth2v1.SignedValidatorRegistration JSON marshaled objects
-func (s *signedValidatorRegistrations) UnmarshalJSON(buf []byte) error {
-	err := json.Unmarshal(buf, &s.Registrations)
-	if err != nil {
-		return errors.Wrap(err, "unmarshal wrapped signed validator registrations")
-	}
-
-	return nil
-}
diff --git a/core/validatorapi/router.go b/core/validatorapi/router.go
index a48afe44..696f1a5d 100644
--- a/core/validatorapi/router.go
+++ b/core/validatorapi/router.go
@@ -1280,22 +1280,10 @@ func submitBlindedBlock(p eth2client.BlindedProposalSubmitter) handlerFunc {
 }
 
 // submitValidatorRegistrations returns a handler function for the validator (builder) registration submitter endpoint.
-func submitValidatorRegistrations(r eth2client.ValidatorRegistrationsSubmitter) handlerFunc {
-	return func(ctx context.Context, _ map[string]string, _ http.Header, _ url.Values, typ contentType, body []byte) (any, http.Header, error) {
-		var unversioned signedValidatorRegistrations
-		if err := unmarshal(typ, body, &unversioned); err != nil {
-			return nil, nil, errors.Wrap(err, "unmarshal signed builder registration")
-		}
-
-		var versioned []*eth2api.VersionedSignedValidatorRegistration
-		for _, registration := range unversioned.Registrations {
-			versioned = append(versioned, &eth2api.VersionedSignedValidatorRegistration{
-				Version: eth2spec.BuilderVersionV1,
-				V1:      registration,
-			})
-		}
-
-		return nil, nil, r.SubmitValidatorRegistrations(ctx, versioned)
+func submitValidatorRegistrations(_ eth2client.ValidatorRegistrationsSubmitter) handlerFunc {
+	return func(_ context.Context, _ map[string]string, _ http.Header, _ url.Values, _ contentType, _ []byte) (any, http.Header, error) {
+		// Charon submits validator registrations itself, so we effectively ignore these requests.
+		return nil, nil, nil
 	}
 }
 
diff --git a/core/validatorapi/validatorapi.go b/core/validatorapi/validatorapi.go
index 5d1c2152..ccf19e50 100644
--- a/core/validatorapi/validatorapi.go
+++ b/core/validatorapi/validatorapi.go
@@ -670,80 +670,8 @@ func (c Component) SubmitBlindedProposal(ctx context.Context, opts *eth2api.Subm
 	return nil
 }
 
-// submitRegistration receives the partially signed validator (builder) registration.
-func (c Component) submitRegistration(ctx context.Context, registration *eth2api.VersionedSignedValidatorRegistration) error {
-	// Note this should be the group pubkey
-	eth2Pubkey, err := registration.PubKey()
-	if err != nil {
-		return err
-	}
-
-	pubkey, err := core.PubKeyFromBytes(eth2Pubkey[:])
-	if err != nil {
-		return err
-	}
-
-	if _, ok := c.getPubShareFunc(eth2Pubkey); !ok {
-		log.Debug(ctx, "Ignoring validator registration for non-distributed validator. "+
-			"This is expected behavior for many validator clients", z.Any("pubkey", pubkey), c.swallowRegFilter)
-
-		return nil
-	}
-
-	timestamp, err := registration.Timestamp()
-	if err != nil {
-		return err
-	}
-
-	slot, err := SlotFromTimestamp(ctx, c.eth2Cl, timestamp)
-	if err != nil {
-		return err
-	}
-
-	duty := core.NewBuilderRegistrationDuty(uint64(slot))
-	ctx = log.WithCtx(ctx, z.Any("duty", duty))
-
-	signedData, err := core.NewPartialVersionedSignedValidatorRegistration(registration, c.shareIdx)
-	if err != nil {
-		return err
-	}
-
-	// Verify registration signature.
-	err = c.verifyPartialSig(ctx, signedData, pubkey)
-	if err != nil {
-		return err
-	}
-
-	// TODO(corver): Batch these for improved network performance
-	set := core.ParSignedDataSet{pubkey: signedData}
-	for _, sub := range c.subs {
-		// No need to clone since sub auto clones.
-		err = sub(ctx, duty, set)
-		if err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
-
-// SubmitValidatorRegistrations receives the partially signed validator (builder) registration.
-func (c Component) SubmitValidatorRegistrations(ctx context.Context, registrations []*eth2api.VersionedSignedValidatorRegistration) error {
-	if len(registrations) == 0 {
-		return nil // Nothing to do
-	}
-
-	// Swallow unexpected validator registrations from VCs (for ex: vouch)
-	if !c.builderEnabled {
-		return nil
-	}
-
-	for _, registration := range registrations {
-		err := c.submitRegistration(ctx, registration)
-		if err != nil {
-			return err
-		}
-	}
+func (Component) SubmitValidatorRegistrations(ctx context.Context, _ []*eth2api.VersionedSignedValidatorRegistration) error {
+	log.Info(ctx, "Submitting validator registrations is ignored. Charon submits validator registrations directly to the beacon node.")
 
 	return nil
 }
diff --git a/docs/architecture.md b/docs/architecture.md
index 83ad5aea..41dce45b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -149,6 +149,20 @@ The new v3 endpoint specification added a few new parameters and this is how Cha
 > ℹ️ The change in serialization (both json and ssz) introduced *a breaking change* in the internal protocol.
 Therefore, Charon v1.x will not work together with Charon v0.x. See *Version compatibility* section of `README.md` for more details.
 
+### DutyBuilderRegistration redesign
+
+Since Charon v1.8.0, the `/eth/v1/validator/register_validator` validator API endpoint has no effect.
+This endpoint now always returns HTTP 200 OK without any processing.
+Now, Charon's Scheduler component is responsible for submitting builder registrations at the right times:
+
+- At startup, it submits registrations for all DVs in the cluster (found in cluster-lock.json).
+- Thereafter, it submits registrations in the first slot of every epoch.
+
+During the transition period, a cluster running a mix of new and old Charon versions may experience "consensus timeout" errors,
+because the new version will not participate in consensus for the `DutyBuilderRegistration` duty.
+
+Also, the old metrics `core_bcast_recast_registration_total`, `core_bcast_recast_total`, and `core_bcast_recast_errors_total` are removed in favor of new metrics: `core_scheduler_submit_registration_total` and `core_scheduler_submit_registration_errors_total`.
+
 ### Scheduler
 
 The scheduler is the initiator of a duty in the core workflow. It resolves which DVs in the cluster are active and
diff --git a/docs/metrics.md b/docs/metrics.md
index 6a1c36f7..a13250c6 100644
--- a/docs/metrics.md
+++ b/docs/metrics.md
@@ -49,9 +49,6 @@ when storing metrics from multiple nodes or clusters in one Prometheus instance.
 | `cluster_validators` | Gauge | Number of validators in the cluster lock |  |
 | `core_bcast_broadcast_delay_seconds` | Histogram | Duty broadcast delay since the expected duty submission in seconds by type | `duty` |
 | `core_bcast_broadcast_total` | Counter | The total count of successfully broadcast duties by type | `duty` |
-| `core_bcast_recast_errors_total` | Counter | The total count of failed recasted registrations by source; `pregen` vs `downstream` | `source` |
-| `core_bcast_recast_registration_total` | Counter | The total number of unique validator registration stored in recaster per pubkey | `pubkey` |
-| `core_bcast_recast_total` | Counter | The total count of recasted registrations by source; `pregen` vs `downstream` | `source` |
 | `core_consensus_decided_leader_index` | Gauge | Index of the decided leader by protocol and duty | `protocol, duty` |
 | `core_consensus_decided_rounds` | Gauge | Number of decided rounds by protocol, duty, and timer | `protocol, duty, timer` |
 | `core_consensus_duration_seconds` | Histogram | Duration of the consensus process by protocol, duty, and timer | `protocol, duty, timer` |
@@ -62,6 +59,8 @@ when storing metrics from multiple nodes or clusters in one Prometheus instance.
 | `core_scheduler_current_slot` | Gauge | The current slot |  |
 | `core_scheduler_duty_total` | Counter | The total count of duties scheduled by type | `duty` |
 | `core_scheduler_skipped_slots_total` | Counter | Total number times slots were skipped |  |
+| `core_scheduler_submit_registration_errors_total` | Counter | The total count of failed submit registration requests |  |
+| `core_scheduler_submit_registration_total` | Counter | The total number of submit registration requests |  |
 | `core_scheduler_validator_balance_gwei` | Gauge | Total balance of a validator by public key | `pubkey_full, pubkey` |
 | `core_scheduler_validator_status` | Gauge | Gauge with validator pubkey and status as labels, value=1 is current status, value=0 is previous. | `pubkey_full, pubkey, status` |
 | `core_scheduler_validators_active` | Gauge | Number of active validators |  |
diff --git a/go.mod b/go.mod
index b8f99433..6136f0d8 100644
--- a/go.mod
+++ b/go.mod
@@ -92,7 +92,7 @@ require (
 	github.com/cockroachdb/pebble v1.1.5 // indirect
 	github.com/cockroachdb/redact v1.1.6 // indirect
 	github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect
-	github.com/consensys/gnark-crypto v0.18.0 // indirect
+	github.com/consensys/gnark-crypto v0.18.1 // indirect
 	github.com/containerd/errdefs v1.0.0 // indirect
 	github.com/containerd/errdefs/pkg v0.3.0 // indirect
 	github.com/containerd/stargz-snapshotter/estargz v0.17.0 // indirect
diff --git a/go.sum b/go.sum
index 7c416677..e1cf5731 100644
--- a/go.sum
+++ b/go.sum
@@ -101,8 +101,8 @@ github.com/cockroachdb/redact v1.1.6 h1:zXJBwDZ84xJNlHl1rMyCojqyIxv+7YUpQiJLQ7n4
 github.com/cockroachdb/redact v1.1.6/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
 github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
 github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
-github.com/consensys/gnark-crypto v0.18.0 h1:vIye/FqI50VeAr0B3dx+YjeIvmc3LWz4yEfbWBpTUf0=
-github.com/consensys/gnark-crypto v0.18.0/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c=
+github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI=
+github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c=
 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=



Test patch:
diff --git a/app/eth1wrap/eth1wrap_internal_test.go b/app/eth1wrap/eth1wrap_internal_test.go
index 9609f11d..df788e8d 100644
--- a/app/eth1wrap/eth1wrap_internal_test.go
+++ b/app/eth1wrap/eth1wrap_internal_test.go
@@ -101,7 +101,11 @@ func TestMagicValue(t *testing.T) {
 }
 
 func TestERC1271Implementation(t *testing.T) {
-	createdConnection := make(chan struct{})
+	var (
+		testContract = "0x123"
+		testHash     = [32]byte{0x1, 0x2, 0x3}
+		testSig      = []byte{0x4, 0x5, 0x6}
+	)
 
 	ecMock := mocks.NewEthClient(t)
 	ecMock.On("Close").Return().Once()
@@ -114,21 +118,18 @@ func TestERC1271Implementation(t *testing.T) {
 			sig := args.Get(2).([]byte)
 
 			require.Nil(t, opts, "Opts should be nil")
-			require.Equal(t, [32]byte{1, 2, 3}, hash, "Hash should be passed correctly")
-			require.Equal(t, []byte{4, 5, 6}, sig, "Signature should be passed correctly")
+			require.Equal(t, testHash, hash, "Hash should be passed correctly")
+			require.Equal(t, testSig, sig, "Signature should be passed correctly")
 		}).
-		Return(erc1271MagicValue, nil).Once()
-
-	doneCh := make(chan struct{})
+		Return(erc1271MagicValue, nil).Maybe() // can be called multiple times due to polling
 
 	client := NewEthClientRunner(
 		"",
 		func(ctx context.Context, rawurl string) (EthClient, error) {
-			close(createdConnection)
 			return ecMock, nil
 		},
 		func(contractAddress string, eth1Client EthClient) (Erc1271, error) {
-			require.Equal(t, "0x123", contractAddress, "Contract address should be passed to factory")
+			require.Equal(t, testContract, contractAddress, "Contract address should be passed to factory")
 			require.Equal(t, ecMock, eth1Client, "Eth1Client should be passed to factory")
 
 			return mockErc1271, nil
@@ -136,28 +137,29 @@ func TestERC1271Implementation(t *testing.T) {
 	)
 
 	// Start the client
-	ctx, cancel := context.WithCancel(context.Background())
+	ctx, cancel := context.WithCancel(t.Context())
+	doneCh := make(chan struct{})
 
 	go func() {
 		client.Run(ctx)
 		close(doneCh)
 	}()
 
-	// Wait for client to initialize
-	<-createdConnection
-
-	hash := [32]byte{1, 2, 3}
-	sig := []byte{4, 5, 6}
-	valid, err := client.VerifySmartContractBasedSignature("0x123", hash, sig)
+	// Wait for client to be connected by polling until it's ready
+	require.Eventually(t, func() bool {
+		valid, err := client.VerifySmartContractBasedSignature(testContract, testHash, testSig)
+		return valid && err == nil
+	}, 1*time.Second, 10*time.Millisecond, "Client should eventually be connected")
 
-	require.NoError(t, err, "Should not return an error")
-	require.True(t, valid, "Signature should be valid")
+	require.NotEmpty(t, mockErc1271.Calls)
 
 	cancel()
-	require.Eventually(t, func() bool {
-		<-doneCh
-		return true
-	}, 1*time.Second, 10*time.Millisecond)
+
+	select {
+	case <-doneCh:
+	case <-time.After(1 * time.Second):
+		require.Fail(t, "Client did not shut down in time")
+	}
 }
 
 func TestNoopClientCreation(t *testing.T) {
diff --git a/app/eth1wrap/eth1wrap_test.go b/app/eth1wrap/eth1wrap_test.go
index 75cf77e1..129f890d 100644
--- a/app/eth1wrap/eth1wrap_test.go
+++ b/app/eth1wrap/eth1wrap_test.go
@@ -115,24 +115,12 @@ func TestClientRun(t *testing.T) {
 	// 4. Reconnects
 	// 5. Second call to ERC1271 succeeds
 	t.Run("reconnect when connection lost", func(t *testing.T) {
-		// This is a known flaky test: BlockNumber & Close mocks were not called.
-		t.SkipNow()
+		faultyClient := mocks.NewEthClient(t)
+		faultyClient.On("BlockNumber", mock.Anything).Return(uint64(0), errors.New("connection lost")).Maybe()
+		faultyClient.On("Close").Return().Maybe()
 
-		createFaultyClient := func() eth1wrap.EthClient {
-			mockEth1Client := mocks.NewEthClient(t)
-			mockEth1Client.On("BlockNumber", mock.Anything).Return(uint64(0),
-				errors.New("connection lost")).Once()
-			mockEth1Client.On("Close").Return().Once()
-
-			return mockEth1Client
-		}
-
-		createGoodClient := func() eth1wrap.EthClient {
-			mockEth1Client := mocks.NewEthClient(t)
-			mockEth1Client.On("Close").Return().Once()
-
-			return mockEth1Client
-		}
+		goodClient := mocks.NewEthClient(t)
+		goodClient.On("Close").Return().Maybe()
 
 		var connectionAttempts atomic.Int32
 
@@ -142,26 +130,26 @@ func TestClientRun(t *testing.T) {
 				connectionAttempts.Add(1)
 
 				if connectionAttempts.Load() == 1 {
-					return createFaultyClient(), nil
+					return faultyClient, nil
 				}
 
-				return createGoodClient(), nil
+				return goodClient, nil
 			},
 			func(contractAddress string, eth1Client eth1wrap.EthClient) (eth1wrap.Erc1271, error) {
 				mockErc1271 := mocks.NewErc1271(t)
 				if connectionAttempts.Load() == 1 {
 					mockErc1271.On("IsValidSignature", mock.Anything, mock.Anything, mock.Anything).
-						Return([4]byte{}, errors.New("connection lost")).Once()
+						Return([4]byte{}, errors.New("connection lost")).Maybe()
 				} else {
 					mockErc1271.On("IsValidSignature", mock.Anything, mock.Anything, mock.Anything).
-						Return([4]byte{0x16, 0x26, 0xba, 0x7e}, nil).Once()
+						Return([4]byte{0x16, 0x26, 0xba, 0x7e}, nil).Maybe()
 				}
 
 				return mockErc1271, nil
 			},
 		)
 
-		ctx, cancel := context.WithCancel(context.Background())
+		ctx, cancel := context.WithCancel(t.Context())
 		doneCh := make(chan struct{})
 
 		// Run client in a goroutine
@@ -170,13 +158,23 @@ func TestClientRun(t *testing.T) {
 			close(doneCh)
 		}()
 
-		// Wait for the first connection attempt
+		// Wait for the first connection to be established and ready
 		require.Eventually(t, func() bool {
-			return connectionAttempts.Load() == 1
+			return connectionAttempts.Load() >= 1
 		}, 1*time.Second, 10*time.Millisecond)
 
 		// This should fail and trigger maybeReconnect()
-		valid, err := client.VerifySmartContractBasedSignature("0x123", [32]byte{}, []byte{})
+		var (
+			valid bool
+			err   error
+		)
+
+		require.Eventually(t, func() bool {
+			valid, err = client.VerifySmartContractBasedSignature("0x123", [32]byte{}, []byte{})
+			// Wait until we get a non-"not connected" error (i.e., the connection lost error)
+			return !errors.Is(err, eth1wrap.ErrEthClientNotConnected)
+		}, 1*time.Second, 10*time.Millisecond)
+
 		require.False(t, valid)
 		require.Error(t, err)
 
@@ -185,18 +183,24 @@ func TestClientRun(t *testing.T) {
 			return connectionAttempts.Load() == 2
 		}, 1*time.Second, 10*time.Millisecond)
 
-		// Now try again with the success mock
-		valid, err = client.VerifySmartContractBasedSignature("0x123", [32]byte{}, []byte{})
-		require.True(t, valid)
-		require.NoError(t, err)
+		// Now try again with the success mock - wait until it succeeds
+		require.Eventually(t, func() bool {
+			valid, err = client.VerifySmartContractBasedSignature("0x123", [32]byte{}, []byte{})
+			return err == nil && valid
+		}, 1*time.Second, 10*time.Millisecond)
 
-		require.EqualValues(t, connectionAttempts.Load(), 2, "Should attempt to reconnect after connection lost")
+		require.EqualValues(t, 2, connectionAttempts.Load(), "Should attempt to reconnect after connection lost")
 
 		cancel()
-		require.Eventually(t, func() bool {
-			<-doneCh
-			return true
-		}, 1*time.Second, 10*time.Millisecond)
+
+		select {
+		case <-doneCh:
+		case <-time.After(1 * time.Second):
+			require.Fail(t, "Client did not shut down in time")
+		}
+
+		require.NotEmpty(t, faultyClient)
+		require.NotEmpty(t, goodClient)
 	})
 }
 
diff --git a/app/health/checks_internal_test.go b/app/health/checks_internal_test.go
index 7d0cb559..cd05db0e 100644
--- a/app/health/checks_internal_test.go
+++ b/app/health/checks_internal_test.go
@@ -333,9 +333,7 @@ func TestWarnLogsCheck(t *testing.T) {
 func TestHighRegistrationFailuresRateCheck(t *testing.T) {
 	m := Metadata{}
 	checkName := "high_registration_failures_rate"
-	metricName := "core_bcast_recast_errors_total"
-	pregenLabel := genLabels("source", "pregen")
-	downsteamLabel := genLabels("source", "downstream")
+	metricName := "core_scheduler_submit_registration_errors_total"
 
 	t.Run("no data", func(t *testing.T) {
 		testCheck(t, m, checkName, false, nil)
@@ -344,33 +342,16 @@ func TestHighRegistrationFailuresRateCheck(t *testing.T) {
 	t.Run("same errors count", func(t *testing.T) {
 		testCheck(t, m, checkName, false,
 			genFam(metricName,
-				genGauge(pregenLabel, 1, 1, 1), // No increments
+				genCounter(genLabels(), 1, 1, 1), // No increments
 			),
 		)
 	})
 
-	t.Run("incrementing errors count", func(t *testing.T) {
+	t.Run("have increasing errors count", func(t *testing.T) {
 		testCheck(t, m, checkName, true,
 			genFam(metricName,
-				genGauge(downsteamLabel, 0, 1, 2, 10),
-			),
-		)
-	})
-
-	t.Run("both labels have stable errors count", func(t *testing.T) {
-		testCheck(t, m, checkName, false,
-			genFam(metricName,
-				genGauge(pregenLabel, 1, 1, 1),
-				genGauge(downsteamLabel, 1, 1, 1),
-			),
-		)
-	})
-
-	t.Run("both labels have increasing errors count", func(t *testing.T) {
-		testCheck(t, m, checkName, true,
-			genFam(metricName,
-				genGauge(pregenLabel, 10, 15, 18),
-				genGauge(downsteamLabel, 1, 2, 3),
+				genCounter(genLabels(), 10, 15, 18),
+				genCounter(genLabels(), 1, 2, 3),
 			),
 		)
 	})
diff --git a/app/obolapi/deposit_test.go b/app/obolapi/deposit_test.go
new file mode 100644
index 00000000..e5457de8
--- /dev/null
+++ b/app/obolapi/deposit_test.go
@@ -0,0 +1,90 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package obolapi_test
+
+import (
+	"context"
+	"encoding/hex"
+	"math/rand"
+	"net/http/httptest"
+	"testing"
+
+	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
+	"github.com/stretchr/testify/require"
+
+	"github.com/obolnetwork/charon/app/obolapi"
+	"github.com/obolnetwork/charon/cluster"
+	"github.com/obolnetwork/charon/eth2util"
+	"github.com/obolnetwork/charon/eth2util/deposit"
+	"github.com/obolnetwork/charon/tbls"
+	"github.com/obolnetwork/charon/testutil/beaconmock"
+	"github.com/obolnetwork/charon/testutil/obolapimock"
+)
+
+func TestAPIDeposit(t *testing.T) {
+	kn := 4
+
+	beaconMock, err := beaconmock.New(t.Context())
+	require.NoError(t, err)
+
+	defer func() {
+		require.NoError(t, beaconMock.Close())
+	}()
+
+	mockEth2Cl := eth2Client(t, context.Background(), beaconMock.Address())
+
+	handler, addLockFiles := obolapimock.MockServer(false, mockEth2Cl)
+	srv := httptest.NewServer(handler)
+
+	defer srv.Close()
+
+	random := rand.New(rand.NewSource(int64(0)))
+
+	lock, peers, shares := cluster.NewForT(
+		t,
+		1,
+		kn,
+		kn,
+		0,
+		random,
+	)
+
+	addLockFiles(lock)
+
+	wc, err := hex.DecodeString("010000000000000000000000000000000000000000000000000000000000dead")
+	require.NoError(t, err)
+
+	depositMessage := eth2p0.DepositMessage{
+		PublicKey:             eth2p0.BLSPubKey(lock.Validators[0].PubKey),
+		WithdrawalCredentials: wc,
+		Amount:                eth2p0.Gwei(deposit.OneEthInGwei * 32),
+	}
+
+	network, err := eth2util.ForkVersionToNetwork(lock.ForkVersion)
+	require.NoError(t, err)
+
+	depositMessageSigRoot, err := deposit.GetMessageSigningRoot(depositMessage, network)
+	require.NoError(t, err)
+
+	cl, err := obolapi.New(srv.URL)
+	require.NoError(t, err)
+
+	for idx := range len(peers) {
+		signature, err := tbls.Sign(shares[0][idx], depositMessageSigRoot[:])
+		require.NoError(t, err)
+
+		depositData := eth2p0.DepositData{
+			PublicKey:             depositMessage.PublicKey,
+			WithdrawalCredentials: depositMessage.WithdrawalCredentials,
+			Amount:                depositMessage.Amount,
+			Signature:             eth2p0.BLSSignature(signature),
+		}
+
+		// send all the partial deposits
+		require.NoError(t, cl.PostPartialDeposits(t.Context(), lock.LockHash, uint64(idx+1), []eth2p0.DepositData{depositData}), "share index: %d", idx+1)
+	}
+
+	// get full exit
+	_, err = cl.GetFullDeposit(t.Context(), lock.Validators[0].PublicKeyHex(), lock.LockHash, lock.Threshold)
+	require.NoError(t, err, "full deposit")
+}
diff --git a/app/obolapi/exit_test.go b/app/obolapi/exit_test.go
index 2d49551a..f3e40b7d 100644
--- a/app/obolapi/exit_test.go
+++ b/app/obolapi/exit_test.go
@@ -26,7 +26,7 @@ import (
 
 const exitEpoch = eth2p0.Epoch(194048)
 
-func TestAPIFlow(t *testing.T) {
+func TestAPIExit(t *testing.T) {
 	kn := 4
 
 	beaconMock, err := beaconmock.New(t.Context())
@@ -118,7 +118,7 @@ func TestAPIFlow(t *testing.T) {
 	}
 }
 
-func TestAPIFlowMissingSig(t *testing.T) {
+func TestAPIExitMissingSig(t *testing.T) {
 	kn := 4
 
 	beaconMock, err := beaconmock.New(t.Context())
diff --git a/cmd/depositfetch_internal_test.go b/cmd/depositfetch_internal_test.go
new file mode 100644
index 00000000..9f862860
--- /dev/null
+++ b/cmd/depositfetch_internal_test.go
@@ -0,0 +1,151 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package cmd
+
+import (
+	"encoding/json"
+	"fmt"
+	"math/rand"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/obolnetwork/charon/app/log"
+	"github.com/obolnetwork/charon/app/z"
+	"github.com/obolnetwork/charon/cluster"
+	"github.com/obolnetwork/charon/tbls"
+	"github.com/obolnetwork/charon/testutil/obolapimock"
+)
+
+func TestDepositFetchValid(t *testing.T) {
+	ctx := t.Context()
+	ctx = log.WithCtx(ctx, z.Str("test_case", t.Name()))
+
+	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])
+		}
+	}
+
+	lockJSON, err := json.Marshal(lock)
+	require.NoError(t, err)
+
+	writeAllLockData(t, root, operatorAmt, enrs, operatorShares, lockJSON)
+
+	handler, addLockFiles := obolapimock.MockServer(false, nil)
+
+	srv := httptest.NewServer(handler)
+	defer srv.Close()
+
+	addLockFiles(lock)
+
+	// First submit partial deposits to API.
+	for idx := range operatorAmt {
+		baseDir := filepath.Join(root, fmt.Sprintf("op%d", idx))
+
+		config := depositConfig{
+			ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex(), lock.Validators[1].PublicKeyHex()},
+			PrivateKeyPath:      filepath.Join(baseDir, "charon-enr-private-key"),
+			ValidatorKeysDir:    filepath.Join(baseDir, "validator_keys"),
+			LockFilePath:        filepath.Join(baseDir, "cluster-lock.json"),
+			PublishAddress:      srv.URL,
+			PublishTimeout:      10 * time.Second,
+		}
+
+		signConfig := depositSignConfig{
+			depositConfig:       config,
+			WithdrawalAddresses: []string{"0x0100000000000000000000000000000000000000000000000000000000001234", "0x0100000000000000000000000000000000000000000000000000000000001235"},
+			DepositAmounts:      []uint{32, 1},
+		}
+
+		require.NoError(t, runDepositSign(ctx, signConfig), "operator index submit deposit sign: %v", idx)
+	}
+
+	baseDir := filepath.Join(root, fmt.Sprintf("op%d", 0))
+
+	config := depositConfig{
+		ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex(), lock.Validators[1].PublicKeyHex()},
+		PrivateKeyPath:      filepath.Join(baseDir, "charon-enr-private-key"),
+		ValidatorKeysDir:    filepath.Join(baseDir, "validator_keys"),
+		LockFilePath:        filepath.Join(baseDir, "cluster-lock.json"),
+		PublishAddress:      srv.URL,
+		PublishTimeout:      10 * time.Second,
+	}
+
+	fetchConfig := depositFetchConfig{
+		depositConfig:  config,
+		DepositDataDir: filepath.Join(baseDir, "deposit_data"),
+	}
+
+	err = runDepositFetch(ctx, fetchConfig)
+	require.NoError(t, err)
+}
+
+func TestDepositFetchCLI(t *testing.T) {
+	tests := []struct {
+		name        string
+		expectedErr string
+
+		flags []string
+	}{
+		{
+			name:        "correct flags",
+			expectedErr: "load cluster lock: load dag from disk: no file found",
+			flags: []string{
+				"--validator-public-keys=test",
+				"--private-key-file=test",
+				"--validator-keys-dir=test",
+				"--lock-file=test",
+				"--publish-address=test",
+				"--publish-timeout=1ms",
+			},
+		},
+		{
+			name:        "missing validator public keys",
+			expectedErr: "required flag(s) \"validator-public-keys\" not set",
+			flags: []string{
+				"--private-key-file=test",
+				"--validator-keys-dir=test",
+				"--lock-file=test",
+				"--publish-address=test",
+				"--publish-timeout=1ms",
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			cmd := newDepositCmd(newDepositFetchCmd(runDepositFetch))
+			cmd.SetArgs(append([]string{"fetch"}, 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/depositsign_internal_test.go b/cmd/depositsign_internal_test.go
new file mode 100644
index 00000000..a9280df4
--- /dev/null
+++ b/cmd/depositsign_internal_test.go
@@ -0,0 +1,145 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package cmd
+
+import (
+	"encoding/json"
+	"fmt"
+	"math/rand"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/obolnetwork/charon/app/log"
+	"github.com/obolnetwork/charon/app/z"
+	"github.com/obolnetwork/charon/cluster"
+	"github.com/obolnetwork/charon/tbls"
+	"github.com/obolnetwork/charon/testutil/obolapimock"
+)
+
+func TestDepositSignValid(t *testing.T) {
+	ctx := t.Context()
+	ctx = log.WithCtx(ctx, z.Str("test_case", t.Name()))
+
+	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])
+		}
+	}
+
+	lockJSON, err := json.Marshal(lock)
+	require.NoError(t, err)
+
+	writeAllLockData(t, root, operatorAmt, enrs, operatorShares, lockJSON)
+
+	handler, addLockFiles := obolapimock.MockServer(false, nil)
+
+	srv := httptest.NewServer(handler)
+	defer srv.Close()
+
+	addLockFiles(lock)
+
+	idx := 0
+
+	baseDir := filepath.Join(root, fmt.Sprintf("op%d", idx))
+
+	config := depositConfig{
+		ValidatorPublicKeys: []string{lock.Validators[0].PublicKeyHex()},
+		PrivateKeyPath:      filepath.Join(baseDir, "charon-enr-private-key"),
+		ValidatorKeysDir:    filepath.Join(baseDir, "validator_keys"),
+		LockFilePath:        filepath.Join(baseDir, "cluster-lock.json"),
+		PublishAddress:      srv.URL,
+		PublishTimeout:      10 * time.Second,
+	}
+
+	signConfig := depositSignConfig{
+		depositConfig:       config,
+		WithdrawalAddresses: []string{"0x0100000000000000000000000000000000000000000000000000000000001234"},
+		DepositAmounts:      []uint{32},
+	}
+
+	require.NoError(t, runDepositSign(ctx, signConfig), "operator index submit deposit sign: %v", idx)
+}
+
+func TestDepositSignCLI(t *testing.T) {
+	tests := []struct {
+		name        string
+		expectedErr string
+
+		flags []string
+	}{
+		{
+			name:        "correct flags",
+			expectedErr: "load identity key: read private key from disk: open test: no such file or directory",
+			flags: []string{
+				"--validator-public-keys=test",
+				"--withdrawal-addresses=test",
+				"--private-key-file=test",
+				"--validator-keys-dir=test",
+				"--lock-file=test",
+				"--publish-address=test",
+				"--publish-timeout=1ms",
+			},
+		},
+		{
+			name:        "missing validator public keys",
+			expectedErr: "required flag(s) \"validator-public-keys\" not set",
+			flags: []string{
+				"--withdrawal-addresses=test",
+				"--private-key-file=test",
+				"--validator-keys-dir=test",
+				"--lock-file=test",
+				"--publish-address=test",
+				"--publish-timeout=1ms",
+			},
+		},
+		{
+			name:        "missing withdrawal addresses",
+			expectedErr: "required flag(s) \"withdrawal-addresses\" not set",
+			flags: []string{
+				"--validator-public-keys=test",
+				"--private-key-file=test",
+				"--validator-keys-dir=test",
+				"--lock-file=test",
+				"--publish-address=test",
+				"--publish-timeout=1ms",
+			},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			cmd := newDepositCmd(newDepositSignCmd(runDepositSign))
+			cmd.SetArgs(append([]string{"sign"}, 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/testbeacon.go b/cmd/testbeacon.go
index 2963c5b8..b489986c 100644
--- a/cmd/testbeacon.go
+++ b/cmd/testbeacon.go
@@ -14,7 +14,6 @@ import (
 	"os"
 	"path/filepath"
 	"slices"
-	"sort"
 	"strconv"
 	"strings"
 	"sync"
@@ -1829,9 +1828,7 @@ func generateSimulationValues(s []time.Duration, endpoint string) SimulationValu
 
 	sorted := make([]time.Duration, len(s))
 	copy(sorted, s)
-	sort.Slice(sorted, func(i, j int) bool {
-		return sorted[i] < sorted[j]
-	})
+	slices.Sort(sorted)
 	minVal := sorted[0]
 	maxVal := sorted[len(s)-1]
 	medianVal := sorted[len(s)/2]
diff --git a/cmd/testpeers_internal_test.go b/cmd/testpeers_internal_test.go
index 8030d47a..ba5e24e6 100644
--- a/cmd/testpeers_internal_test.go
+++ b/cmd/testpeers_internal_test.go
@@ -33,16 +33,13 @@ import (
 //go:generate go test . -run=TestPeersTest -update
 
 func TestPeersTest(t *testing.T) {
-	// This is a known flaky test, skipping for now.
-	// The test fails pretty reliably when running with pre-commit locally.
-	t.SkipNow()
-
 	peer1PrivKey := base64ToPrivKey(t, "GCc1IKup3kKVxSd9iSu8iX5hc37coxAXasYpGFd/cwo=")
 	peer2PrivKey := base64ToPrivKey(t, "9PhpdrWEDJugHgoXhpbk2KqR4Gj5QZP/YNxNeJ3Q2+A=")
 	peer3PrivKey := base64ToPrivKey(t, "GpicOFPB/c/ZKIy1/fOt/4BmEekhFuyxa/SGcjrNe9o=")
 	freeTCPAddr := testutil.AvailableAddr(t)
 	// start local relay
-	relayAddr := relay.StartRelay(context.Background(), t)
+	ctx := t.Context()
+	relayAddr := relay.StartRelay(ctx, t)
 
 	tests := []struct {
 		name        string
@@ -50,7 +47,6 @@ func TestPeersTest(t *testing.T) {
 		expected    testCategoryResult
 		expectedErr string
 		prepare     func(*testing.T, testPeersConfig) testPeersConfig
-		cleanup     func(*testing.T, string)
 	}{
 		{
 			name: "default scenario",
@@ -110,9 +106,9 @@ func TestPeersTest(t *testing.T) {
 				t.Helper()
 
 				// start peers
-				enr1 := startPeer(t, conf, &peer1PrivKey)
-				enr2 := startPeer(t, conf, &peer2PrivKey)
-				enr3 := startPeer(t, conf, &peer3PrivKey)
+				enr1 := startPeer(t, ctx, conf, &peer1PrivKey)
+				enr2 := startPeer(t, ctx, conf, &peer2PrivKey)
+				enr3 := startPeer(t, ctx, conf, &peer3PrivKey)
 
 				newConfig := conf
 				newConfig.ENRs = []string{enr1.String(), enr2.String(), enr3.String()}
@@ -266,12 +262,6 @@ func TestPeersTest(t *testing.T) {
 				Score: categoryScoreC,
 			},
 			expectedErr: "",
-			cleanup: func(t *testing.T, p string) {
-				t.Helper()
-
-				err := os.Remove(p)
-				require.NoError(t, err)
-			},
 		},
 		{
 			name: "publish to Obol API",
@@ -347,12 +337,6 @@ func TestPeersTest(t *testing.T) {
 				require.NoError(t, err)
 			}
 
-			defer func() {
-				if test.cleanup != nil {
-					test.cleanup(t, conf.OutputJSON)
-				}
-			}()
-
 			if conf.Quiet {
 				require.Empty(t, buf.String())
 			} else {
@@ -362,6 +346,11 @@ func TestPeersTest(t *testing.T) {
 			if test.config.OutputJSON != "" {
 				testWriteFile(t, test.expected, test.config.OutputJSON)
 			}
+
+			if conf.OutputJSON != "" {
+				err = os.Remove(conf.OutputJSON)
+				require.NoError(t, err)
+			}
 		})
 	}
 }
@@ -530,10 +519,9 @@ func testPublishToObolAPI(t *testing.T, expectedRes testCategoryResult, conf tes
 	return server, conf
 }
 
-func startPeer(t *testing.T, conf testPeersConfig, peerPrivKey *k1.PrivateKey) enr.Record {
+func startPeer(t *testing.T, ctx context.Context, conf testPeersConfig, peerPrivKey *k1.PrivateKey) enr.Record {
 	t.Helper()
 
-	ctx := context.Background()
 	peerConf := conf
 	freeTCPAddr := testutil.AvailableAddr(t)
 	peerConf.P2P.TCPAddrs = []string{fmt.Sprintf("127.0.0.1:%v", freeTCPAddr.Port)}
@@ -554,6 +542,11 @@ func startPeer(t *testing.T, conf testPeersConfig, peerPrivKey *k1.PrivateKey) e
 	peerTCPNode, err := p2p.NewNode(ctx, peerConf.P2P, peerPrivKey, connGater, false, p2p.NodeTypeTCP)
 	require.NoError(t, err)
 
+	t.Cleanup(func() {
+		err := peerTCPNode.Close()
+		require.NoError(t, err)
+	})
+
 	for _, relay := range relays {
 		go p2p.NewRelayReserver(peerTCPNode, relay)(ctx)
 	}
diff --git a/core/bcast/bcast_test.go b/core/bcast/bcast_test.go
index 258167f7..659a3726 100644
--- a/core/bcast/bcast_test.go
+++ b/core/bcast/bcast_test.go
@@ -33,7 +33,6 @@ func TestBroadcast(t *testing.T) {
 		attData,                   // Attestation
 		proposalData,              // BeaconBlock
 		blindedProposalData,       // BlindedBlock
-		validatorRegistrationData, // ValidatorRegistration
 		validatorExitData,         // ValidatorExit
 		aggregateAttestationData,  // AggregateAttestation
 		beaconCommitteeSelections, // BeaconCommitteeSelections
@@ -188,29 +187,6 @@ func blindedProposalData(t *testing.T, mock *beaconmock.Mock) test {
 	}
 }
 
-func validatorRegistrationData(t *testing.T, mock *beaconmock.Mock) test {
-	t.Helper()
-
-	asserted := make(chan struct{})
-	registration := testutil.RandomCoreVersionedSignedValidatorRegistration(t).VersionedSignedValidatorRegistration
-	aggData := core.VersionedSignedValidatorRegistration{VersionedSignedValidatorRegistration: registration}
-
-	mock.SubmitValidatorRegistrationsFunc = func(ctx context.Context, registrations []*eth2api.VersionedSignedValidatorRegistration) error {
-		require.Equal(t, aggData.VersionedSignedValidatorRegistration, *registrations[0])
-		close(asserted)
-
-		return nil
-	}
-
-	return test{
-		name:     "Broadcast Validator Registration",
-		aggData:  aggData,
-		duty:     core.DutyBuilderRegistration,
-		bcastCnt: 1,
-		asserted: asserted,
-	}
-}
-
 func validatorExitData(t *testing.T, mock *beaconmock.Mock) test {
 	t.Helper()
 
diff --git a/core/consensus/qbft/strategysim_internal_test.go b/core/consensus/qbft/strategysim_internal_test.go
index a4ebef38..ec17de68 100644
--- a/core/consensus/qbft/strategysim_internal_test.go
+++ b/core/consensus/qbft/strategysim_internal_test.go
@@ -10,6 +10,7 @@ import (
 	"math/rand"
 	"os"
 	"runtime"
+	"slices"
 	"sort"
 	"strings"
 	"sync"
@@ -759,9 +760,7 @@ func quorumDecidedDuration(results []result) time.Duration {
 		panic("not enough durations")
 	}
 
-	sort.Slice(durations, func(i, j int) bool {
-		return durations[i] < durations[j]
-	})
+	slices.Sort(durations)
 
 	return durations[q-1]
 }
diff --git a/core/scheduler/scheduler_test.go b/core/scheduler/scheduler_test.go
index f02bb3e0..84d6bf23 100644
--- a/core/scheduler/scheduler_test.go
+++ b/core/scheduler/scheduler_test.go
@@ -22,6 +22,7 @@ import (
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/eth2wrap"
 	"github.com/obolnetwork/charon/app/featureset"
+	"github.com/obolnetwork/charon/cluster"
 	"github.com/obolnetwork/charon/core"
 	"github.com/obolnetwork/charon/core/scheduler"
 	"github.com/obolnetwork/charon/testutil"
@@ -47,16 +48,29 @@ func TestIntegration(t *testing.T) {
 	eth2Cl, err := eth2wrap.NewMultiHTTP(time.Second*2, [4]byte{}, map[string]string{}, []string{beaconURL}, []string{})
 	require.NoError(t, err)
 
-	// Use random actual mainnet validators
-	pubkeys := []core.PubKey{
-		"0x914cff835a769156ba43ad50b931083c2dadd94e8359ce394bc7a3e06424d0214922ddf15f81640530b9c25c0bc0d490",
-		"0x8dae41352b69f2b3a1c0b05330c1bf65f03730c520273028864b11fcb94d8ce8f26d64f979a0ee3025467f45fd2241ea",
-		"0x8ee91545183c8c2db86633626f5074fd8ef93c4c9b7a2879ad1768f600c5b5906c3af20d47de42c3b032956fa8db1a76",
-		"0xa8785ecbb5c030e5da6cbbacc3e6cad39dffbc7bcf7f223a12844db8c1182603df99f673157f0d27912a53546e0f64fe",
-		"0xb790b322e1cce41c48e3c344cf8d752bdc3cfd51e8eeef44a4bdaac081bc92b53b73e823a9878b5d7a532eb9d9dce1e3",
+	// Builder registrations for mainnet validators
+	valRegs := []cluster.BuilderRegistration{
+		{
+			Message: cluster.Registration{
+				FeeRecipient: beaconmock.MustBytesFromHex("0x388c818ca8b9251b393131c08a736a67ccb19297"),
+				GasLimit:     36000000,
+				Timestamp:    time.Unix(1606824023, 0),
+				PubKey:       beaconmock.MustBytesFromHex("0x8f4ef114368b24863b369bbf597ace2eab5f77e4726f7931f988ba757fbd1dffd2f44270bbed42d5dfa72e10c79dcb6d"),
+			},
+			Signature: beaconmock.MustBytesFromHex("0xacc05896c51c57177306d3f1eb9de64a6a1fb50b88cf4afe276a3c53673ce1227af2337ef607c769624f45472968cbde15f87c355dfa43bca81c882ea2d89ad301a4ce1c4169874bf9f9b1bafe70838519af34741d774930edbad40a7fefc7e6"),
+		},
+		{
+			Message: cluster.Registration{
+				FeeRecipient: beaconmock.MustBytesFromHex("0x388c818ca8b9251b393131c08a736a67ccb19297"),
+				GasLimit:     36000000,
+				Timestamp:    time.Unix(1606824023, 0),
+				PubKey:       beaconmock.MustBytesFromHex("0xa0753f1bdb24b39441aee027a44af9ec5117a572678aa987944d26d92d332789208cf0733ca99f1d96fae50f7e889c22"),
+			},
+			Signature: beaconmock.MustBytesFromHex("0x8de77931277c745c05879db7e57853a07c5d3bd45d4d5757f55bdf05de3266c6ddac81b3b0316552b2ceb77405aa5c701311db3cd6a6ca176c792a9f1814ede89907b8cd05061a15bfb6618c8390577b349b3b4a75693311f1c638f53c186c58"),
+		},
 	}
 
-	s, err := scheduler.New(pubkeys, eth2Cl, false)
+	s, err := scheduler.New(valRegs, eth2Cl, false)
 	require.NoError(t, err)
 
 	count := 10
@@ -150,7 +164,7 @@ func TestSchedulerWait(t *testing.T) {
 			}
 
 			dd := new(delayer)
-			sched := scheduler.NewForT(t, clock, dd.delay, nil, eth2Cl, nil)
+			sched := scheduler.NewForT(t, clock, dd.delay, nil, eth2Cl, nil, false)
 			sched.Stop() // Just run wait functions, then quit.
 			require.NoError(t, sched.Run())
 			require.LessOrEqual(t, test.WaitSecs, int(clock.Since(t0).Seconds()))
@@ -215,14 +229,11 @@ func TestSchedulerDuties(t *testing.T) {
 				return origFunc(ctx, epoch, indices)
 			}
 
-			// get pubkeys for validators to schedule
-			pubkeys, err := valSet.CorePubKeys()
-			require.NoError(t, err)
-
 			// Construct scheduler
 			clock := newTestClock(t0)
 			delayer := new(delayer)
-			sched := scheduler.NewForT(t, clock, delayer.delay, pubkeys, eth2Cl, nil)
+			valRegs := beaconmock.BuilderRegistrationSetA
+			sched := scheduler.NewForT(t, clock, delayer.delay, valRegs, eth2Cl, nil, false)
 
 			// Only test scheduler output for first N slots, so Stop scheduler (and slotTicker) after that.
 			const stopAfter = 3
@@ -319,14 +330,11 @@ func TestScheduler_GetDuty(t *testing.T) {
 	)
 	require.NoError(t, err)
 
-	// Get pubkeys for validators to schedule.
-	pubkeys, err := valSet.CorePubKeys()
-	require.NoError(t, err)
-
 	// Construct scheduler.
 	clock := newTestClock(t0)
 	dd := new(delayer)
-	sched := scheduler.NewForT(t, clock, dd.delay, pubkeys, eth2Cl, nil)
+	valRegs := beaconmock.BuilderRegistrationSetA
+	sched := scheduler.NewForT(t, clock, dd.delay, valRegs, eth2Cl, nil, false)
 
 	_, err = sched.GetDutyDefinition(ctx, core.NewAttesterDuty(slot))
 	require.ErrorContains(t, err, "epoch not resolved yet")
@@ -414,7 +422,7 @@ func TestNoActive(t *testing.T) {
 	// Construct scheduler.
 	clock := newTestClock(t0)
 	dd := new(delayer)
-	sched := scheduler.NewForT(t, clock, dd.delay, nil, eth2Cl, nil)
+	sched := scheduler.NewForT(t, clock, dd.delay, nil, eth2Cl, nil, false)
 
 	clock.CallbackAfter(t0.Add(slotDuration*2), func() {
 		_, err := sched.GetDutyDefinition(ctx, core.NewAttesterDuty(1))
@@ -443,10 +451,6 @@ func TestHandleChainReorgEvent(t *testing.T) {
 	)
 	require.NoError(t, err)
 
-	// Get pubkeys for validators to schedule.
-	pubkeys, err := valSet.CorePubKeys()
-	require.NoError(t, err)
-
 	// Construct scheduler.
 	schedSlotCh := make(chan core.Slot)
 	schedSlotFunc := func(ctx context.Context, slot core.Slot) {
@@ -458,7 +462,8 @@ func TestHandleChainReorgEvent(t *testing.T) {
 	}
 	clock := newTestClock(t0)
 	dd := new(delayer)
-	sched := scheduler.NewForT(t, clock, dd.delay, pubkeys, eth2Cl, schedSlotFunc)
+	valRegs := beaconmock.BuilderRegistrationSetA
+	sched := scheduler.NewForT(t, clock, dd.delay, valRegs, eth2Cl, schedSlotFunc, false)
 
 	doneCh := make(chan error, 1)
 
@@ -493,6 +498,107 @@ func TestHandleChainReorgEvent(t *testing.T) {
 	require.NoError(t, <-doneCh)
 }
 
+func TestSubmitValidatorRegistrations(t *testing.T) {
+	// The test uses hard-coded validator registrations from beaconmock.BuilderRegistrationSetA.
+	// The scheduler advances through 3 epochs to ensure it triggers the registration submission.
+	var (
+		t0     time.Time
+		valSet = beaconmock.ValidatorSetA
+	)
+
+	eth2Cl, err := beaconmock.New(
+		t.Context(),
+		beaconmock.WithValidatorSet(valSet),
+		beaconmock.WithGenesisTime(t0),
+		beaconmock.WithDeterministicAttesterDuties(1),
+		beaconmock.WithSlotsPerEpoch(4),
+	)
+	require.NoError(t, err)
+
+	// Track calls to SubmitValidatorRegistrations
+	var (
+		callCount     atomic.Int64
+		callMutex     sync.Mutex
+		registrations []*eth2api.VersionedSignedValidatorRegistration
+	)
+
+	origFunc := eth2Cl.SubmitValidatorRegistrationsFunc
+	eth2Cl.SubmitValidatorRegistrationsFunc = func(ctx context.Context, regs []*eth2api.VersionedSignedValidatorRegistration) error {
+		callCount.Add(1)
+
+		if registrations == nil {
+			callMutex.Lock()
+
+			registrations = regs
+
+			callMutex.Unlock()
+		}
+
+		return origFunc(ctx, regs)
+	}
+
+	schedSlotCh := make(chan core.Slot)
+	schedSlotFunc := func(ctx context.Context, slot core.Slot) {
+		select {
+		case <-ctx.Done():
+			return
+		case schedSlotCh <- slot:
+		}
+	}
+	clock := newTestClock(t0)
+	dd := new(delayer)
+	valRegs := beaconmock.BuilderRegistrationSetA
+	sched := scheduler.NewForT(t, clock, dd.delay, valRegs, eth2Cl, schedSlotFunc, true)
+
+	doneCh := make(chan error, 1)
+
+	go func() {
+		doneCh <- sched.Run()
+
+		close(schedSlotCh)
+	}()
+
+	epochsSeen := make(map[uint64]bool)
+	slotCount := 0
+	stopped := false
+
+	for slot := range schedSlotCh {
+		clock.Pause()
+
+		slotCount++
+
+		if !epochsSeen[slot.Epoch()] {
+			epochsSeen[slot.Epoch()] = true
+		}
+
+		// Stop after processing enough slots to see 3 epochs
+		// With 4 slots per epoch, we need at least 9 slots (slots 0-8 cover epochs 0, 1, 2)
+		if slotCount >= 9 && !stopped {
+			stopped = true
+
+			sched.Stop()
+		}
+
+		clock.Resume()
+	}
+
+	require.NoError(t, <-doneCh)
+
+	count := callCount.Load()
+	require.GreaterOrEqual(t, count, int64(1), "Expected at least 1 call to SubmitValidatorRegistrations, got %d", count)
+	require.NotNil(t, registrations, "No registrations were captured")
+	require.Len(t, registrations, len(valRegs), "Expected %d registrations, got %d", len(valRegs), len(registrations))
+
+	// Verify registration data matches BuilderRegistrationSetA
+	for i, reg := range registrations {
+		require.Equal(t, valRegs[i].Message.GasLimit, int(reg.V1.Message.GasLimit))
+		require.Equal(t, valRegs[i].Message.Timestamp.Unix(), reg.V1.Message.Timestamp.Unix())
+		require.Equal(t, valRegs[i].Message.FeeRecipient, reg.V1.Message.FeeRecipient[:])
+		require.Equal(t, valRegs[i].Message.PubKey, reg.V1.Message.Pubkey[:])
+		require.Equal(t, valRegs[i].Signature, reg.V1.Signature[:])
+	}
+}
+
 // delayer implements scheduler.delayFunc and records the deadline and returns it immediately.
 type delayer struct {
 	mu        sync.Mutex
diff --git a/core/validatorapi/validatorapi_test.go b/core/validatorapi/validatorapi_test.go
index 3e41f1cf..5137d5aa 100644
--- a/core/validatorapi/validatorapi_test.go
+++ b/core/validatorapi/validatorapi_test.go
@@ -1853,143 +1853,6 @@ func TestComponent_Duties(t *testing.T) {
 	})
 }
 
-func TestComponent_SubmitValidatorRegistration(t *testing.T) {
-	ctx := t.Context()
-	shareIdx := 1
-	// Create keys (just use normal keys, not split tbls)
-	secret, err := tbls.GenerateSecretKey()
-	require.NoError(t, err)
-
-	pubkey, err := tbls.SecretToPublicKey(secret)
-	require.NoError(t, err)
-
-	// Convert pubkey
-	eth2Pubkey := eth2p0.BLSPubKey(pubkey)
-	corePubKey, err := core.PubKeyFromBytes(pubkey[:])
-	require.NoError(t, err)
-
-	allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{corePubKey: {shareIdx: pubkey}} // Maps self to self since not tbls
-
-	// Configure beacon mock
-	bmock, err := beaconmock.New(t.Context())
-	require.NoError(t, err)
-
-	// Construct the validator api component
-	vapi, err := validatorapi.NewComponent(bmock, allPubSharesByKey, shareIdx, nil, true, 30000000, nil)
-	require.NoError(t, err)
-
-	unsigned := testutil.RandomValidatorRegistration(t)
-	unsigned.Pubkey = eth2Pubkey
-	genesis, err := bmock.Genesis(ctx, &eth2api.GenesisOpts{})
-	require.NoError(t, err)
-
-	genesisTime := genesis.Data.GenesisTime
-	unsigned.Timestamp = genesisTime // Set timestamp to genesis which should result in epoch 0 and slot 0.
-
-	// Sign validator (builder) registration
-	sigRoot, err := unsigned.HashTreeRoot()
-	require.NoError(t, err)
-
-	sigData, err := signing.GetDataRoot(ctx, bmock, signing.DomainApplicationBuilder, 0, sigRoot)
-	require.NoError(t, err)
-
-	s, err := tbls.Sign(secret, sigData[:])
-	require.NoError(t, err)
-
-	signed := &eth2api.VersionedSignedValidatorRegistration{
-		Version: eth2spec.BuilderVersionV1,
-		V1: &eth2v1.SignedValidatorRegistration{
-			Message:   unsigned,
-			Signature: eth2p0.BLSSignature(s),
-		},
-	}
-
-	output := make(chan core.ParSignedDataSet, 1)
-
-	// Register subscriber
-	vapi.Subscribe(func(ctx context.Context, duty core.Duty, set core.ParSignedDataSet) error {
-		require.Equal(t, core.NewBuilderRegistrationDuty(0), duty)
-
-		select {
-		case <-ctx.Done():
-			return ctx.Err()
-		case output <- set:
-		}
-
-		return nil
-	})
-
-	err = vapi.SubmitValidatorRegistrations(ctx, []*eth2api.VersionedSignedValidatorRegistration{signed})
-	require.NoError(t, err)
-
-	// Assert output
-	actualData := <-output
-	registration, ok := actualData[corePubKey].SignedData.(core.VersionedSignedValidatorRegistration)
-	require.True(t, ok)
-	require.Equal(t, *signed, registration.VersionedSignedValidatorRegistration)
-
-	// Assert incorrect pubkey registration is swallowed
-	close(output) // Panic if registration is not swallowed
-
-	signed.V1.Message.Pubkey = testutil.RandomEth2PubKey(t)
-	err = vapi.SubmitValidatorRegistrations(ctx, []*eth2api.VersionedSignedValidatorRegistration{signed})
-	require.NoError(t, err)
-}
-
-func TestComponent_SubmitValidatorRegistrationInvalidSignature(t *testing.T) {
-	ctx := context.Background()
-	shareIdx := 1
-	// Create keys (just use normal keys, not split tbls)
-	secret, err := tbls.GenerateSecretKey()
-	require.NoError(t, err)
-
-	pubkey, err := tbls.SecretToPublicKey(secret)
-	require.NoError(t, err)
-
-	// Convert pubkey
-	eth2Pubkey := eth2p0.BLSPubKey(pubkey)
-	corePubKey, err := core.PubKeyFromBytes(pubkey[:])
-	require.NoError(t, err)
-
-	allPubSharesByKey := map[core.PubKey]map[int]tbls.PublicKey{corePubKey: {shareIdx: pubkey}} // Maps self to self since not tbls
-
-	// Configure beacon mock
-	bmock, err := beaconmock.New(t.Context())
-	require.NoError(t, err)
-
-	// Construct the validator api component
-	vapi, err := validatorapi.NewComponent(bmock, allPubSharesByKey, shareIdx, nil, true, 30000000, nil)
-	require.NoError(t, err)
-
-	unsigned := testutil.RandomValidatorRegistration(t)
-	unsigned.Pubkey = eth2Pubkey
-	genesis, err := bmock.Genesis(ctx, &eth2api.GenesisOpts{})
-	require.NoError(t, err)
-
-	genesisTime := genesis.Data.GenesisTime
-	unsigned.Timestamp = genesisTime // Set timestamp to genesis which should result in epoch 0 and slot 0.
-
-	vapi.RegisterGetDutyDefinition(func(ctx context.Context, duty core.Duty) (core.DutyDefinitionSet, error) {
-		return core.DutyDefinitionSet{corePubKey: nil}, nil
-	})
-
-	// Add invalid Signature to validator (builder) registration
-
-	s, err := tbls.Sign(secret, []byte("invalid msg"))
-	require.NoError(t, err)
-
-	signed := &eth2api.VersionedSignedValidatorRegistration{
-		Version: eth2spec.BuilderVersionV1,
-		V1: &eth2v1.SignedValidatorRegistration{
-			Message:   unsigned,
-			Signature: eth2p0.BLSSignature(s),
-		},
-	}
-
-	err = vapi.SubmitValidatorRegistrations(ctx, []*eth2api.VersionedSignedValidatorRegistration{signed})
-	require.ErrorContains(t, err, "signature not verified")
-}
-
 func TestComponent_AggregateBeaconCommitteeSelections(t *testing.T) {
 	ctx := context.Background()
 
diff --git a/dkg/dkg_test.go b/dkg/dkg_test.go
index d6ff329f..13666275 100644
--- a/dkg/dkg_test.go
+++ b/dkg/dkg_test.go
@@ -532,9 +532,6 @@ func verifyDistValidators(t *testing.T, lock cluster.Lock, def cluster.Definitio
 }
 
 func TestSyncFlow(t *testing.T) {
-	// The known flakey test, needs to be reworked.
-	t.SkipNow()
-
 	tests := []struct {
 		name       string
 		connect    []int // Initial connections
@@ -582,13 +579,17 @@ func TestSyncFlow(t *testing.T) {
 				// Initialise slice with the given number of nodes since this table tests input node indices as testcases.
 				stopDkgs   = make([]context.CancelFunc, test.nodes)
 				cTracker   = newConnTracker(pIDs)
-				dkgErrChan = make(chan error)
+				dkgErrChan = make(chan error, test.nodes) // Buffered to prevent blocking
 			)
 
+			// Set all callbacks before starting any DKG processes to avoid race conditions
+			for i := range test.nodes {
+				configs[i].TestConfig.SyncCallback = cTracker.Set
+			}
+
 			// Start DKG for initial peers.
 			for _, idx := range test.connect {
 				log.Info(ctx, "Starting initial peer", z.Int("peer_index", idx))
-				configs[idx].TestConfig.SyncCallback = cTracker.Set
 				stopDkgs[idx] = startNewDKG(t, peerCtx(ctx, idx), configs[idx], dkgErrChan)
 			}
 
@@ -606,10 +607,17 @@ func TestSyncFlow(t *testing.T) {
 				stopDkgs[idx]()
 
 				// Wait for this dkg process to return.
-				err := <-dkgErrChan
-				require.ErrorIs(t, err, context.Canceled)
+				select {
+				case err := <-dkgErrChan:
+					require.ErrorIs(t, err, context.Canceled)
+				case <-time.After(5 * time.Second):
+					require.Fail(t, "timeout waiting for peer to stop", "peer_index=%d", idx)
+				}
 			}
 
+			// Give remaining peers time to detect disconnections and update their connection counts
+			time.Sleep(500 * time.Millisecond)
+
 			// Wait for remaining-initial peers to update connection counts.
 			expect = len(test.connect) - len(test.disconnect) - 1
 			for _, idx := range test.connect {
@@ -617,8 +625,6 @@ func TestSyncFlow(t *testing.T) {
 					continue
 				}
 
-				configs[idx].TestConfig.SyncCallback = cTracker.Set
-
 				log.Info(ctx, "Waiting for remaining-initial peer count",
 					z.Int("peer_index", idx), z.Int("expect", expect))
 				cTracker.AwaitN(t, dkgErrChan, expect, idx)
@@ -631,18 +637,16 @@ func TestSyncFlow(t *testing.T) {
 			}
 
 			// Wait for all peer DKG processes to complete.
-			var disconnectedCount int
-
-			for err := range dkgErrChan {
-				testutil.SkipIfBindErr(t, err)
-
-				if !errors.Is(err, context.Canceled) {
-					require.NoError(t, err)
-				}
-
-				disconnectedCount++
-				if disconnectedCount == test.nodes {
-					break
+			for range test.nodes {
+				select {
+				case err := <-dkgErrChan:
+					testutil.SkipIfBindErr(t, err)
+
+					if !errors.Is(err, context.Canceled) {
+						require.NoError(t, err)
+					}
+				case <-time.After(time.Minute):
+					require.Fail(t, "timeout waiting for all DKG processes to complete")
 				}
 			}
 
@@ -679,7 +683,7 @@ func (c *connTracker) AwaitN(t *testing.T, dkgErrChan chan error, n int, peerIdx
 	ticker := time.NewTicker(time.Millisecond * 100)
 	defer ticker.Stop()
 
-	timeout := time.NewTimer(time.Second * 15)
+	timeout := time.NewTimer(time.Second * 20) // Increased timeout for CI environments
 	defer timeout.Stop()
 
 	for {
@@ -688,7 +692,14 @@ func (c *connTracker) AwaitN(t *testing.T, dkgErrChan chan error, n int, peerIdx
 			require.Fail(t, "timeout", "expected %d connections for peer %d, got %d", n, peerIdx, c.count(peerIdx))
 		case err := <-dkgErrChan:
 			testutil.SkipIfBindErr(t, err)
-			require.Failf(t, "DKG exited", "err=%v", err)
+			// Put error back on channel for other consumers
+			select {
+			case dkgErrChan <- err:
+			default:
+				// Channel full, that's ok
+			}
+
+			require.Failf(t, "DKG exited unexpectedly", "err=%v", err)
 		case <-ticker.C:
 			if c.count(peerIdx) == n {
 				return
diff --git a/testutil/beaconmock/options.go b/testutil/beaconmock/options.go
index 9ff8696e..b227b01b 100644
--- a/testutil/beaconmock/options.go
+++ b/testutil/beaconmock/options.go
@@ -10,7 +10,6 @@ import (
 	"math/big"
 	"net/http"
 	"slices"
-	"sort"
 	"strconv"
 	"strings"
 	"time"
@@ -27,6 +26,7 @@ import (
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/eth2wrap"
 	"github.com/obolnetwork/charon/app/log"
+	"github.com/obolnetwork/charon/cluster"
 	"github.com/obolnetwork/charon/core"
 	"github.com/obolnetwork/charon/testutil"
 )
@@ -139,6 +139,37 @@ var ValidatorSetA = ValidatorSet{
 	},
 }
 
+// BuilderRegistrationSetA defines a set of 3 deterministic builder registrations for ValidatorSetA.
+var BuilderRegistrationSetA = []cluster.BuilderRegistration{
+	{
+		Message: cluster.Registration{
+			FeeRecipient: MustBytesFromHex("0x0000000000000000000000000000000000000001"),
+			GasLimit:     30000000,
+			Timestamp:    time.Unix(1609459200, 0), // 2021-01-01 00:00:00 UTC
+			PubKey:       MustBytesFromHex("0x914cff835a769156ba43ad50b931083c2dadd94e8359ce394bc7a3e06424d0214922ddf15f81640530b9c25c0bc0d490"),
+		},
+		Signature: MustBytesFromHex("0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6"),
+	},
+	{
+		Message: cluster.Registration{
+			FeeRecipient: MustBytesFromHex("0x0000000000000000000000000000000000000002"),
+			GasLimit:     30000000,
+			Timestamp:    time.Unix(1609459200, 0), // 2021-01-01 00:00:00 UTC
+			PubKey:       MustBytesFromHex("0x8dae41352b69f2b3a1c0b05330c1bf65f03730c520273028864b11fcb94d8ce8f26d64f979a0ee3025467f45fd2241ea"),
+		},
+		Signature: MustBytesFromHex("0xb2b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6"),
+	},
+	{
+		Message: cluster.Registration{
+			FeeRecipient: MustBytesFromHex("0x0000000000000000000000000000000000000003"),
+			GasLimit:     30000000,
+			Timestamp:    time.Unix(1609459200, 0), // 2021-01-01 00:00:00 UTC
+			PubKey:       MustBytesFromHex("0x8ee91545183c8c2db86633626f5074fd8ef93c4c9b7a2879ad1768f600c5b5906c3af20d47de42c3b032956fa8db1a76"),
+		},
+		Signature: MustBytesFromHex("0xc3b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6"),
+	},
+}
+
 // WithValidatorSet configures the mock with the provided validator set.
 func WithValidatorSet(set ValidatorSet) Option {
 	return func(mock *Mock) {
@@ -368,9 +399,7 @@ func WithDeterministicProposerDuties(factor int) Option {
 
 			valIdxs := vals.Indices()
 
-			sort.Slice(valIdxs, func(i, j int) bool {
-				return valIdxs[i] < valIdxs[j]
-			})
+			slices.Sort(valIdxs)
 
 			slotsPerEpoch, err := mock.SlotsPerEpoch(ctx)
 			if err != nil {
@@ -706,3 +735,15 @@ func mustPKFromHex(pubkeyHex string) eth2p0.BLSPubKey {
 
 	return resp
 }
+
+// MustBytesFromHex converts a hex string to bytes, panicking on error.
+func MustBytesFromHex(hexStr string) []byte {
+	hexStr = strings.TrimPrefix(hexStr, "0x")
+
+	b, err := hex.DecodeString(hexStr)
+	if err != nil {
+		panic(err)
+	}
+
+	return b
+}
diff --git a/testutil/integration/simnet_test.go b/testutil/integration/simnet_test.go
index e70ca8c0..5da08821 100644
--- a/testutil/integration/simnet_test.go
+++ b/testutil/integration/simnet_test.go
@@ -80,13 +80,6 @@ func TestSimnetDuties(t *testing.T) {
 			duties:        []core.DutyType{core.DutyProposer, core.DutyRandao},
 			vcType:        vcVmock,
 		},
-		{
-			name:          "proposer with mock VCs with builder API",
-			scheduledType: core.DutyProposer,
-			duties:        []core.DutyType{core.DutyBuilderRegistration, core.DutyProposer, core.DutyRandao},
-			vcType:        vcVmock,
-			builderAPI:    true,
-		},
 		// TODO(kalo): Teku tests fail with invalid signature since removal of v1 endpoints.
 		// TODO(kalo): Assess if it is still the case, once pre-electra is implemented for v2 endpoints.
 		// {
@@ -95,12 +88,6 @@ func TestSimnetDuties(t *testing.T) {
 		// 	duties:        []core.DutyType{core.DutyProposer, core.DutyRandao},
 		// 	vcType:        vcTeku,
 		// },
-		{
-			name:       "builder registration with mock VCs",
-			duties:     []core.DutyType{core.DutyBuilderRegistration},
-			builderAPI: true,
-			vcType:     vcVmock,
-		},
 		// TODO(kalo): Teku tests fail with invalid signature since removal of v1 endpoints.
 		// TODO(kalo): Assess if it is still the case, once pre-electra is implemented for v2 endpoints.
 		// {
@@ -132,12 +119,26 @@ func TestSimnetDuties(t *testing.T) {
 		// 	exit:   true,
 		// 	vcType: vcTeku,
 		// },
-		{
-			name:               "pre-generate registrations",
-			duties:             []core.DutyType{core.DutyBuilderRegistration},
-			builderAPI:         true,
-			pregenRegistration: true,
-		},
+		// TODO(andrei): Need a redesign due to how builder registration is handled now.
+		// {
+		// 	name:       "builder registration with mock VCs",
+		// 	duties:     []core.DutyType{core.DutyBuilderRegistration},
+		// 	builderAPI: true,
+		// 	vcType:     vcVmock,
+		// },
+		// {
+		// 	name:               "pre-generate registrations",
+		// 	duties:             []core.DutyType{core.DutyBuilderRegistration},
+		// 	builderAPI:         true,
+		// 	pregenRegistration: true,
+		// },
+		// {
+		// 	name:          "proposer with mock VCs with builder API",
+		// 	scheduledType: core.DutyProposer,
+		// 	duties:        []core.DutyType{core.DutyBuilderRegistration, core.DutyProposer, core.DutyRandao},
+		// 	vcType:        vcVmock,
+		// 	builderAPI:    true,
+		// },
 	}
 
 	for _, test := range tests {
diff --git a/testutil/obolapimock/deposit.go b/testutil/obolapimock/deposit.go
new file mode 100644
index 00000000..2efe26d5
--- /dev/null
+++ b/testutil/obolapimock/deposit.go
@@ -0,0 +1,205 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package obolapimock
+
+import (
+	"encoding/hex"
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
+	"github.com/gorilla/mux"
+
+	"github.com/obolnetwork/charon/app/errors"
+	"github.com/obolnetwork/charon/app/obolapi"
+	"github.com/obolnetwork/charon/eth2util"
+	"github.com/obolnetwork/charon/eth2util/deposit"
+	"github.com/obolnetwork/charon/tbls"
+)
+
+const (
+	submitPartialDepositTmpl = "/deposit_data/partial_deposits/" + lockHashPath + "/" + shareIndexPath
+	fetchFullDepositTmpl     = "/deposit_data/" + lockHashPath + "/" + valPubkeyPath
+)
+
+// depositBlob represents an Obol API DepositBlob with its share index.
+type depositBlob struct {
+	obolapi.FullDepositResponse
+
+	shareIdx uint64
+}
+
+func (ts *testServer) HandleSubmitPartialDeposit(writer http.ResponseWriter, request *http.Request) {
+	ts.lock.Lock()
+	defer ts.lock.Unlock()
+
+	vars := mux.Vars(request)
+
+	var data obolapi.PartialDepositRequest
+
+	if err := json.NewDecoder(request.Body).Decode(&data); err != nil {
+		writeErr(writer, http.StatusBadRequest, "invalid body")
+		return
+	}
+
+	lockHash := vars[cleanTmpl(lockHashPath)]
+	if lockHash == "" {
+		writeErr(writer, http.StatusBadRequest, "invalid lock hash")
+		return
+	}
+
+	lock, ok := ts.lockFiles[lockHash]
+	if !ok {
+		writeErr(writer, http.StatusNotFound, "lock not found")
+		return
+	}
+
+	shareIndexVar := vars[cleanTmpl(shareIndexPath)]
+	if shareIndexVar == "" {
+		writeErr(writer, http.StatusBadRequest, "invalid share index")
+		return
+	}
+
+	shareIndex, err := strconv.ParseUint(shareIndexVar, 10, 64)
+	if err != nil {
+		writeErr(writer, http.StatusBadRequest, "malformed share index")
+		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
+	}
+
+	network, err := eth2util.ForkVersionToNetwork(lock.ForkVersion)
+	if err != nil {
+		writeErr(writer, http.StatusBadRequest, "invalid network")
+		return
+	}
+
+	for _, depositData := range data.PartialDepositData {
+		signedDepositsRoot, err := deposit.GetMessageSigningRoot(eth2p0.DepositMessage{
+			PublicKey:             depositData.PublicKey,
+			WithdrawalCredentials: depositData.WithdrawalCredentials,
+			Amount:                depositData.Amount,
+		}, network)
+		if err != nil {
+			writeErr(writer, http.StatusInternalServerError, "cannot calculate hash tree root for provided signed exits")
+			return
+		}
+
+		publicKeyShare := tbls.PublicKey{}
+		for _, v := range lock.Validators {
+			if v.PublicKeyHex() == depositData.PublicKey.String() {
+				publicKeyShare, err = v.PublicShare(int(shareIndex) - 1)
+				if err != nil {
+					writeErr(writer, http.StatusBadRequest, "cannot fetch public share: "+err.Error())
+					return
+				}
+			}
+		}
+
+		if len(publicKeyShare) == 0 {
+			writeErr(writer, http.StatusBadRequest, "cannot find public key in lock file: "+err.Error())
+			return
+		}
+
+		if err := tbls.Verify(publicKeyShare, signedDepositsRoot[:], tbls.Signature(depositData.Signature)); err != nil {
+			writeErr(writer, http.StatusBadRequest, "cannot verify signature: "+err.Error())
+			return
+		}
+
+		existingDeposit, ok := ts.partialDeposits[depositData.PublicKey.String()]
+
+		amounts := []obolapi.Amount{}
+		if ok {
+			amounts = existingDeposit.Amounts
+		}
+
+		amtFound := false
+
+		for idx, amt := range amounts {
+			if amt.Amount == strconv.FormatUint(uint64(depositData.Amount), 10) {
+				amt.Partials = append(amt.Partials, obolapi.Partial{
+					PartialDepositSignature: depositData.Signature.String(),
+					PartialPublicKey:        "",
+				})
+				amounts[idx] = amt
+				amtFound = true
+			}
+		}
+
+		existingDeposit.Amounts = amounts
+
+		if !amtFound {
+			amounts = append(amounts, obolapi.Amount{
+				Amount: strconv.FormatUint(uint64(depositData.Amount), 10),
+				Partials: []obolapi.Partial{
+					{
+						PartialDepositSignature: depositData.Signature.String(),
+						PartialPublicKey:        "",
+					},
+				},
+			})
+
+			ts.partialDeposits[depositData.PublicKey.String()] = depositBlob{
+				FullDepositResponse: obolapi.FullDepositResponse{
+					PublicKey:             depositData.PublicKey.String(),
+					WithdrawalCredentials: hex.EncodeToString(depositData.WithdrawalCredentials),
+					Amounts:               amounts,
+				},
+				shareIdx: shareIndex,
+			}
+		}
+	}
+
+	writer.WriteHeader(http.StatusCreated)
+}
+
+func (ts *testServer) HandleGetFullDeposit(writer http.ResponseWriter, request *http.Request) {
+	ts.lock.Lock()
+	defer ts.lock.Unlock()
+
+	vars := mux.Vars(request)
+
+	valPubkey := vars[cleanTmpl(valPubkeyPath)]
+	lockHash := vars[cleanTmpl(lockHashPath)]
+
+	lock, ok := ts.lockFiles[lockHash]
+	if !ok {
+		writeErr(writer, http.StatusNotFound, "lock not found")
+		return
+	}
+
+	partialDeposits, ok := ts.partialDeposits[valPubkey]
+	if !ok {
+		writeErr(writer, http.StatusNotFound, "validator not found")
+		return
+	}
+
+	amountsWithEnoughPartials := []obolapi.Amount{}
+
+	for _, pd := range partialDeposits.Amounts {
+		if len(pd.Partials) >= lock.Threshold {
+			amountsWithEnoughPartials = append(amountsWithEnoughPartials, pd)
+		}
+	}
+
+	if len(amountsWithEnoughPartials) == 0 {
+		writeErr(writer, http.StatusUnauthorized, "not enough partial deposits for any amount")
+		return
+	}
+
+	depositResponseData := obolapi.FullDepositResponse{
+		PublicKey:             partialDeposits.PublicKey,
+		WithdrawalCredentials: partialDeposits.WithdrawalCredentials,
+		Amounts:               amountsWithEnoughPartials,
+	}
+
+	if err := json.NewEncoder(writer).Encode(depositResponseData); err != nil {
+		writeErr(writer, http.StatusInternalServerError, errors.Wrap(err, "cannot marshal exit message").Error())
+		return
+	}
+}
diff --git a/testutil/obolapimock/obolapi_exit.go b/testutil/obolapimock/exit.go
similarity index 69%
rename from testutil/obolapimock/obolapi_exit.go
rename to testutil/obolapimock/exit.go
index c336aaeb..e061c2d2 100644
--- a/testutil/obolapimock/obolapi_exit.go
+++ b/testutil/obolapimock/exit.go
@@ -12,28 +12,19 @@ import (
 	"sort"
 	"strconv"
 	"strings"
-	"sync"
 
 	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
 	"github.com/gorilla/mux"
 
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/eth2wrap"
-	"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/cluster"
-	"github.com/obolnetwork/charon/eth2util/enr"
 	"github.com/obolnetwork/charon/eth2util/signing"
 	"github.com/obolnetwork/charon/tbls"
 )
 
 const (
-	lockHashPath   = "{lock_hash}"
-	valPubkeyPath  = "{validator_pubkey}"
-	shareIndexPath = "{share_index}"
-
 	expPartialExits = "/exp/partial_exits"
 	expExit         = "/exp/exit"
 
@@ -42,26 +33,6 @@ const (
 	fetchFullExitTmpl     = "/" + lockHashPath + "/" + shareIndexPath + "/" + valPubkeyPath
 )
 
-type contextKey string
-
-const (
-	tokenContextKey contextKey = "token"
-)
-
-type tsError struct {
-	Message string
-}
-
-func writeErr(wr http.ResponseWriter, status int, msg string) {
-	resp, err := json.Marshal(tsError{Message: msg})
-	if err != nil {
-		panic(err) // never happens
-	}
-
-	wr.WriteHeader(status)
-	_, _ = wr.Write(resp)
-}
-
 // exitBlob represents an Obol API ExitBlob with its share index.
 type exitBlob struct {
 	obolapi.ExitBlob
@@ -69,33 +40,6 @@ type exitBlob struct {
 	shareIdx uint64
 }
 
-// testServer is a mock implementation (but that actually does cryptography) of the Obol API side,
-// which will handle storing and recollecting partial signatures.
-type testServer struct {
-	// for convenience, this thing handles one request at a time
-	lock sync.Mutex
-
-	// store the partial exits by the validator pubkey
-	partialExits map[string][]exitBlob
-
-	// store the lock file by its lock hash
-	lockFiles map[string]cluster.Lock
-
-	// drop one partial signature when returning the full set
-	dropOnePsig bool
-
-	// Beacon node client, needed to verify exits.
-	beacon eth2wrap.Client
-}
-
-// addLockFiles adds a set of lock files to ts.
-func (ts *testServer) addLockFiles(lock cluster.Lock) {
-	ts.lock.Lock()
-	defer ts.lock.Unlock()
-
-	ts.lockFiles["0x"+hex.EncodeToString(lock.LockHash)] = lock
-}
-
 func (ts *testServer) HandleSubmitPartialExit(writer http.ResponseWriter, request *http.Request) {
 	ts.lock.Lock()
 	defer ts.lock.Unlock()
@@ -382,104 +326,6 @@ func (ts *testServer) partialExitsMatch(newOne obolapi.ExitBlob) bool {
 	return *last.SignedExitMessage.Message == *newOne.SignedExitMessage.Message
 }
 
-// verifyIdentitySignature verifies that sig for hash has been created with operator's identity key.
-func verifyIdentitySignature(operator cluster.Operator, sig, hash []byte) error {
-	opENR, err := enr.Parse(operator.ENR)
-	if err != nil {
-		return errors.Wrap(err, "operator enr")
-	}
-
-	verified, err := k1util.Verify65(opENR.PubKey, hash, sig)
-	if err != nil {
-		return errors.Wrap(err, "k1 signature verify")
-	}
-
-	if !verified {
-		return errors.New("identity signature verification failed")
-	}
-
-	return nil
-}
-
-// cleanTmpl cleans tmpl from '{' and '}', used in path definitions.
-func cleanTmpl(tmpl string) string {
-	return strings.NewReplacer(
-		"{",
-		"",
-		"}",
-		"").Replace(tmpl)
-}
-
-// MockServer returns an Obol API mock test server.
-// It returns a http.Handler to be served over HTTP, and a function to add cluster lock files to its database.
-func MockServer(dropOnePsig bool, beacon eth2wrap.Client) (http.Handler, func(lock cluster.Lock)) {
-	ts := testServer{
-		lock:         sync.Mutex{},
-		partialExits: map[string][]exitBlob{},
-		lockFiles:    map[string]cluster.Lock{},
-		dropOnePsig:  dropOnePsig,
-		beacon:       beacon,
-	}
-
-	router := mux.NewRouter()
-
-	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(expPartialExits+submitPartialExitTmpl, ts.HandleSubmitPartialExit).Methods(http.MethodPost)
-
-	return router, ts.addLockFiles
-}
-
-func authMiddleware(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer")
-
-		bearer = strings.TrimSpace(bearer)
-		if bearer == "" {
-			writeErr(w, http.StatusUnauthorized, "missing authorization header")
-			return
-		}
-
-		bearerBytes, err := from0x(bearer, 65)
-		if err != nil {
-			writeErr(w, http.StatusBadRequest, "bearer token must be hex-encoded")
-			return
-		}
-
-		r = r.WithContext(context.WithValue(r.Context(), tokenContextKey, bearerBytes))
-
-		// compare the return-value to the authMW
-		next.ServeHTTP(w, r)
-	})
-}
-
-// from0x decodes hex-encoded data and expects it to be exactly of len(length).
-// Accepts both 0x-prefixed strings or not.
-func from0x(data string, length int) ([]byte, error) {
-	if data == "" {
-		return nil, errors.New("empty data")
-	}
-
-	b, err := hex.DecodeString(strings.TrimPrefix(data, "0x"))
-	if err != nil {
-		return nil, errors.Wrap(err, "decode hex")
-	} else if len(b) != length {
-		return nil, errors.Wrap(err,
-			"invalid hex length",
-			z.Int("expect", length),
-			z.Int("actual", len(b)),
-		)
-	}
-
-	return b, nil
-}
-
 // sigDataForExit returns the hash tree root for the given exit message, at the given exit epoch.
 func sigDataForExit(ctx context.Context, exit eth2p0.VoluntaryExit, eth2Cl eth2wrap.Client, exitEpoch eth2p0.Epoch) ([32]byte, error) {
 	sigRoot, err := exit.HashTreeRoot()
diff --git a/testutil/obolapimock/obolapi.go b/testutil/obolapimock/obolapi.go
new file mode 100644
index 00000000..897e8651
--- /dev/null
+++ b/testutil/obolapimock/obolapi.go
@@ -0,0 +1,179 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package obolapimock
+
+import (
+	"context"
+	"encoding/hex"
+	"encoding/json"
+	"net/http"
+	"strings"
+	"sync"
+
+	"github.com/gorilla/mux"
+
+	"github.com/obolnetwork/charon/app/errors"
+	"github.com/obolnetwork/charon/app/eth2wrap"
+	"github.com/obolnetwork/charon/app/k1util"
+	"github.com/obolnetwork/charon/app/z"
+	"github.com/obolnetwork/charon/cluster"
+	"github.com/obolnetwork/charon/eth2util/enr"
+)
+
+const (
+	lockHashPath   = "{lock_hash}"
+	valPubkeyPath  = "{validator_pubkey}"
+	shareIndexPath = "{share_index}"
+)
+
+type contextKey string
+
+const (
+	tokenContextKey contextKey = "token"
+)
+
+type tsError struct {
+	Message string
+}
+
+func writeErr(wr http.ResponseWriter, status int, msg string) {
+	resp, err := json.Marshal(tsError{Message: msg})
+	if err != nil {
+		panic(err) // never happens
+	}
+
+	wr.WriteHeader(status)
+	_, _ = wr.Write(resp)
+}
+
+// testServer is a mock implementation (but that actually does cryptography) of the Obol API side,
+// which will handle storing and recollecting partial signatures.
+type testServer struct {
+	// for convenience, this thing handles one request at a time
+	lock sync.Mutex
+
+	// store the partial exits by the validator pubkey
+	partialExits map[string][]exitBlob
+
+	// store the partial deposits by the validator pubkey
+	partialDeposits map[string]depositBlob
+
+	// store the lock file by its lock hash
+	lockFiles map[string]cluster.Lock
+
+	// drop one partial signature when returning the full set
+	dropOnePsig bool
+
+	// Beacon node client, needed to verify exits.
+	beacon eth2wrap.Client
+}
+
+// addLockFiles adds a set of lock files to ts.
+func (ts *testServer) addLockFiles(lock cluster.Lock) {
+	ts.lock.Lock()
+	defer ts.lock.Unlock()
+
+	ts.lockFiles["0x"+hex.EncodeToString(lock.LockHash)] = lock
+}
+
+// verifyIdentitySignature verifies that sig for hash has been created with operator's identity key.
+func verifyIdentitySignature(operator cluster.Operator, sig, hash []byte) error {
+	opENR, err := enr.Parse(operator.ENR)
+	if err != nil {
+		return errors.Wrap(err, "operator enr")
+	}
+
+	verified, err := k1util.Verify65(opENR.PubKey, hash, sig)
+	if err != nil {
+		return errors.Wrap(err, "k1 signature verify")
+	}
+
+	if !verified {
+		return errors.New("identity signature verification failed")
+	}
+
+	return nil
+}
+
+// cleanTmpl cleans tmpl from '{' and '}', used in path definitions.
+func cleanTmpl(tmpl string) string {
+	return strings.NewReplacer(
+		"{",
+		"",
+		"}",
+		"").Replace(tmpl)
+}
+
+// MockServer returns an Obol API mock test server.
+// It returns a http.Handler to be served over HTTP, and a function to add cluster lock files to its database.
+func MockServer(dropOnePsig bool, beacon eth2wrap.Client) (http.Handler, func(lock cluster.Lock)) {
+	ts := testServer{
+		lock:            sync.Mutex{},
+		partialExits:    map[string][]exitBlob{},
+		partialDeposits: map[string]depositBlob{},
+		lockFiles:       map[string]cluster.Lock{},
+		dropOnePsig:     dropOnePsig,
+		beacon:          beacon,
+	}
+
+	router := mux.NewRouter()
+
+	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(expPartialExits+submitPartialExitTmpl, ts.HandleSubmitPartialExit).Methods(http.MethodPost)
+
+	router.HandleFunc(submitPartialDepositTmpl, ts.HandleSubmitPartialDeposit).Methods(http.MethodPost)
+	router.HandleFunc(fetchFullDepositTmpl, ts.HandleGetFullDeposit).Methods(http.MethodGet)
+
+	return router, ts.addLockFiles
+}
+
+func authMiddleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer")
+
+		bearer = strings.TrimSpace(bearer)
+		if bearer == "" {
+			writeErr(w, http.StatusUnauthorized, "missing authorization header")
+			return
+		}
+
+		bearerBytes, err := from0x(bearer, 65)
+		if err != nil {
+			writeErr(w, http.StatusBadRequest, "bearer token must be hex-encoded")
+			return
+		}
+
+		r = r.WithContext(context.WithValue(r.Context(), tokenContextKey, bearerBytes))
+
+		// compare the return-value to the authMW
+		next.ServeHTTP(w, r)
+	})
+}
+
+// from0x decodes hex-encoded data and expects it to be exactly of len(length).
+// Accepts both 0x-prefixed strings or not.
+func from0x(data string, length int) ([]byte, error) {
+	if data == "" {
+		return nil, errors.New("empty data")
+	}
+
+	b, err := hex.DecodeString(strings.TrimPrefix(data, "0x"))
+	if err != nil {
+		return nil, errors.Wrap(err, "decode hex")
+	} else if len(b) != length {
+		return nil, errors.Wrap(err,
+			"invalid hex length",
+			z.Int("expect", length),
+			z.Int("actual", len(b)),
+		)
+	}
+
+	return b, nil
+}
diff --git a/testutil/validatormock/component.go b/testutil/validatormock/component.go
index eb7433fd..a39b6194 100644
--- a/testutil/validatormock/component.go
+++ b/testutil/validatormock/component.go
@@ -302,6 +302,8 @@ func (m *Component) runDuty(ctx context.Context, duty core.Duty) error {
 		if _, err = syncComm.Aggregate(ctx, eth2Slot); err != nil { // Rename to sync.Comm.AggregateSyncContribution
 			return err
 		}
+	case core.DutyBuilderRegistration:
+		// Expected duty, but no action needed in validatormock.
 	default:
 		return errors.New("unexpected duty", z.Str("duty", duty.String()))
 	}