Repository: ObolNetwork/charon

Issue:
Review fetch active/complete activators by slot
# 🎯 Problem to be solved
We updated the validator cache refresh logic to fetch active/complete validators by slot rather than by the head of beacon node. Since some beacon nodes could be slow, we introduced a retry mechanism.

However this retry mechanism has a high retry count, doesn't use our `expbackoff` implementation and doesn't default to fetching by `head` after N failed attempts. This lead to some beacon nodes refusing to reply due to our excessive retries leading to empty response which caused further issues.

# 🛠️ Proposed solution
- [ ] Core team consensus on the proposed solution

Try to fetch once by slot (N).
If it fails, fetch by head and record that the previous fetched failed.
In the next slot (N+1), we check if the previous failed and retry fetching by slot (N and not N+1).

Add grafana panel to display which peers are connected to possibly slow beacon nodes by recording when we are able to fetch by slot versus when we default to head.

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

Hints from issue comments:

Solution patch:
diff --git a/app/app.go b/app/app.go
index 33605232..9123557a 100644
--- a/app/app.go
+++ b/app/app.go
@@ -456,16 +456,17 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config,
 
 	// Setup validator cache, refreshing it every epoch.
 	valCache := eth2wrap.NewValidatorCache(eth2Cl, eth2Pubkeys)
-	eth2Cl.SetValidatorCache(valCache.Get)
+	eth2Cl.SetValidatorCache(valCache.GetByHead)
 
 	firstValCacheRefresh := true
+	refreshedBySlot := true
 	var fvcrLock sync.RWMutex
 
 	shouldUpdateCache := func(slot core.Slot, lock *sync.RWMutex) bool {
 		lock.RLock()
 		defer lock.RUnlock()
 
-		if !slot.FirstInEpoch() && !firstValCacheRefresh {
+		if !slot.FirstInEpoch() && !firstValCacheRefresh && refreshedBySlot {
 			return false
 		}
 
@@ -484,13 +485,22 @@ func wireCoreWorkflow(ctx context.Context, life *lifecycle.Manager, conf Config,
 
 		log.Info(ctx, "Refreshing validator cache")
 
+		// If not refreshed by slot previously then fetch the first slot of the epoch
+		var slotToFetch uint64
+		if !refreshedBySlot {
+			slotToFetch = slot.Epoch() * slot.SlotsPerEpoch
+		} else {
+			slotToFetch = slot.Slot
+		}
+
 		valCache.Trim()
-		_, _, err := valCache.GetBySlot(ctx, slot.Slot)
+		_, _, refresh, err := valCache.GetBySlot(ctx, slotToFetch)
 		if err != nil {
 			log.Error(ctx, "Cannot refresh validator cache", err)
 			return err
 		}
 
+		refreshedBySlot = refresh
 		firstValCacheRefresh = false
 
 		return nil
diff --git a/app/eth2wrap/valcache.go b/app/eth2wrap/valcache.go
index 3d1dd1d0..23dc90ea 100644
--- a/app/eth2wrap/valcache.go
+++ b/app/eth2wrap/valcache.go
@@ -6,20 +6,12 @@ import (
 	"context"
 	"strconv"
 	"sync"
-	"time"
 
 	eth2api "github.com/attestantio/go-eth2-client/api"
 	eth2v1 "github.com/attestantio/go-eth2-client/api/v1"
 	eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0"
 
 	"github.com/obolnetwork/charon/app/errors"
-	"github.com/obolnetwork/charon/app/log"
-	"github.com/obolnetwork/charon/app/z"
-)
-
-const (
-	maxRetries = 20
-	retryDelay = 100 * time.Millisecond
 )
 
 // ActiveValidators is a map of active validator indices to pubkeys.
@@ -100,7 +92,7 @@ func (c *ValidatorCache) cached() (CompleteValidators, bool) {
 }
 
 // Get returns the cached active validators, cached complete Validators response, or fetches them if not available populating the cache.
-func (c *ValidatorCache) Get(ctx context.Context) (ActiveValidators, CompleteValidators, error) {
+func (c *ValidatorCache) GetByHead(ctx context.Context) (ActiveValidators, CompleteValidators, error) {
 	completeCached, completeOk := c.cached()
 	activeCached, activeOk := c.activeCached()
 
@@ -143,32 +135,27 @@ func (c *ValidatorCache) Get(ctx context.Context) (ActiveValidators, CompleteVal
 }
 
 // GetBySlot fetches active and complete validator by slot populating the cache.
-func (c *ValidatorCache) GetBySlot(ctx context.Context, slot uint64) (ActiveValidators, CompleteValidators, error) {
+// If it fails to fetch by slot, it falls back to head state and retries to fetch by slot next slot.
+func (c *ValidatorCache) GetBySlot(ctx context.Context, slot uint64) (ActiveValidators, CompleteValidators, bool, error) {
 	c.mu.Lock()
 	defer c.mu.Unlock()
 
+	refreshedBySlot := true
+
 	opts := &eth2api.ValidatorsOpts{
 		State:   strconv.FormatUint(slot, 10),
 		PubKeys: c.pubkeys,
 	}
 
-	var eth2Resp *eth2api.Response[map[eth2p0.ValidatorIndex]*eth2v1.Validator]
-	var err error
-
-	for retryCount := 0; retryCount < maxRetries; retryCount++ {
+	eth2Resp, err := c.eth2Cl.Validators(ctx, opts)
+	if err != nil {
+		// Failed to fetch by slot, fall back to head state
+		refreshedBySlot = false
+		opts.State = "head"
 		eth2Resp, err = c.eth2Cl.Validators(ctx, opts)
-		if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
-			break
+		if err != nil {
+			return nil, nil, refreshedBySlot, err
 		}
-
-		sleepDuration := retryDelay * time.Duration(retryCount+1)
-		time.Sleep(sleepDuration)
-
-		log.Info(ctx, "Retrying fetching validators by slot", z.U64("slot", slot), z.Int("retryCount", retryCount+1), z.Err(err))
-	}
-
-	if err != nil {
-		return nil, nil, wrapError(ctx, err, "Failed to fetch validators by slot after maximum retries")
 	}
 
 	complete := eth2Resp.Data
@@ -176,7 +163,7 @@ func (c *ValidatorCache) GetBySlot(ctx context.Context, slot uint64) (ActiveVali
 	active := make(ActiveValidators)
 	for _, val := range complete {
 		if val == nil || val.Validator == nil {
-			return nil, nil, errors.New("validator data cannot be nil")
+			return nil, nil, refreshedBySlot, errors.New("validator data cannot be nil")
 		}
 
 		if !val.Status.IsActive() {
@@ -189,5 +176,5 @@ func (c *ValidatorCache) GetBySlot(ctx context.Context, slot uint64) (ActiveVali
 	c.active = active
 	c.complete = complete
 
-	return active, complete, nil
+	return active, complete, refreshedBySlot, nil
 }
diff --git a/app/obolapi/api.go b/app/obolapi/api.go
index 7ad1f560..b8ce27fb 100644
--- a/app/obolapi/api.go
+++ b/app/obolapi/api.go
@@ -20,7 +20,7 @@ const (
 	// launchpadReturnPathFmt is the URL path format string at which one can find details for a given cluster lock hash.
 	launchpadReturnPathFmt = "/lock/0x%X/launchpad"
 
-	// defaultTimeout is the default HTTP request timeout if not specified
+	// defaultTimeout is the default HTTP request timeout if not specified.
 	defaultTimeout = 10 * time.Second
 )
 
@@ -33,7 +33,6 @@ func New(urlStr string, options ...func(*Client)) (Client, error) {
 
 	// always set a default timeout, even if no options are provided
 	options = append([]func(*Client){WithTimeout(defaultTimeout)}, options...)
-
 	cl := Client{
 		baseURL: urlStr,
 	}
@@ -69,28 +68,6 @@ func (c Client) url() *url.URL {
 	return baseURL
 }
 
-// PublishLock posts the lockfile to obol-api.
-// It respects the timeout specified in the Client instance.
-func (c Client) PublishLock(ctx context.Context, lock cluster.Lock) error {
-	addr := c.url()
-	addr.Path = "lock"
-
-	b, err := lock.MarshalJSON()
-	if err != nil {
-		return errors.Wrap(err, "marshal lock")
-	}
-
-	ctx, cancel := context.WithTimeout(ctx, c.reqTimeout)
-	defer cancel()
-
-	err = httpPost(ctx, addr, b, nil)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
 // LaunchpadURLForLock returns the Launchpad cluster dashboard page for a given lock, on the given
 // Obol API client.
 func (c Client) LaunchpadURLForLock(lock cluster.Lock) string {
diff --git a/app/obolapi/publish.go b/app/obolapi/publish.go
new file mode 100644
index 00000000..8c56e7eb
--- /dev/null
+++ b/app/obolapi/publish.go
@@ -0,0 +1,102 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package obolapi
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"github.com/obolnetwork/charon/app/errors"
+	"github.com/obolnetwork/charon/cluster"
+)
+
+const (
+	// publishLockPath is the path to publish a cluster lockfile to obol-api.
+	publishLockPath = "lock"
+
+	// publishDefinitionPath is the path to publish a cluster definition to obol-api.
+	publishDefinitionPath = "/definition"
+
+	// termsAndConditionsPath is the path to sign Obol's Terms and Conditions.
+	termsAndConditionsPath = "/termsAndConditions"
+
+	// termsAndConditionsHash is the hash of the terms and conditions that the user must sign.
+	termsAndConditionsHash = "0xd33721644e8f3afab1495a74abe3523cec12d48b8da6cb760972492ca3f1a273"
+)
+
+// PublishLock posts the lockfile to obol-api.
+// It respects the timeout specified in the Client instance.
+func (c Client) PublishLock(ctx context.Context, lock cluster.Lock) error {
+	addr := c.url()
+	addr.Path = publishLockPath
+
+	b, err := lock.MarshalJSON()
+	if err != nil {
+		return errors.Wrap(err, "marshal lock")
+	}
+
+	ctx, cancel := context.WithTimeout(ctx, c.reqTimeout)
+	defer cancel()
+
+	err = httpPost(ctx, addr, b, nil)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// PublishDefinition posts the cluster definition to obol-api.
+// It requires the cluster creator to previously sign Obol's Terms and Conditions.
+func (c Client) PublishDefinition(ctx context.Context, def cluster.Definition, sig []byte) error {
+	addr := c.url()
+	addr.Path += publishDefinitionPath
+
+	b, err := def.MarshalJSON()
+	if err != nil {
+		return errors.Wrap(err, "marshal definition")
+	}
+
+	headers := map[string]string{
+		"authorization": bearerString(sig),
+	}
+
+	ctx, cancel := context.WithTimeout(ctx, c.reqTimeout)
+	defer cancel()
+
+	return httpPost(ctx, addr, b, headers)
+}
+
+type RequestSignTermsAndConditions struct {
+	Address                string `json:"address"`
+	Version                int    `json:"version"`
+	TermsAndConditionsHash string `json:"terms_and_conditions_hash"`
+	ForkVersion            string `json:"fork_version"`
+}
+
+// SignTermsAndConditions submits the user's signature of Obol's Terms and Conditions to obol-api.
+func (c Client) SignTermsAndConditions(ctx context.Context, userAddr string, forkVersion []byte, sig []byte) error {
+	addr := c.url()
+	addr.Path += termsAndConditionsPath
+
+	req := RequestSignTermsAndConditions{
+		Address:                userAddr,
+		Version:                1,
+		TermsAndConditionsHash: termsAndConditionsHash,
+		ForkVersion:            fmt.Sprintf("0x%x", forkVersion),
+	}
+
+	r, err := json.Marshal(req)
+	if err != nil {
+		return errors.Wrap(err, "marshal sign terms and Conditions")
+	}
+
+	headers := map[string]string{
+		"authorization": bearerString(sig),
+	}
+
+	ctx, cancel := context.WithTimeout(ctx, c.reqTimeout)
+	defer cancel()
+	return httpPost(ctx, addr, r, headers)
+}
diff --git a/app/retry/retry.go b/app/retry/retry.go
index 5b5f5703..8206368f 100644
--- a/app/retry/retry.go
+++ b/app/retry/retry.go
@@ -16,6 +16,7 @@ import (
 	"time"
 
 	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/codes"
 	"go.opentelemetry.io/otel/trace"
 
 	"github.com/obolnetwork/charon/app/errors"
@@ -108,15 +109,16 @@ func (r *Retryer[T]) DoAsync(parent context.Context, t T, topic, name string, fn
 	defer cancel()
 
 	_, span := tracer.Start(r.asyncCtx, "app/retry.DoAsync")
-	defer span.End()
 	span.SetAttributes(attribute.String("topic", topic))
 	span.SetAttributes(attribute.String("name", name))
+	defer span.End()
 
 	for i := 0; ; i++ {
 		span.AddEvent("retry.attempt.start", trace.WithAttributes(attribute.Int("i", i)))
 
 		err := fn(ctx)
 		if err == nil {
+			span.SetStatus(codes.Ok, "success")
 			return
 		}
 
@@ -127,27 +129,30 @@ func (r *Retryer[T]) DoAsync(parent context.Context, t T, topic, name string, fn
 		// Note that the local context is not checked, since we care about downstream timeouts.
 
 		if !isCtxErr && !isNetErr && !isTempErr {
+			span.RecordError(err)
+			span.SetStatus(codes.Error, err.Error())
 			log.Error(ctx, "Permanent failure calling "+label, err)
+
 			return
 		}
 
 		if ctx.Err() == nil {
 			log.Warn(ctx, "Temporary failure (will retry) calling "+label, err)
-			span.AddEvent("retry.backoff.start")
 			select {
 			case <-backoffFunc():
 			case <-ctx.Done():
 			case <-r.shutdown:
 				return
 			}
-			span.AddEvent("retry.backoff.done")
 		}
 
 		if r.asyncCtx.Err() != nil {
 			return // Shutdown, return without logging
 		} else if ctx.Err() != nil {
+			span.SetStatus(codes.Error, "timeout")
 			// No need to log this at error level since tracker will analyse and report on failed duties.
 			log.Debug(ctx, "Timeout calling "+label+", duty expired")
+
 			return
 		}
 	}
diff --git a/app/vmock.go b/app/vmock.go
index 7408903a..90aeea01 100644
--- a/app/vmock.go
+++ b/app/vmock.go
@@ -96,7 +96,7 @@ func newVMockEth2Provider(conf Config, pubshares []eth2p0.BLSPubKey) func() (eth
 
 			cached = eth2wrap.AdaptEth2HTTP(eth2Http, nil, timeout)
 			valCache := eth2wrap.NewValidatorCache(cached, pubshares)
-			cached.SetValidatorCache(valCache.Get)
+			cached.SetValidatorCache(valCache.GetByHead)
 
 			break
 		}
diff --git a/cluster/eip712sigs.go b/cluster/eip712sigs.go
index 9542b1a5..19d0b159 100644
--- a/cluster/eip712sigs.go
+++ b/cluster/eip712sigs.go
@@ -11,47 +11,94 @@ import (
 	"github.com/obolnetwork/charon/eth2util/eip712"
 )
 
-// eip712Type defines the EIP712 (https://eips.ethereum.org/EIPS/eip-712) Primary type and the Message field and value.
+// eip712TypeField defines the fields, values and types of a EIP712 message
+type eip712TypeField struct {
+	Field     string
+	Type      eip712.Primitive
+	ValueFunc func(Definition, Operator) any
+}
+
+// eip712Type defines the EIP712 (https://eips.ethereum.org/EIPS/eip-712) Primary type and the Message fields.
 type eip712Type struct {
 	PrimaryType string
-	Field       string
-	ValueFunc   func(Definition, Operator) string
+	Fields      []eip712TypeField
 }
 
 var (
 	// eip712CreatorConfigHash defines the EIP712 structure of the legacy config signature for v1.4 and later.
 	eip712CreatorConfigHash = eip712Type{
 		PrimaryType: "CreatorConfigHash",
-		Field:       "creator_config_hash",
-		ValueFunc: func(definition Definition, _ Operator) string {
-			return to0xHex(definition.ConfigHash)
+		Fields: []eip712TypeField{
+			{
+				Field: "creator_config_hash",
+				Type:  eip712.PrimitiveString,
+				ValueFunc: func(definition Definition, _ Operator) any {
+					return to0xHex(definition.ConfigHash)
+				},
+			},
 		},
 	}
 
 	// eip712OperatorConfigHash defines the EIP712 structure of the operator config signature for v1.4 and later.
 	eip712OperatorConfigHash = eip712Type{
 		PrimaryType: "OperatorConfigHash",
-		Field:       "operator_config_hash",
-		ValueFunc: func(definition Definition, _ Operator) string {
-			return to0xHex(definition.ConfigHash)
+		Fields: []eip712TypeField{
+			{
+				Field: "operator_config_hash",
+				Type:  eip712.PrimitiveString,
+				ValueFunc: func(definition Definition, _ Operator) any {
+					return to0xHex(definition.ConfigHash)
+				},
+			},
 		},
 	}
 
 	// eip712V1x3ConfigHash defines the EIP712 structure of the legacy config signature for v1.3 and before.
 	eip712V1x3ConfigHash = eip712Type{
 		PrimaryType: "ConfigHash",
-		Field:       "config_hash",
-		ValueFunc: func(definition Definition, _ Operator) string {
-			return to0xHex(definition.ConfigHash)
+		Fields: []eip712TypeField{
+			{
+				Field: "config_hash",
+				Type:  eip712.PrimitiveString,
+				ValueFunc: func(definition Definition, _ Operator) any {
+					return to0xHex(definition.ConfigHash)
+				},
+			},
 		},
 	}
 
 	// eip712ENR defines the EIP712 structure of the enr signature.
 	eip712ENR = eip712Type{
 		PrimaryType: "ENR",
-		Field:       "enr",
-		ValueFunc: func(_ Definition, operator Operator) string {
-			return operator.ENR
+		Fields: []eip712TypeField{
+			{
+				Field: "enr",
+				Type:  eip712.PrimitiveString,
+				ValueFunc: func(_ Definition, operator Operator) any {
+					return operator.ENR
+				},
+			},
+		},
+	}
+
+	// eip712TermsAndConditions defines the EIP712 structure of the terms and conditions signature.
+	eip712TermsAndConditions = eip712Type{
+		PrimaryType: "TermsAndConditions",
+		Fields: []eip712TypeField{
+			{
+				Field: "terms_and_conditions_hash",
+				Type:  eip712.PrimitiveString,
+				ValueFunc: func(_ Definition, _ Operator) any {
+					return "0xd33721644e8f3afab1495a74abe3523cec12d48b8da6cb760972492ca3f1a273"
+				},
+			},
+			{
+				Field: "version",
+				Type:  eip712.PrimitiveUint256,
+				ValueFunc: func(_ Definition, _ Operator) any {
+					return uint64(1)
+				},
+			},
 		},
 	}
 )
@@ -84,16 +131,17 @@ func digestEIP712(typ eip712Type, def Definition, operator Operator) ([]byte, er
 		},
 		Type: eip712.Type{
 			Name: typ.PrimaryType,
-			Fields: []eip712.Field{
-				{
-					Name:  typ.Field,
-					Type:  eip712.PrimitiveString,
-					Value: typ.ValueFunc(def, operator),
-				},
-			},
 		},
 	}
 
+	for _, field := range typ.Fields {
+		data.Type.Fields = append(data.Type.Fields, eip712.Field{
+			Name:  field.Field,
+			Type:  field.Type,
+			Value: field.ValueFunc(def, operator),
+		})
+	}
+
 	digest, err := eip712.HashTypedData(data)
 	if err != nil {
 		return nil, errors.Wrap(err, "hash EIP712")
@@ -116,3 +164,13 @@ func signEIP712(secret *k1.PrivateKey, typ eip712Type, def Definition, operator
 
 	return sig, nil
 }
+
+// SignTermsAndConditions returns the EIP712 signature for Obol's Terms and Conditions
+func SignTermsAndConditions(secret *k1.PrivateKey, def Definition) ([]byte, error) {
+	return signEIP712(secret, eip712TermsAndConditions, def, Operator{})
+}
+
+// SignClusterDefinitionHash returns the EIP712 signature for cluster configuration hash
+func SignClusterDefinitionHash(secret *k1.PrivateKey, def Definition) ([]byte, error) {
+	return signEIP712(secret, eip712CreatorConfigHash, def, Operator{})
+}
diff --git a/cmd/createdkg.go b/cmd/createdkg.go
index 9deed095..52cc0daa 100644
--- a/cmd/createdkg.go
+++ b/cmd/createdkg.go
@@ -6,14 +6,18 @@ import (
 	"context"
 	crand "crypto/rand"
 	"encoding/json"
+	"fmt"
 	"os"
 	"path"
+	"time"
 
+	k1 "github.com/decred/dcrd/dcrec/secp256k1/v4"
 	"github.com/spf13/cobra"
 
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/eth1wrap"
 	"github.com/obolnetwork/charon/app/log"
+	"github.com/obolnetwork/charon/app/obolapi"
 	"github.com/obolnetwork/charon/app/version"
 	"github.com/obolnetwork/charon/app/z"
 	"github.com/obolnetwork/charon/cluster"
@@ -38,6 +42,9 @@ type createDKGConfig struct {
 	TargetGasLimit      uint
 	Compounding         bool
 	ExecutionEngineAddr string
+	Publish             bool
+	PublishAddress      string
+	OperatorsAddresses  []string
 }
 
 func newCreateDKGCmd(runFunc func(context.Context, createDKGConfig) error) *cobra.Command {
@@ -68,6 +75,16 @@ func newCreateDKGCmd(runFunc func(context.Context, createDKGConfig) error) *cobr
 			}
 		}
 
+		if config.Publish {
+			mustMarkFlagRequired(cmd, "operator-addresses")
+		} else {
+			mustMarkFlagRequired(cmd, "operator-enrs")
+		}
+
+		if len(config.OperatorENRs) != 0 && len(config.OperatorsAddresses) != 0 {
+			return errors.New("cannot provide both --operator-enrs and --operator-addresses")
+		}
+
 		return nil
 	})
 
@@ -75,8 +92,6 @@ func newCreateDKGCmd(runFunc func(context.Context, createDKGConfig) error) *cobr
 }
 
 func bindCreateDKGFlags(cmd *cobra.Command, config *createDKGConfig) {
-	const operatorENRs = "operator-enrs"
-
 	cmd.Flags().StringVar(&config.Name, "name", "", "Optional cosmetic cluster name")
 	cmd.Flags().StringVar(&config.OutputDir, "output-dir", ".charon", "The folder to write the output cluster-definition.json file to.")
 	cmd.Flags().IntVar(&config.NumValidators, "num-validators", 1, "The number of distributed validators the cluster will manage (32ETH+ staked for each).")
@@ -86,13 +101,14 @@ func bindCreateDKGFlags(cmd *cobra.Command, config *createDKGConfig) {
 	cmd.Flags().StringVar(&config.Network, "network", defaultNetwork, "Ethereum network to create validators for. Options: mainnet, goerli, sepolia, hoodi, holesky, gnosis, chiado.")
 	cmd.Flags().StringVar(&config.DKGAlgo, "dkg-algorithm", "default", "DKG algorithm to use; default, frost")
 	cmd.Flags().IntSliceVar(&config.DepositAmounts, "deposit-amounts", nil, "List of partial deposit amounts (integers) in ETH. Values must sum up to at least 32ETH.")
-	cmd.Flags().StringSliceVar(&config.OperatorENRs, operatorENRs, nil, "[REQUIRED] Comma-separated list of each operator's Charon ENR address.")
+	cmd.Flags().StringSliceVar(&config.OperatorENRs, "operator-enrs", nil, "Comma-separated list of each operator's Charon ENR address.")
 	cmd.Flags().StringVar(&config.ConsensusProtocol, "consensus-protocol", "", "Preferred consensus protocol name for the cluster. Selected automatically when not specified.")
 	cmd.Flags().UintVar(&config.TargetGasLimit, "target-gas-limit", 36000000, "Preferred target gas limit for transactions.")
 	cmd.Flags().BoolVar(&config.Compounding, "compounding", false, "Enable compounding rewards for validators by using 0x02 withdrawal credentials.")
 	cmd.Flags().StringVar(&config.ExecutionEngineAddr, "execution-client-rpc-endpoint", "", "The address of the execution engine JSON-RPC API.")
-
-	mustMarkFlagRequired(cmd, operatorENRs)
+	cmd.Flags().BoolVar(&config.Publish, "publish", false, "Creates an invitation to the DKG ceremony on the DV Launchpad. Terms and conditions apply.")
+	cmd.Flags().StringVar(&config.PublishAddress, "publish-address", "https://api.obol.tech/v1", "The URL to publish the cluster to.")
+	cmd.Flags().StringSliceVar(&config.OperatorsAddresses, "operator-addresses", nil, "Comma-separated list of each operator's Ethereum address.")
 }
 
 func mustMarkFlagRequired(cmd *cobra.Command, flag string) {
@@ -107,7 +123,14 @@ func runCreateDKG(ctx context.Context, conf createDKGConfig) (err error) {
 		conf.Network = eth2util.Goerli.Name
 	}
 
-	if err = validateDKGConfig(len(conf.OperatorENRs), conf.Network, conf.DepositAmounts, conf.ConsensusProtocol, conf.Compounding); err != nil {
+	var operatorsLen int
+	if len(conf.OperatorENRs) > 0 {
+		operatorsLen = len(conf.OperatorENRs)
+	} else {
+		operatorsLen = len(conf.OperatorsAddresses)
+	}
+
+	if err = validateDKGConfig(operatorsLen, conf.Network, conf.DepositAmounts, conf.ConsensusProtocol, conf.Compounding); err != nil {
 		return err
 	}
 
@@ -136,8 +159,22 @@ func runCreateDKG(ctx context.Context, conf createDKGConfig) (err error) {
 			ENR: opENR,
 		})
 	}
+	for i, opAddr := range conf.OperatorsAddresses {
+		checksumAddr, err := eth2util.ChecksumAddress(opAddr)
+		if err != nil {
+			return errors.Wrap(err, "invalid operator address", z.Int("operator", i))
+		}
+		operators = append(operators, cluster.Operator{
+			Address: checksumAddr,
+		})
+	}
 
-	safeThreshold := cluster.Threshold(len(conf.OperatorENRs))
+	var safeThreshold int
+	if len(conf.OperatorENRs) == 0 {
+		safeThreshold = cluster.Threshold(len(conf.OperatorsAddresses))
+	} else {
+		safeThreshold = cluster.Threshold(len(conf.OperatorENRs))
+	}
 	if conf.Threshold == 0 {
 		conf.Threshold = safeThreshold
 	} else {
@@ -149,27 +186,55 @@ func runCreateDKG(ctx context.Context, conf createDKGConfig) (err error) {
 		return err
 	}
 
+	var privKey *k1.PrivateKey
+	creator := cluster.Creator{}
+
+	// Populate creator field
+	if conf.Publish {
+		// Temporary creator address
+		privKey, err = k1.GeneratePrivateKey()
+		if err != nil {
+			return errors.Wrap(err, "generate private key")
+		}
+		creator = cluster.Creator{
+			Address: eth2util.PublicKeyToAddress(privKey.PubKey()),
+		}
+	}
+
 	var opts []func(*cluster.Definition)
 	opts = append(opts, cluster.WithDKGAlgorithm(conf.DKGAlgo))
 	def, err := cluster.NewDefinition(
 		conf.Name, conf.NumValidators, conf.Threshold,
 		conf.FeeRecipientAddrs, conf.WithdrawalAddrs,
-		forkVersion, cluster.Creator{}, operators, conf.DepositAmounts,
+		forkVersion, creator, operators, conf.DepositAmounts,
 		conf.ConsensusProtocol, conf.TargetGasLimit, conf.Compounding,
 		crand.Reader, opts...)
 	if err != nil {
 		return err
 	}
-
 	if err := def.VerifyHashes(); err != nil {
 		return err
 	}
 
+	// Generate creator signature after hashes have been populated
+	if conf.Publish {
+		def.Creator.ConfigSignature, err = cluster.SignClusterDefinitionHash(privKey, def)
+		if err != nil {
+			return errors.Wrap(err, "sign cluster definition")
+		}
+	}
+
 	eth1Cl := eth1wrap.NewDefaultEthClientRunner(conf.ExecutionEngineAddr)
 	go eth1Cl.Run(ctx)
 
-	if err := def.VerifySignatures(eth1Cl); err != nil {
-		return err
+	if !conf.Publish {
+		if err := def.VerifySignatures(eth1Cl); err != nil {
+			return err
+		}
+	}
+
+	if conf.Publish {
+		return publishPartialDefinition(ctx, conf, privKey, def)
 	}
 
 	b, err := json.MarshalIndent(def, "", " ")
@@ -237,3 +302,32 @@ func validateDKGConfig(numOperators int, network string, depositAmounts []int, c
 func isMainOrGnosis(network string) bool {
 	return network == eth2util.Mainnet.Name || network == eth2util.Gnosis.Name
 }
+
+func publishPartialDefinition(ctx context.Context, conf createDKGConfig, privKey *k1.PrivateKey, def cluster.Definition) error {
+	apiClient, err := obolapi.New(conf.PublishAddress, obolapi.WithTimeout(10*time.Second))
+	if err != nil {
+		return errors.Wrap(err, "create Obol API client")
+	}
+
+	sig, err := cluster.SignTermsAndConditions(privKey, def)
+	if err != nil {
+		return errors.Wrap(err, "sign terms")
+	}
+
+	err = apiClient.SignTermsAndConditions(ctx, def.Creator.Address, def.ForkVersion, sig)
+	if err != nil {
+		return errors.Wrap(err, "submit sign terms")
+	}
+
+	log.Info(ctx, "Creator successfully signed Obol's terms and conditions")
+
+	err = apiClient.PublishDefinition(ctx, def, def.Creator.ConfigSignature)
+	if err != nil {
+		return errors.Wrap(err, "publish cluster definition")
+	}
+
+	log.Info(ctx, "Cluster Invitation Prepared")
+	log.Info(ctx, "Direct the Node Operators to: https://launchpad.obol.org/dv#"+fmt.Sprintf("%#x", def.ConfigHash)+" to review the cluster configuration and begin the distributed key generation ceremony.")
+
+	return nil
+}
diff --git a/core/consensus/qbft/qbft.go b/core/consensus/qbft/qbft.go
index 8e6338aa..f7752f8c 100644
--- a/core/consensus/qbft/qbft.go
+++ b/core/consensus/qbft/qbft.go
@@ -14,6 +14,7 @@ import (
 	"github.com/libp2p/go-libp2p/core/peer"
 	"github.com/libp2p/go-libp2p/core/protocol"
 	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/codes"
 	"go.opentelemetry.io/otel/trace"
 	"google.golang.org/protobuf/proto"
 	"google.golang.org/protobuf/types/known/anypb"
@@ -389,8 +390,18 @@ func (c *Consensus) runInstance(parent context.Context, duty core.Duty) (err err
 		span    trace.Span
 	)
 
-	_, span = tracer.Start(parent, "qbft.runInstance")
-	defer span.End()
+	_, span = tracer.Start(ctx, "qbft.runInstance")
+	span.SetAttributes(attribute.String("duty", duty.Type.String()))
+
+	defer func() {
+		if err != nil && !isContextErr(err) {
+			span.RecordError(err)
+			span.SetStatus(codes.Error, err.Error())
+		} else {
+			span.SetStatus(codes.Ok, "")
+		}
+		span.End()
+	}()
 
 	decideCallback := func(qcommit []qbft.Msg[core.Duty, [32]byte]) {
 		round := qcommit[0].Round()
@@ -412,7 +423,10 @@ func (c *Consensus) runInstance(parent context.Context, duty core.Duty) (err err
 		span.SetAttributes(attribute.Int64("round", round))
 		span.SetAttributes(attribute.Int64("leader_index", leaderIndex))
 		span.SetAttributes(attribute.String("leader_name", leaderName))
-		endSpanWithEvent(span, "qbft.Decided")
+		span.AddEvent("qbft.Decided")
+
+		// qbft.Run() is stopped by cancelling the context, or if an error occurred.
+		cancel()
 	}
 
 	// Create a new qbft definition for this instance.
@@ -438,15 +452,14 @@ func (c *Consensus) runInstance(parent context.Context, duty core.Duty) (err err
 	// Run the algo, blocking until the context is cancelled.
 	err = qbft.Run(ctx, def, qt, duty, peerIdx, inst.HashCh)
 	if err != nil && !isContextErr(err) {
-		endSpanWithEvent(span, "qbft.Error")
-
+		span.AddEvent("qbft.Error")
 		c.metrics.IncConsensusError()
 
 		return err // Only return non-context errors.
 	}
 
 	if !decided {
-		endSpanWithEvent(span, "qbft.Timeout")
+		span.AddEvent("qbft.Timeout")
 		c.metrics.IncConsensusTimeout(duty.Type.String(), string(roundTimer.Type()))
 
 		return errors.New("consensus timeout", z.Str("duty", duty.String()))
@@ -455,11 +468,6 @@ func (c *Consensus) runInstance(parent context.Context, duty core.Duty) (err err
 	return nil
 }
 
-func endSpanWithEvent(span trace.Span, event string) {
-	span.AddEvent(event)
-	span.End()
-}
-
 // handle processes an incoming consensus wire message.
 func (c *Consensus) handle(ctx context.Context, _ peer.ID, req proto.Message) (proto.Message, bool, error) {
 	t0 := time.Now()
diff --git a/core/parsigdb/memory.go b/core/parsigdb/memory.go
index ada45b00..6c0d76f3 100644
--- a/core/parsigdb/memory.go
+++ b/core/parsigdb/memory.go
@@ -92,10 +92,6 @@ func (db *MemDB) StoreExternal(ctx context.Context, duty core.Duty, signedSet co
 			continue
 		}
 
-		log.Debug(ctx, "Partial signed data stored",
-			z.Int("count", len(sigs)),
-			z.Any("pubkey", pubkey))
-
 		// Check if sufficient matching partial signed data has been received.
 		psigs, ok, err := getThresholdMatching(duty.Type, sigs, db.threshold)
 		if err != nil {
diff --git a/core/sigagg/sigagg.go b/core/sigagg/sigagg.go
index d43d0f73..264d3d5c 100644
--- a/core/sigagg/sigagg.go
+++ b/core/sigagg/sigagg.go
@@ -9,6 +9,8 @@ package sigagg
 import (
 	"context"
 
+	"go.opentelemetry.io/otel/codes"
+
 	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/eth2wrap"
 	"github.com/obolnetwork/charon/app/log"
@@ -101,23 +103,35 @@ func (a *Aggregator) aggregate(ctx context.Context, pubkey core.PubKey, parSigs
 	}
 
 	// Aggregate signatures
-	_, span := tracer.Start(ctx, "tbls.Aggregate")
+	_, span := tracer.Start(ctx, "tbls.ThresholdAggregate")
+	defer span.End()
+
 	sig, err := tbls.ThresholdAggregate(blsSigs)
-	span.End()
 	if err != nil {
+		span.RecordError(err)
+		span.SetStatus(codes.Error, err.Error())
+
 		return nil, err
 	}
 
 	// Inject signature into one of the parSigs resulting in aggregate signed data.
 	aggSig, err := parSigs[0].SetSignature(tblsconv.SigToCore(sig))
 	if err != nil {
+		span.RecordError(err)
+		span.SetStatus(codes.Error, err.Error())
+
 		return nil, err
 	}
 
 	if err := a.verifyFunc(ctx, pubkey, aggSig); err != nil {
+		span.RecordError(err)
+		span.SetStatus(codes.Error, err.Error())
+
 		return nil, err
 	}
 
+	span.SetStatus(codes.Ok, "success")
+
 	return aggSig, nil
 }
 
diff --git a/core/tracing.go b/core/tracing.go
index d69352b0..46d9459c 100644
--- a/core/tracing.go
+++ b/core/tracing.go
@@ -8,9 +8,11 @@ import (
 	"sync"
 
 	eth2api "github.com/attestantio/go-eth2-client/api"
+	"go.opentelemetry.io/otel/codes"
 	semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
 	"go.opentelemetry.io/otel/trace"
 
+	"github.com/obolnetwork/charon/app/errors"
 	"github.com/obolnetwork/charon/app/tracer"
 )
 
@@ -72,73 +74,88 @@ func WithTracing() WireOption {
 			ctx, span := tracer.Start(parent, "core/fetcher.Fetch")
 			defer span.End()
 
-			return clone.FetcherFetch(ctx, duty, set)
+			return withSpanStatus(span, clone.FetcherFetch(ctx, duty, set))
 		}
 		w.ConsensusParticipate = func(parent context.Context, duty Duty) error {
 			ctx, span := tracer.Start(parent, "core/consensus.Participate")
 			defer span.End()
 
-			return clone.ConsensusParticipate(ctx, duty)
+			return withSpanStatus(span, clone.ConsensusParticipate(ctx, duty))
 		}
 		w.ConsensusPropose = func(parent context.Context, duty Duty, set UnsignedDataSet) error {
 			ctx, span := tracer.Start(parent, "core/consensus.Propose")
 			defer span.End()
 
-			return clone.ConsensusPropose(ctx, duty, set)
+			return withSpanStatus(span, clone.ConsensusPropose(ctx, duty, set))
 		}
 		w.DutyDBStore = func(parent context.Context, duty Duty, set UnsignedDataSet) error {
 			ctx, span := tracer.Start(parent, "core/dutydb.Store")
 			defer span.End()
 
-			return clone.DutyDBStore(ctx, duty, set)
+			return withSpanStatus(span, clone.DutyDBStore(ctx, duty, set))
 		}
 		w.DutyDBAwaitProposal = func(parent context.Context, slot uint64) (*eth2api.VersionedProposal, error) {
 			ctx, span := tracer.Start(parent, "core/dutydb.AwaitProposal")
 			defer span.End()
 
-			return clone.DutyDBAwaitProposal(ctx, slot)
+			vp, err := clone.DutyDBAwaitProposal(ctx, slot)
+
+			return vp, withSpanStatus(span, err)
 		}
 		w.ParSigDBStoreInternal = func(parent context.Context, duty Duty, set ParSignedDataSet) error {
 			ctx, span := tracer.Start(parent, "core/parsigdb.StoreInternal")
 			defer span.End()
 
-			return clone.ParSigDBStoreInternal(ctx, duty, set)
+			return withSpanStatus(span, clone.ParSigDBStoreInternal(ctx, duty, set))
 		}
 		w.ParSigDBStoreExternal = func(parent context.Context, duty Duty, set ParSignedDataSet) error {
 			ctx, span := tracer.Start(parent, "core/parsigdb.StoreExternal")
 			defer span.End()
 
-			return clone.ParSigDBStoreExternal(ctx, duty, set)
+			return withSpanStatus(span, clone.ParSigDBStoreExternal(ctx, duty, set))
 		}
 		w.ParSigExBroadcast = func(parent context.Context, duty Duty, set ParSignedDataSet) error {
 			ctx, span := tracer.Start(parent, "core/parsigex.Broadcast")
 			defer span.End()
 
-			return clone.ParSigExBroadcast(ctx, duty, set)
+			return withSpanStatus(span, clone.ParSigExBroadcast(ctx, duty, set))
 		}
 		w.SigAggAggregate = func(parent context.Context, duty Duty, set map[PubKey][]ParSignedData) error {
 			ctx, span := tracer.Start(parent, "core/sigagg.Aggregate")
 			defer span.End()
 
-			return clone.SigAggAggregate(ctx, duty, set)
+			return withSpanStatus(span, clone.SigAggAggregate(ctx, duty, set))
 		}
 		w.AggSigDBStore = func(parent context.Context, duty Duty, set SignedDataSet) error {
 			ctx, span := tracer.Start(parent, "core/aggsigdb.Store")
 			defer span.End()
 
-			return clone.AggSigDBStore(ctx, duty, set)
+			return withSpanStatus(span, clone.AggSigDBStore(ctx, duty, set))
 		}
 		w.AggSigDBAwait = func(parent context.Context, duty Duty, key PubKey) (SignedData, error) {
 			ctx, span := tracer.Start(parent, "core/aggsigdb.Await")
 			defer span.End()
 
-			return clone.AggSigDBAwait(ctx, duty, key)
+			sd, err := clone.AggSigDBAwait(ctx, duty, key)
+
+			return sd, withSpanStatus(span, err)
 		}
 		w.BroadcasterBroadcast = func(parent context.Context, duty Duty, set SignedDataSet) error {
 			ctx, span := tracer.Start(parent, "core/broadcaster.Broadcast")
 			defer span.End()
 
-			return clone.BroadcasterBroadcast(ctx, duty, set)
+			return withSpanStatus(span, clone.BroadcasterBroadcast(ctx, duty, set))
 		}
 	}
 }
+
+func withSpanStatus(span trace.Span, err error) error {
+	if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
+		span.RecordError(err)
+		span.SetStatus(codes.Error, err.Error())
+	} else {
+		span.SetStatus(codes.Ok, "")
+	}
+
+	return err
+}
diff --git a/eth2util/eip712/eip712.go b/eth2util/eip712/eip712.go
index 7336173e..f3d93ed9 100644
--- a/eth2util/eip712/eip712.go
+++ b/eth2util/eip712/eip712.go
@@ -8,6 +8,7 @@ import (
 	"bytes"
 	"encoding/binary"
 	"fmt"
+	"slices"
 
 	"golang.org/x/crypto/sha3"
 
@@ -18,8 +19,9 @@ import (
 type Primitive string
 
 const (
-	PrimitiveString  Primitive = "string"
-	PrimitiveUint256 Primitive = "uint256"
+	PrimitiveString           Primitive = "string"
+	PrimitiveUint256          Primitive = "uint256"
+	TermsAndConditionTypeName string    = "TermsAndConditions"
 )
 
 // TypedData represents a dynamically typed EIP-712 message.
@@ -64,7 +66,16 @@ func domainToType(domain Domain) Type {
 
 // HashTypedData returns the hash of the typed data.
 func HashTypedData(data TypedData) ([]byte, error) {
-	domainHash, err := hashData(domainToType(data.Domain))
+	domainType := domainToType(data.Domain)
+
+	// TODO(diogo): temporary hack until api is updated then remove
+	// Currently, api doesn't have chainId field in the domain for
+	// eip712 termsAndConditions message. This will change in the future.
+	if data.Type.Name == TermsAndConditionTypeName {
+		domainType.Fields = slices.Delete(domainType.Fields, 2, 3)
+	}
+
+	domainHash, err := hashData(domainType)
 	if err != nil {
 		return nil, err
 	}



Test patch:
diff --git a/app/eth2wrap/valcache_test.go b/app/eth2wrap/valcache_test.go
index 9815ee03..958794e2 100644
--- a/app/eth2wrap/valcache_test.go
+++ b/app/eth2wrap/valcache_test.go
@@ -59,14 +59,14 @@ func TestValidatorCache(t *testing.T) {
 	ctx := context.Background()
 
 	// Check cache is populated.
-	actual, complete, err := valCache.Get(ctx)
+	actual, complete, err := valCache.GetByHead(ctx)
 	require.NoError(t, err)
 	require.Equal(t, expected, actual)
 	require.Equal(t, 1, queried)
 	require.Equal(t, completeExpected, complete)
 
 	// Check cache is used.
-	actual, complete, err = valCache.Get(ctx)
+	actual, complete, err = valCache.GetByHead(ctx)
 	require.NoError(t, err)
 	require.Equal(t, expected, actual)
 	require.Equal(t, 1, queried)
@@ -76,14 +76,14 @@ func TestValidatorCache(t *testing.T) {
 	valCache.Trim()
 
 	// Check cache is populated again.
-	actual, complete, err = valCache.Get(ctx)
+	actual, complete, err = valCache.GetByHead(ctx)
 	require.NoError(t, err)
 	require.Equal(t, expected, actual)
 	require.Equal(t, 2, queried)
 	require.Equal(t, completeExpected, complete)
 
 	// Check cache is used again.
-	actual, complete, err = valCache.Get(ctx)
+	actual, complete, err = valCache.GetByHead(ctx)
 	require.NoError(t, err)
 	require.Equal(t, expected, actual)
 	require.Equal(t, 2, queried)
@@ -91,94 +91,146 @@ func TestValidatorCache(t *testing.T) {
 }
 
 func TestGetBySlot(t *testing.T) {
-	// Create a mock client.
-	eth2Cl, err := beaconmock.New()
-	require.NoError(t, err)
 
-	// Create two validators pubkeys
-	pubkeys := []eth2p0.BLSPubKey{
-		testutil.RandomEth2PubKey(t),
-		testutil.RandomEth2PubKey(t),
-	}
+	t.Run("successful fetch", func(t *testing.T) {
+		// Create a mock client.
+		eth2Cl, err := beaconmock.New()
+		require.NoError(t, err)
 
-	eth2Cl.ValidatorsFunc = func(ctx context.Context, opts *eth2api.ValidatorsOpts) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error) {
-		switch opts.State {
-		case "1":
-			return beaconmock.ValidatorSet{
-				0: &eth2v1.Validator{
-					Index:  0,
-					Status: eth2v1.ValidatorStatePendingQueued,
-					Validator: &eth2p0.Validator{
-						PublicKey: pubkeys[0],
+		// Create two validators pubkeys
+		pubkeys := []eth2p0.BLSPubKey{
+			testutil.RandomEth2PubKey(t),
+			testutil.RandomEth2PubKey(t),
+		}
+
+		eth2Cl.ValidatorsFunc = func(ctx context.Context, opts *eth2api.ValidatorsOpts) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error) {
+			switch opts.State {
+			case "1":
+				return beaconmock.ValidatorSet{
+					0: &eth2v1.Validator{
+						Index:  0,
+						Status: eth2v1.ValidatorStatePendingQueued,
+						Validator: &eth2p0.Validator{
+							PublicKey: pubkeys[0],
+						},
 					},
-				},
-				1: &eth2v1.Validator{
-					Index:  1,
-					Status: eth2v1.ValidatorStateActiveOngoing,
-					Validator: &eth2p0.Validator{
-						PublicKey: pubkeys[1],
+					1: &eth2v1.Validator{
+						Index:  1,
+						Status: eth2v1.ValidatorStateActiveOngoing,
+						Validator: &eth2p0.Validator{
+							PublicKey: pubkeys[1],
+						},
 					},
-				},
-			}, nil
-		case "2":
-			return beaconmock.ValidatorSet{
-				0: &eth2v1.Validator{
-					Index:  0,
-					Status: eth2v1.ValidatorStateActiveOngoing,
-					Validator: &eth2p0.Validator{
-						PublicKey: pubkeys[0],
+				}, nil
+			case "2":
+				return beaconmock.ValidatorSet{
+					0: &eth2v1.Validator{
+						Index:  0,
+						Status: eth2v1.ValidatorStateActiveOngoing,
+						Validator: &eth2p0.Validator{
+							PublicKey: pubkeys[0],
+						},
 					},
-				},
-				1: &eth2v1.Validator{
-					Index:  1,
-					Status: eth2v1.ValidatorStateActiveOngoing,
-					Validator: &eth2p0.Validator{
-						PublicKey: pubkeys[1],
+					1: &eth2v1.Validator{
+						Index:  1,
+						Status: eth2v1.ValidatorStateActiveOngoing,
+						Validator: &eth2p0.Validator{
+							PublicKey: pubkeys[1],
+						},
 					},
-				},
-			}, nil
-		case "11":
-			return beaconmock.ValidatorSet{
-				0: &eth2v1.Validator{
-					Index:  0,
-					Status: eth2v1.ValidatorStatePendingQueued,
-					Validator: &eth2p0.Validator{
-						PublicKey: pubkeys[0],
+				}, nil
+			case "11":
+				return beaconmock.ValidatorSet{
+					0: &eth2v1.Validator{
+						Index:  0,
+						Status: eth2v1.ValidatorStatePendingQueued,
+						Validator: &eth2p0.Validator{
+							PublicKey: pubkeys[0],
+						},
 					},
-				},
-				1: &eth2v1.Validator{
-					Index:  1,
-					Status: eth2v1.ValidatorStatePendingQueued,
-					Validator: &eth2p0.Validator{
-						PublicKey: pubkeys[1],
+					1: &eth2v1.Validator{
+						Index:  1,
+						Status: eth2v1.ValidatorStatePendingQueued,
+						Validator: &eth2p0.Validator{
+							PublicKey: pubkeys[1],
+						},
 					},
-				},
-			}, nil
+				}, nil
 
-		default:
-			return nil, errors.New("no slot found")
+			default:
+				return nil, errors.New("no slot found")
+			}
 		}
-	}
-
-	valCache := eth2wrap.NewValidatorCache(eth2Cl, pubkeys)
-	ctx := context.Background()
 
-	active, complete, err := valCache.GetBySlot(ctx, 1)
-	require.NoError(t, err)
-	require.Len(t, active, 1)
-	require.Equal(t, pubkeys[1], active[1])
-	require.Len(t, complete, 2)
+		valCache := eth2wrap.NewValidatorCache(eth2Cl, pubkeys)
+		ctx := t.Context()
+
+		active, complete, refreshedBySlot, err := valCache.GetBySlot(ctx, 1)
+		require.NoError(t, err)
+		require.Len(t, active, 1)
+		require.Equal(t, pubkeys[1], active[1])
+		require.Len(t, complete, 2)
+		require.True(t, refreshedBySlot)
+
+		active, complete, refreshedBySlot, err = valCache.GetBySlot(ctx, 2)
+		require.NoError(t, err)
+		require.Len(t, active, 2)
+		require.Len(t, complete, 2)
+		require.True(t, refreshedBySlot)
+
+		active, complete, refreshedBySlot, err = valCache.GetBySlot(ctx, 11)
+		require.NoError(t, err)
+		require.Empty(t, active)
+		require.Len(t, complete, 2)
+		require.True(t, refreshedBySlot)
+
+		_, _, refreshedBySlot, err = valCache.GetBySlot(ctx, 3)
+		require.Error(t, err)
+		require.False(t, refreshedBySlot)
+	})
+
+	t.Run("fallback to head state", func(t *testing.T) {
+		// Create a mock client.
+		eth2Cl, err := beaconmock.New()
+		require.NoError(t, err)
+
+		// Create two validators pubkeys
+		pubkeys := []eth2p0.BLSPubKey{
+			testutil.RandomEth2PubKey(t),
+			testutil.RandomEth2PubKey(t),
+		}
 
-	active, complete, err = valCache.GetBySlot(ctx, 2)
-	require.NoError(t, err)
-	require.Len(t, active, 2)
-	require.Len(t, complete, 2)
+		eth2Cl.ValidatorsFunc = func(ctx context.Context, opts *eth2api.ValidatorsOpts) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error) {
+			switch opts.State {
+			case "head":
+				return beaconmock.ValidatorSet{
+					0: &eth2v1.Validator{
+						Index:  0,
+						Status: eth2v1.ValidatorStateActiveOngoing,
+						Validator: &eth2p0.Validator{
+							PublicKey: pubkeys[0],
+						},
+					},
+					1: &eth2v1.Validator{
+						Index:  1,
+						Status: eth2v1.ValidatorStateActiveOngoing,
+						Validator: &eth2p0.Validator{
+							PublicKey: pubkeys[1],
+						},
+					},
+				}, nil
+			default:
+				return beaconmock.ValidatorSet{}, errors.New("no slot found")
+			}
+		}
 
-	active, complete, err = valCache.GetBySlot(ctx, 11)
-	require.NoError(t, err)
-	require.Empty(t, active)
-	require.Len(t, complete, 2)
+		valCache := eth2wrap.NewValidatorCache(eth2Cl, pubkeys)
+		ctx := t.Context()
 
-	_, _, err = valCache.GetBySlot(ctx, 3)
-	require.Error(t, err)
+		active, complete, refreshedBySlot, err := valCache.GetBySlot(ctx, 1)
+		require.NoError(t, err)
+		require.Len(t, active, 2)
+		require.Len(t, complete, 2)
+		require.False(t, refreshedBySlot)
+	})
 }
diff --git a/app/obolapi/api_test.go b/app/obolapi/api_test.go
index f28c0c9b..c71e5afa 100644
--- a/app/obolapi/api_test.go
+++ b/app/obolapi/api_test.go
@@ -98,3 +98,116 @@ func TestLaunchpadDashURL(t *testing.T) {
 		)
 	})
 }
+
+func TestSignTermsAndConditions(t *testing.T) {
+	t.Run("successful request", func(t *testing.T) {
+		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			require.Equal(t, "/termsAndConditions", r.URL.Path)
+			require.Equal(t, "application/json", r.Header.Get("Content-Type"))
+			require.Contains(t, r.Header.Get("Authorization"), "Bearer 0x5678")
+
+			data, err := io.ReadAll(r.Body)
+			require.NoError(t, err)
+			defer r.Body.Close()
+
+			var req obolapi.RequestSignTermsAndConditions
+			require.NoError(t, json.Unmarshal(data, &req))
+			require.Equal(t, 1, req.Version)
+			require.Equal(t, "user-address", req.Address)
+			require.Equal(t, "0xd33721644e8f3afab1495a74abe3523cec12d48b8da6cb760972492ca3f1a273", req.TermsAndConditionsHash)
+			require.Equal(t, "0x1234", req.ForkVersion)
+
+			w.WriteHeader(http.StatusOK)
+		}))
+		defer srv.Close()
+
+		cl, err := obolapi.New(srv.URL)
+		require.NoError(t, err)
+
+		err = cl.SignTermsAndConditions(t.Context(), "user-address", []byte{0x12, 0x34}, []byte{0x56, 0x78})
+		require.NoError(t, err)
+	})
+
+	t.Run("marshal error", func(t *testing.T) {
+		cl, err := obolapi.New("http://example.com")
+		require.NoError(t, err)
+
+		err = cl.SignTermsAndConditions(t.Context(), "user-address", nil, nil)
+		require.Error(t, err)
+	})
+
+	t.Run("server error response", func(t *testing.T) {
+		// Mock server to simulate a server error
+		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(http.StatusInternalServerError)
+		}))
+		defer srv.Close()
+
+		// Create the client
+		cl, err := obolapi.New(srv.URL)
+		require.NoError(t, err)
+
+		// Call the function
+		err = cl.SignTermsAndConditions(t.Context(), "user-address", []byte{0x12, 0x34}, []byte{0x56, 0x78})
+		require.Error(t, err)
+	})
+}
+
+func TestPublishDefinition(t *testing.T) {
+	t.Run("successful request", func(t *testing.T) {
+		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			require.Equal(t, "/definition", r.URL.Path)
+			require.Equal(t, "application/json", r.Header.Get("Content-Type"))
+			require.Contains(t, r.Header.Get("Authorization"), "Bearer 0x5678")
+
+			data, err := io.ReadAll(r.Body)
+			require.NoError(t, err)
+			defer r.Body.Close()
+
+			var req cluster.Definition
+			require.NoError(t, json.Unmarshal(data, &req))
+			require.Equal(t, "v1.10.0", req.Version)
+
+			w.WriteHeader(http.StatusOK)
+		}))
+		defer srv.Close()
+
+		cl, err := obolapi.New(srv.URL)
+		require.NoError(t, err)
+
+		def := cluster.Definition{
+			Version: "v1.10.0",
+		}
+
+		err = cl.PublishDefinition(t.Context(), def, []byte{0x56, 0x78})
+		require.NoError(t, err)
+	})
+
+	t.Run("marshal error", func(t *testing.T) {
+		cl, err := obolapi.New("http://example.com")
+		require.NoError(t, err)
+
+		// Call the function with an invalid definition to force a marshal error
+		var def cluster.Definition
+		err = cl.PublishDefinition(t.Context(), def, nil)
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "marshal definition")
+	})
+
+	t.Run("server error response", func(t *testing.T) {
+		srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(http.StatusInternalServerError)
+		}))
+		defer srv.Close()
+
+		cl, err := obolapi.New(srv.URL)
+		require.NoError(t, err)
+
+		def := cluster.Definition{
+			Version: "v1.10.0",
+		}
+
+		err = cl.PublishDefinition(t.Context(), def, []byte{0x56, 0x78})
+		require.Error(t, err)
+	})
+}
diff --git a/app/obolapi/test.go b/app/obolapi/test.go
new file mode 100644
index 00000000..5318a62f
--- /dev/null
+++ b/app/obolapi/test.go
@@ -0,0 +1,33 @@
+// Copyright © 2022-2025 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1
+
+package obolapi
+
+import (
+	"context"
+	"net/url"
+
+	"github.com/obolnetwork/charon/app/errors"
+)
+
+const (
+	postTestPath = "/test"
+)
+
+func (c Client) PostTestResult(ctx context.Context, jsonTestResult []byte) error {
+	u, err := url.ParseRequestURI(c.baseURL)
+	if err != nil {
+		return errors.Wrap(err, "bad Obol API url")
+	}
+
+	u.Path = postTestPath
+
+	ctx, cancel := context.WithTimeout(ctx, c.reqTimeout)
+	defer cancel()
+
+	err = httpPost(ctx, u, jsonTestResult, nil)
+	if err != nil {
+		return errors.Wrap(err, "http Obol API POST request")
+	}
+
+	return nil
+}
diff --git a/cmd/test.go b/cmd/test.go
index e552fdaf..f575b081 100644
--- a/cmd/test.go
+++ b/cmd/test.go
@@ -20,12 +20,16 @@ import (
 	"time"
 	"unicode/utf8"
 
+	ssz "github.com/ferranbt/fastssz"
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
 
 	"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/eth2util/enr"
 )
 
 var (
@@ -49,10 +53,13 @@ const (
 )
 
 type testConfig struct {
-	OutputJSON string
-	Quiet      bool
-	TestCases  []string
-	Timeout    time.Duration
+	OutputJSON            string
+	Quiet                 bool
+	TestCases             []string
+	Timeout               time.Duration
+	Publish               bool
+	PublishAddr           string
+	PublishPrivateKeyFile string
 }
 
 func newTestCmd(cmds ...*cobra.Command) *cobra.Command {
@@ -72,6 +79,9 @@ func bindTestFlags(cmd *cobra.Command, config *testConfig) {
 	cmd.Flags().StringSliceVar(&config.TestCases, "test-cases", nil, fmt.Sprintf("List of comma separated names of tests to be exeucted. Available tests are: %v", listTestCases(cmd)))
 	cmd.Flags().DurationVar(&config.Timeout, "timeout", time.Hour, "Execution timeout for all tests.")
 	cmd.Flags().BoolVar(&config.Quiet, "quiet", false, "Do not print test results to stdout.")
+	cmd.Flags().BoolVar(&config.Publish, "publish", false, "Publish test result file to obol-api.")
+	cmd.Flags().StringVar(&config.PublishAddr, "publish-address", "https://api.obol.tech/v1", "The URL to publish the test result file to.")
+	cmd.Flags().StringVar(&config.PublishPrivateKeyFile, "publish-private-key-file", ".charon/charon-enr-private-key", "The path to the charon enr private key file, used for signing the publish request.")
 }
 
 func bindTestLogFlags(flags *pflag.FlagSet, config *log.Config) {
@@ -204,6 +214,14 @@ type testCategoryResult struct {
 	Score         categoryScore           `json:"score,omitempty"`
 }
 
+type allCategoriesResult struct {
+	Peers     testCategoryResult `json:"charon_peers,omitempty"`
+	Beacon    testCategoryResult `json:"beacon_node,omitempty"`
+	Validator testCategoryResult `json:"validator_client,omitempty"`
+	MEV       testCategoryResult `json:"mev,omitempty"`
+	Infra     testCategoryResult `json:"infra,omitempty"`
+}
+
 func appendScore(cat []string, score []string) []string {
 	var res []string
 	for i, l := range cat {
@@ -213,12 +231,67 @@ func appendScore(cat []string, score []string) []string {
 	return res
 }
 
-type fileResult struct {
-	Peers     testCategoryResult `json:"charon_peers,omitempty"`
-	Beacon    testCategoryResult `json:"beacon_node,omitempty"`
-	Validator testCategoryResult `json:"validator_client,omitempty"`
-	MEV       testCategoryResult `json:"mev,omitempty"`
-	Infra     testCategoryResult `json:"infra,omitempty"`
+type obolAPIResult struct {
+	ENR  string              `json:"enr,omitempty"`
+	Sig  []byte              `json:"sig,omitempty"`
+	Data allCategoriesResult `json:"data"`
+}
+
+func publishResultToObolAPI(ctx context.Context, data allCategoriesResult, path string, privateKeyFile string) error {
+	p2pPrivKey, err := k1util.Load(privateKeyFile)
+	if err != nil {
+		return err
+	}
+
+	enr, err := enr.New(p2pPrivKey)
+	if err != nil {
+		return err
+	}
+
+	signDataBytes, err := json.Marshal(data)
+	if err != nil {
+		return errors.Wrap(err, "marshal all test categories signing data")
+	}
+
+	hh := ssz.DefaultHasherPool.Get()
+	defer ssz.DefaultHasherPool.Put(hh)
+
+	indx := hh.Index()
+	hh.PutBytes(signDataBytes)
+	hh.Merkleize(indx)
+
+	hash, err := hh.HashRoot()
+	if err != nil {
+		return errors.Wrap(err, "hash root")
+	}
+
+	sig, err := k1util.Sign(p2pPrivKey, hash[:])
+	if err != nil {
+		return errors.Wrap(err, "k1 sign")
+	}
+
+	obolAPI, err := obolapi.New(path)
+	if err != nil {
+		return err
+	}
+
+	res := obolAPIResult{
+		ENR:  enr.String(),
+		Sig:  sig,
+		Data: data,
+	}
+
+	obolAPIJSON, err := json.Marshal(res)
+	if err != nil {
+		return errors.Wrap(err, "marshal Obol API test struct")
+	}
+
+	err = obolAPI.PostTestResult(ctx, obolAPIJSON)
+	if err != nil {
+		return err
+	}
+
+	return nil
 }
 
 func writeResultToFile(res testCategoryResult, path string) error {
@@ -233,9 +306,9 @@ func writeResultToFile(res testCategoryResult, path string) error {
 		return errors.Wrap(err, "get file stat")
 	}
 	// read file contents or default to empty structure
-	var file fileResult
+	var file allCategoriesResult
 	if stat.Size() == 0 {
-		file = fileResult{}
+		file = allCategoriesResult{}
 	} else {
 		err = json.NewDecoder(existingFile).Decode(&file)
 		if err != nil {
diff --git a/cmd/testbeacon.go b/cmd/testbeacon.go
index 0cf9df1c..4d853136 100644
--- a/cmd/testbeacon.go
+++ b/cmd/testbeacon.go
@@ -263,6 +263,13 @@ func runTestBeacon(ctx context.Context, w io.Writer, cfg testBeaconConfig) (res
 		}
 	}
 
+	if cfg.Publish {
+		err = publishResultToObolAPI(ctx, allCategoriesResult{Beacon: res}, cfg.PublishAddr, cfg.PublishPrivateKeyFile)
+		if err != nil {
+			return res, err
+		}
+	}
+
 	return res, nil
 }
 
diff --git a/cmd/testinfra.go b/cmd/testinfra.go
index 06c47b6a..71591712 100644
--- a/cmd/testinfra.go
+++ b/cmd/testinfra.go
@@ -176,6 +176,13 @@ func runTestInfra(ctx context.Context, w io.Writer, cfg testInfraConfig) (res te
 		}
 	}
 
+	if cfg.Publish {
+		err = publishResultToObolAPI(ctx, allCategoriesResult{Infra: res}, cfg.PublishAddr, cfg.PublishPrivateKeyFile)
+		if err != nil {
+			return res, err
+		}
+	}
+
 	return res, nil
 }
 
diff --git a/cmd/testmev.go b/cmd/testmev.go
index ca1a4960..cd7c91cd 100644
--- a/cmd/testmev.go
+++ b/cmd/testmev.go
@@ -158,6 +158,13 @@ func runTestMEV(ctx context.Context, w io.Writer, cfg testMEVConfig) (res testCa
 		}
 	}
 
+	if cfg.Publish {
+		err = publishResultToObolAPI(ctx, allCategoriesResult{MEV: res}, cfg.PublishAddr, cfg.PublishPrivateKeyFile)
+		if err != nil {
+			return res, err
+		}
+	}
+
 	return res, nil
 }
 
diff --git a/cmd/testpeers.go b/cmd/testpeers.go
index 838d00a7..c67b9d43 100644
--- a/cmd/testpeers.go
+++ b/cmd/testpeers.go
@@ -238,6 +238,13 @@ func runTestPeers(ctx context.Context, w io.Writer, conf testPeersConfig) (res t
 		}
 	}
 
+	if conf.Publish {
+		err = publishResultToObolAPI(ctx, allCategoriesResult{Peers: res}, conf.PublishAddr, conf.PublishPrivateKeyFile)
+		if err != nil {
+			return res, err
+		}
+	}
+
 	log.Info(ctx, "Keeping TCP node alive for peers until keep-alive time is reached...")
 	blockAndWait(ctx, conf.KeepAlive)
 
diff --git a/cmd/testpeers_internal_test.go b/cmd/testpeers_internal_test.go
index 7eb376a3..1cfbd61b 100644
--- a/cmd/testpeers_internal_test.go
+++ b/cmd/testpeers_internal_test.go
@@ -11,6 +11,7 @@ import (
 	"io"
 	"maps"
 	"net/http"
+	"net/http/httptest"
 	"os"
 	"slices"
 	"strings"
@@ -267,6 +268,49 @@ func TestPeersTest(t *testing.T) {
 				require.NoError(t, err)
 			},
 		},
+		{
+			name: "publish to Obol API",
+			config: testPeersConfig{
+				testConfig: testConfig{
+					Publish: true,
+					Quiet:   false,
+					Timeout: 3 * time.Second,
+				},
+				ENRs: []string{
+					"enr:-HW4QBHlcyD3fYWUMADiOv4OxODaL5wJG0a7P7d_ltu4VZe1MibZ1N-twFaoaq0BoCtXcY71etxLJGeEZT5p3XCO6GOAgmlkgnY0iXNlY3AyNTZrMaEDI2HRUlVBag__njkOWEEQRLlC9ylIVCrIXOuNBSlrx6o",
+					"enr:-HW4QDwUF804f4WhUjwcp4JJ-PrRH0glQZv8s2cVHlBRPJ3SYcYO-dvJGsKhztffrski5eujJkl8oAc983MZy6-PqF2AgmlkgnY0iXNlY3AyNTZrMaECPEPryjkmUBnQFyjmMw9rl7DVtKL0243nN5iepqsvKDw",
+					"enr:-HW4QPSBgUTag8oZs3zIsgWzlBUrSgT8pgZmFJa7HWwKXUcRLlISa68OJtp-JTzhUXsJ2vSGwKGACn0OTatWdJATxn-AgmlkgnY0iXNlY3AyNTZrMaECA3R_ffXLXCLJsfEwf6xeoAFgWnDIOdq8kS0Yqkhwbr0",
+				},
+				P2P: p2p.Config{
+					TCPAddrs: []string{freeTCPAddr.String()},
+					Relays:   []string{relayAddr},
+				},
+				Log: log.DefaultConfig(),
+			},
+			expected: testCategoryResult{
+				CategoryName: peersTestCategory,
+				Targets: map[string][]testResult{
+					"self": {
+						{Name: "Libp2pTCPPortOpen", Verdict: testVerdictOk, Measurement: "", Suggestion: "", Error: testResultError{}},
+					},
+					fmt.Sprintf("relay %v", relayAddr): {
+						{Name: "PingRelay", Verdict: testVerdictOk, Measurement: "", Suggestion: "", Error: testResultError{}},
+						{Name: "PingMeasureRelay", Verdict: testVerdictGood, Measurement: "", Suggestion: "", Error: testResultError{}},
+					},
+					"peer inexpensive-farm enr:-HW4QBHlc...rx6o": {
+						{Name: "Ping", Verdict: testVerdictFail, Measurement: "", Suggestion: "", Error: errTimeoutInterrupted},
+					},
+					"peer anxious-pencil enr:-HW4QDwUF...vKDw": {
+						{Name: "Ping", Verdict: testVerdictFail, Measurement: "", Suggestion: "", Error: errTimeoutInterrupted},
+					},
+					"peer important-pen enr:-HW4QPSBg...wbr0": {
+						{Name: "Ping", Verdict: testVerdictFail, Measurement: "", Suggestion: "", Error: errTimeoutInterrupted},
+					},
+				},
+				Score: categoryScoreC,
+			},
+			expectedErr: "",
+		},
 	}
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
@@ -276,6 +320,11 @@ func TestPeersTest(t *testing.T) {
 			_, err := p2p.NewSavedPrivKey(temp)
 			require.NoError(t, err)
 			conf.PrivateKeyFile = p2p.KeyPath(temp)
+			if conf.Publish {
+				var server *httptest.Server
+				server, conf = testPublishToObolAPI(t, test.expected, conf, temp)
+				defer server.Close()
+			}
 			if test.prepare != nil {
 				conf = test.prepare(t, conf)
 			}
@@ -382,7 +431,7 @@ func testWriteFile(t *testing.T, expectedRes testCategoryResult, path string) {
 	t.Helper()
 	file, err := os.ReadFile(path)
 	require.NoError(t, err)
-	var res fileResult
+	var res allCategoriesResult
 	err = json.Unmarshal(file, &res)
 	require.NoError(t, err)
 
@@ -429,6 +478,29 @@ func testWriteFile(t *testing.T, expectedRes testCategoryResult, path string) {
 	}
 }
 
+func testPublishToObolAPI(t *testing.T, expectedRes testCategoryResult, conf testPeersConfig, keyPath string) (*httptest.Server, testPeersConfig) {
+	t.Helper()
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		require.Equal(t, r.URL.Path, "/test")
+		require.Equal(t, r.Method, http.MethodPost)
+		require.Equal(t, r.Header.Get("Content-Type"), "application/json")
+
+		w.WriteHeader(http.StatusOK)
+		b, err := io.ReadAll(r.Body)
+		require.NoError(t, err)
+		var res obolAPIResult
+		err = json.Unmarshal(b, &res)
+		require.NoError(t, err)
+		require.Equal(t, expectedRes.CategoryName, res.Data.Peers.CategoryName)
+		require.NotEmpty(t, res.ENR)
+		require.NotEmpty(t, res.Sig)
+	}))
+	conf.PublishAddr = server.URL
+	conf.PublishPrivateKeyFile = p2p.KeyPath(keyPath)
+
+	return server, conf
+}
+
 func startPeer(t *testing.T, conf testPeersConfig, peerPrivKey *k1.PrivateKey) enr.Record {
 	t.Helper()
 	ctx := context.Background()
diff --git a/cmd/testvalidator.go b/cmd/testvalidator.go
index fa936522..3f6c583d 100644
--- a/cmd/testvalidator.go
+++ b/cmd/testvalidator.go
@@ -126,6 +126,13 @@ func runTestValidator(ctx context.Context, w io.Writer, cfg testValidatorConfig)
 		}
 	}
 
+	if cfg.Publish {
+		err = publishResultToObolAPI(ctx, allCategoriesResult{Validator: res}, cfg.PublishAddr, cfg.PublishPrivateKeyFile)
+		if err != nil {
+			return res, err
+		}
+	}
+
 	return res, nil
 }