Skip to content

feat: add ETH cluster migration support with dual reward trees - #26

Open
olegshmuelov wants to merge 5 commits into
mainfrom
feat/eth-cluster-migration
Open

feat: add ETH cluster migration support with dual reward trees#26
olegshmuelov wants to merge 5 commits into
mainfrom
feat/eth-cluster-migration

Conversation

@olegshmuelov

@olegshmuelov olegshmuelov commented Feb 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add support for ClusterMigratedToETH events: sets migration_day on affected validators and inserts corresponding validator_events records
  • Introduce migration_filter parameter ('ssv'/'eth') to all SQL participation and exclusion functions, splitting validator-days by tree
  • Dual-tree reward calculation: SSV tree (with network fee) and ETH tree (no fee), combined effective balance for tier selection and shared inflation cap
  • Separate output files per tree: existing CSVs/JSON for SSV, -eth suffixed outputs for ETH tree
  • New staking_upgrade config (block + log_index) to mark the on-chain staking contract migration boundary
  • Unit tests for isPostStakingUpgrade boundary logic, ABI decoding round-trip, topic hash, and StakingUpgrade plan validation

Test plan

  • go test ./... passes
  • On existing DB (no migrated clusters): run ALTER TABLE validators ADD COLUMN IF NOT EXISTS migration_day DATE;, then calc — SSV merkle root must match the latest published round
  • Run sync --fresh --keep-cache + calc on a DB with ClusterMigratedToETH events — verify SSV and ETH trees are produced correctly
  • Verify cumulative-eth.json is only written when ETH tree has entries
  • Verify merkle generation works independently for both cumulative.json and cumulative-eth.json

@olegshmuelov
olegshmuelov marked this pull request as ready for review February 26, 2026 14:54
@olegshmuelov
olegshmuelov requested a review from y0sher February 26, 2026 14:54
Comment thread pkg/sync/cluster_migration.go
Comment thread pkg/sync/cluster_migration.go
Comment thread cmd/ssv-rewards/calc.go
Comment thread cmd/ssv-rewards/calc.go
Comment thread cmd/ssv-rewards/calc.go
y0sher
y0sher previously approved these changes Mar 30, 2026
Comment thread cmd/ssv-rewards/calc.go
Comment thread cmd/ssv-rewards/calc.go
Comment thread cmd/ssv-rewards/calc.go
Comment thread pkg/sync/validator_events.go
Comment thread cmd/ssv-rewards/calc.go
nkryuchkov
nkryuchkov previously approved these changes Apr 6, 2026
iurii-ssv
iurii-ssv previously approved these changes Apr 8, 2026

@iurii-ssv iurii-ssv left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

y0sher
y0sher previously approved these changes Apr 30, 2026
@olegshmuelov
olegshmuelov dismissed stale reviews from y0sher, iurii-ssv, and nkryuchkov via 4867f99 May 5, 2026 15:28
handleClusterMigration read live nodeStorage state, which races ahead of the postgres recorder. This caused FK violations on post-migration validator adds and wrong migration_day attribution for SSV→ETH hop scenarios.

Replaces the badger read with a chronological walk over validator_events at the migration's (block, log_index) position. Extracts reduceClusterMembers as a pure function with 11 unit tests.

@iurii-ssv iurii-ssv left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM overall, the open items are mostly external validation and test coverage rather than code defects.

Things to confirm before relying on this in production (details inline):

  • Validate the ClusterMigratedToETH ABI/topic against the deployed contract — the topic test is circular and can't catch a wrong signature.
  • Confirm the ETH tree is published to a separate on-chain distributor (a recipient can appear in both cumulative files).
  • Pin down what the event's validatorCount means, then decide if the mismatch should be a hard error.
  • Add coverage for the dual-tree reward math + the SQL day-split (cmd/ssv-rewards/ has no tests today).


// ClusterMigratedToETHTopic is keccak256 of the ClusterMigratedToETH event signature.
var ClusterMigratedToETHTopic = crypto.Keccak256Hash([]byte(
"ClusterMigratedToETH(address,uint64[],uint256,uint256,uint32,(uint32,uint64,uint64,bool,uint256))",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Highest-risk item: unverified event signature. This topic hash comes from a signature that (per the QA doc) isn't emitted on mainnet yet, and TestClusterMigratedToETHTopic only asserts that this exact string hashes to a hardcoded digest — it's circular and cannot detect a wrong signature.

The two failure modes are asymmetric and both bad:

  • Topic hash differs → the events fall through the unknown-event path and are silently ignored; no migrations are detected and nothing errors.
  • Topic matches but the data layout differsInputs.Unpack errors and handleClusterMigration returns an error, which aborts the whole sync.

Please validate against the deployed contract and replace the circular test with a real on-chain log fixture before depending on this.

{"indexed": false, "name": "operatorIds", "type": "uint64[]"},
{"indexed": false, "name": "ethDeposited", "type": "uint256"},
{"indexed": false, "name": "ssvRefunded", "type": "uint256"},
{"indexed": false, "name": "effectiveBalance", "type": "uint32"},

@iurii-ssv iurii-ssv Jun 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tell that the ABI still needs confirming: effectiveBalance as uint32. uint32 maxes out at ~4.29e9, which can't hold a Gwei effective balance (32 ETH = 32e9 Gwei).

One correction to be precise about the consequence: a wider on-chain type wouldn't shift the fields after it — every static scalar (uint32/uint64/uint256) occupies exactly one 32-byte word, so the cluster tuple at decoded[4] stays put either way. The actual failure mode is the opposite of a silent mis-decode:

  • go-ethereum's Unpack range-checks each integer slot (ReadIntegererrBadUint32), so an on-chain value exceeding uint32 max fails the entire decode. handleClusterMigration then returns an error and aborts the sync — fail-closed, not silent.
  • Note effectiveBalance (decoded[3]) isn't even read by the handler, yet declaring it uint32 is enough to halt sync on the first real event if the units are Gwei.

Fix: declare it uint256 (decodes any value safely), or pin the exact type/units against the deployed contract — an unused field shouldn't be able to abort the sync. (Verified against the pinned go-ethereum v1.15.1.)

zap.String("owner", strings.ToLower(owner.Hex())),
)
return nil
} else if len(members) != int(validatorCount) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Downgrading this to a warning means a wrong member derivation silently lands validators in the wrong tree → wrong rewards, with no hard stop, on irreversible on-chain output.

Two questions:

  1. What does validatorCount in the event's cluster tuple represent? If it's the post-migration SSV count (likely 0), this warns on every real migration and becomes pure noise.
  2. Once the semantics are pinned down, consider whether this should be a hard error rather than a warn given the stakes.

}
validatorCount := clusterStruct.ValidatorCount

clusterID, err := ssvtypes.ComputeClusterIDHash(owner.Bytes(), operatorIds)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: ComputeClusterIDHash sorts operatorIds in place. Harmless here (the slice isn't used afterward), but reduceClusterMembers defensively copies before the same call — a one-line comment here keeps the two consistent and guards against a future edit that reuses operatorIds after this point.

Comment thread cmd/ssv-rewards/calc.go
for _, p := range ethTotalByRecipient {
ethTotalRewards["0x"+p.RecipientAddress] = p.reward.String()
}
ef, err := os.Create(filepath.Join(roundDir, "cumulative-eth.json"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirm before publishing. A recipient with both pre- and post-migration days appears in both cumulative.json and this cumulative-eth.json. The historical claim model is cumulative-per-address against a single distributor; if both roots are published to the same distributor, the cumulative accounting collides (double-count / under-pay). Please confirm the ETH tree goes to a separate distributor funded from the ETH-fee pool, and document that assumption.

Comment thread cmd/ssv-rewards/calc.go
// processRoundWithMigration handles reward calculation for rounds with ETH migration support.
// It queries both SSV and ETH validator participations, computes a combined effective balance
// for tier selection, and applies a single shared inflation cap across both trees.
func (c *CalcCmd) processRoundWithMigration(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the most financially-sensitive new code, and cmd/ssv-rewards/ currently has no tests at all. The combined-EB tier selection and the single shared inflation cap across both trees are subtle.

I confirmed by reading that with no migrations this reduces exactly to processRound, but that invariant deserves a regression test. Suggest at least:

  • SSV active_days + ETH active_days = original active_days across the migration_day boundary, and
  • shared cap: SSV total + ETH total for a capped round equals the pre-split capped total.


if eventTrace.Error != nil {
// Check if this is a ClusterMigratedToETH event we can handle ourselves.
if len(eventTrace.Log.Topics) > 0 && eventTrace.Log.Topics[0] == rewards.ClusterMigratedToETHTopic {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This interception only fires because the pinned bloxapp/ssv treats ClusterMigratedToETH as an unknown event (eventTrace.Error != nil). I confirmed that holds for the current pin (EventByID fails → trace emitted with Error set), but if the dependency is ever bumped to a version that recognizes this event, Error becomes nil and this branch is silently bypassed. Worth a comment noting the coupling, or also handling the non-error path.

continue
}
recordedEvents++
// Fetch databaseEvent before error check — needed for both migration

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: moving the databaseEvent lookup ahead of the error check means it now runs for every event, including the unknown/malformed ones that previously continued before any query. That adds a DB round-trip per error event during the reconciliation-critical replay and a new hard-fail path if the lookup ever misses. Almost certainly fine — just flagging the slightly widened failure surface.

Comment thread rewards.sql
AND (
CASE migration_filter
WHEN 'ssv' THEN (v.migration_day IS NULL OR vp.day < v.migration_day)
WHEN 'eth' THEN (v.migration_day IS NOT NULL AND vp.day >= v.migration_day)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The migration_filter day-split is the heart of the dual-tree logic and lives entirely in SQL that CI never compiles or runs. Together with the lack of calc tests, the boundary semantics (< migration_day → SSV, >= migration_day → ETH) are validated only by the manual QA plan. A DB-backed test over a small fixture would de-risk this considerably.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants