diff --git a/beacon-chain/core/electra/deposits.go b/beacon-chain/core/electra/deposits.go index a7f22b08b06c..d12d80d3cf1f 100644 --- a/beacon-chain/core/electra/deposits.go +++ b/beacon-chain/core/electra/deposits.go @@ -470,14 +470,15 @@ func ApplyPendingDeposit(ctx context.Context, st state.BeaconState, deposit *eth // set_or_append_list(state.previous_epoch_participation, index, ParticipationFlags(0b0000_0000)) // set_or_append_list(state.current_epoch_participation, index, ParticipationFlags(0b0000_0000)) // set_or_append_list(state.inactivity_scores, index, uint64(0)) +// // # [New in EIP8148] -// set_or_append_list( -// state.validator_sweep_thresholds, -// index, -// MAX_EFFECTIVE_BALANCE_ELECTRA -// if has_compounding_withdrawal_credential(validator) -// else Gwei(0), -// ) +// threshold = Gwei(0) +// if has_compounding_withdrawal_credential(validator): +// threshold = get_credential_sweep_threshold(withdrawal_credentials) +// if threshold < amount: +// threshold = MAX_EFFECTIVE_BALANCE_ELECTRA +// +// set_or_append_list(state.validator_sweep_thresholds, index, threshold) func AddValidatorToRegistry(beaconState state.BeaconState, pubKey []byte, withdrawalCredentials []byte, amount uint64) error { val, err := GetValidatorFromDeposit(pubKey, withdrawalCredentials, amount) if err != nil { @@ -503,13 +504,18 @@ func AddValidatorToRegistry(beaconState state.BeaconState, pubKey []byte, withdr } } - // [New in EIP-8148] Compounding validators start out at the default 2048 ETH sweep - // threshold; everyone else gets 0, which falls back to get_max_effective_balance. + // [New in EIP-8148] Compounding validators may request a threshold in the withdrawal + // credentials of the deposit creating them, otherwise they start out at the default + // 2048 ETH sweep threshold. Everyone else gets 0, which falls back to + // get_max_effective_balance. config := params.BeaconConfig() if beaconState.Version() >= version.Gloas { threshold := uint64(0) if len(val.WithdrawalCredentials) > 0 && val.WithdrawalCredentials[0] == config.CompoundingWithdrawalPrefixByte { - threshold = config.MaxEffectiveBalanceElectra + threshold = helpers.CredentialSweepThreshold(val.WithdrawalCredentials) + if threshold < amount { + threshold = config.MaxEffectiveBalanceElectra + } } if err := beaconState.AppendValidatorSweepThreshold(threshold); err != nil { diff --git a/beacon-chain/core/electra/deposits_test.go b/beacon-chain/core/electra/deposits_test.go index dfc94b46c32d..e7fef6eead6f 100644 --- a/beacon-chain/core/electra/deposits_test.go +++ b/beacon-chain/core/electra/deposits_test.go @@ -2,6 +2,7 @@ package electra_test import ( "context" + "encoding/binary" "testing" "github.com/OffchainLabs/prysm/v7/beacon-chain/core/electra" @@ -589,3 +590,92 @@ func TestApplyPendingDeposit_InvalidSignature(t *testing.T) { // no topup either require.Equal(t, 0, len(st.Balances())) } + +func TestAddValidatorToRegistry_SweepThreshold(t *testing.T) { + const gwei = 1_000_000_000 + + // compoundingCredentialsWithIncrements builds 0x02 credentials carrying the given + // threshold, in EFFECTIVE_BALANCE_INCREMENT units, at SweepThresholdCredentialOffset. + compoundingCredentialsWithIncrements := func(increments uint16) []byte { + creds := make([]byte, 32) + creds[0] = params.BeaconConfig().CompoundingWithdrawalPrefixByte + binary.BigEndian.PutUint16(creds[helpers.SweepThresholdCredentialOffset:], increments) + return creds + } + + eth1Credentials := func() []byte { + creds := make([]byte, 32) + creds[0] = params.BeaconConfig().ETH1AddressWithdrawalPrefixByte + binary.BigEndian.PutUint16(creds[helpers.SweepThresholdCredentialOffset:], 100) + return creds + } + + tests := []struct { + name string + credentials []byte + amount uint64 + want uint64 + }{ + { + name: "Compounding with no embedded threshold gets the default", + credentials: compoundingCredentialsWithIncrements(0), + amount: params.BeaconConfig().MinActivationBalance, + want: params.BeaconConfig().MaxEffectiveBalanceElectra, + }, + { + // The eth1 bridge deposit path registers the validator with an amount of 0 and + // credits the balance later, so the fallback to the default does not trigger. A + // stored 0 resolves to the same 2048 ETH default in get_effective_sweep_threshold. + name: "Compounding with no embedded threshold and a zero amount stores zero", + credentials: compoundingCredentialsWithIncrements(0), + amount: 0, + want: 0, + }, + { + name: "Compounding with an embedded threshold", + credentials: compoundingCredentialsWithIncrements(100), + amount: params.BeaconConfig().MinActivationBalance, + want: 100 * gwei, + }, + { + name: "Embedded threshold out of range falls back to the default", + credentials: compoundingCredentialsWithIncrements(2049), + amount: params.BeaconConfig().MinActivationBalance, + want: params.BeaconConfig().MaxEffectiveBalanceElectra, + }, + { + name: "Embedded threshold at least the deposited amount is kept", + credentials: compoundingCredentialsWithIncrements(100), + amount: 100 * gwei, + want: 100 * gwei, + }, + { + name: "Embedded threshold below the deposited amount falls back to the default", + credentials: compoundingCredentialsWithIncrements(100), + amount: 100*gwei + 1, + want: params.BeaconConfig().MaxEffectiveBalanceElectra, + }, + { + name: "Non-compounding credentials ignore the embedded threshold", + credentials: eth1Credentials(), + amount: params.BeaconConfig().MinActivationBalance, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + st, err := util.NewBeaconStateGloas() + require.NoError(t, err) + + sk, err := bls.RandKey() + require.NoError(t, err) + + require.NoError(t, electra.AddValidatorToRegistry(st, sk.PublicKey().Marshal(), tt.credentials, tt.amount)) + + threshold, err := st.ValidatorSweepThreshold(0) + require.NoError(t, err) + require.Equal(t, tt.want, threshold) + }) + } +} diff --git a/beacon-chain/core/helpers/validators.go b/beacon-chain/core/helpers/validators.go index 79e923c147c8..980fa417b247 100644 --- a/beacon-chain/core/helpers/validators.go +++ b/beacon-chain/core/helpers/validators.go @@ -20,6 +20,16 @@ import ( "github.com/prometheus/client_golang/prometheus/promauto" ) +// SweepThresholdCredentialOffset is the byte offset, inside `0x02` compounding withdrawal +// credentials, of the sweep threshold a validator asks for at deposit time (EIP-8148). The +// two bytes there hold the threshold in EFFECTIVE_BALANCE_INCREMENT units, big-endian: +// +// byte 0 COMPOUNDING_WITHDRAWAL_PREFIX (0x02) +// bytes 1..9 reserved, must be zero +// bytes 10..11 threshold in EFFECTIVE_BALANCE_INCREMENT units, big-endian +// bytes 12..31 execution address +const SweepThresholdCredentialOffset = 10 + var CommitteeCacheInProgressHit = promauto.NewCounter(prometheus.CounterOpts{ Name: "committee_cache_in_progress_hit", Help: "The number of committee requests that are present in the cache.", @@ -509,6 +519,24 @@ func EffectiveSweepThreshold(val state.ReadOnlyValidator, sweepThreshold uint64) return ValidatorMaxEffectiveBalance(val) } +// CredentialSweepThreshold gets the sweep threshold embedded in compounding withdrawalCredentials, or 0 if it is unset or out of range. +// https://github.com/nalepae/consensus-specs/blob/master/specs/_features/eip8148/beacon-chain.md#new-get_credential_sweep_threshold +func CredentialSweepThreshold(withdrawalCredentials []byte) uint64 { + if len(withdrawalCredentials) < SweepThresholdCredentialOffset+2 { + return 0 + } + + increments := binary.BigEndian.Uint16(withdrawalCredentials[SweepThresholdCredentialOffset : SweepThresholdCredentialOffset+2]) + + cfg := params.BeaconConfig() + threshold := uint64(increments) * cfg.EffectiveBalanceIncrement + if threshold < cfg.MinSweepThreshold() || threshold > cfg.MaxEffectiveBalanceElectra { + return 0 + } + + return threshold +} + // isPartiallyWithdrawableValidatorElectra implements is_partially_withdrawable_validator in the // electra fork. // diff --git a/beacon-chain/core/helpers/validators_test.go b/beacon-chain/core/helpers/validators_test.go index 840d7bbb39e0..2002505a9f0e 100644 --- a/beacon-chain/core/helpers/validators_test.go +++ b/beacon-chain/core/helpers/validators_test.go @@ -1,6 +1,8 @@ package helpers_test import ( + "bytes" + "encoding/binary" "errors" "testing" @@ -1057,6 +1059,77 @@ func TestIsPartiallyWithdrawableValidator(t *testing.T) { } } +func TestCredentialSweepThreshold(t *testing.T) { + const gwei = 1_000_000_000 + + // credentialsWithIncrements builds 0x02 credentials carrying the given threshold, in + // EFFECTIVE_BALANCE_INCREMENT units, at SweepThresholdCredentialOffset. + credentialsWithIncrements := func(increments uint16) []byte { + creds := make([]byte, 32) + creds[0] = params.BeaconConfig().CompoundingWithdrawalPrefixByte + binary.BigEndian.PutUint16(creds[helpers.SweepThresholdCredentialOffset:], increments) + return creds + } + + tests := []struct { + name string + credentials []byte + want uint64 + }{ + { + name: "Unset", + credentials: credentialsWithIncrements(0), + want: 0, + }, + { + name: "MIN_SWEEP_THRESHOLD", + credentials: credentialsWithIncrements(33), + want: 33 * gwei, + }, + { + name: "Just below MIN_SWEEP_THRESHOLD", + credentials: credentialsWithIncrements(32), + want: 0, + }, + { + name: "Big-endian byte order", + credentials: credentialsWithIncrements(256), + want: 256 * gwei, + }, + { + name: "MAX_EFFECTIVE_BALANCE_ELECTRA", + credentials: credentialsWithIncrements(2048), + want: 2048 * gwei, + }, + { + name: "Above MAX_EFFECTIVE_BALANCE_ELECTRA", + credentials: credentialsWithIncrements(2049), + want: 0, + }, + { + name: "Largest encodable value", + credentials: credentialsWithIncrements(65535), + want: 0, + }, + { + name: "Reserved bytes are not read", + credentials: append(append([]byte{params.BeaconConfig().CompoundingWithdrawalPrefixByte}, bytes.Repeat([]byte{0xFF}, 9)...), make([]byte, 22)...), + want: 0, + }, + { + name: "Truncated credentials", + credentials: []byte{params.BeaconConfig().CompoundingWithdrawalPrefixByte, 0xCC}, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, helpers.CredentialSweepThreshold(tt.credentials)) + }) + } +} + func TestValidatorMaxEffectiveBalance(t *testing.T) { tests := []struct { name string diff --git a/changelog/manu_eip8148-custom-sweep-threshold.md b/changelog/manu_eip8148-custom-sweep-threshold.md index 2c464ecd7d6c..6e66fd8cfa0d 100644 --- a/changelog/manu_eip8148-custom-sweep-threshold.md +++ b/changelog/manu_eip8148-custom-sweep-threshold.md @@ -1,3 +1,4 @@ ### Added -- Implement EIP-8148 (custom sweep threshold for validators) as part of the Gloas fork rather than Heze. \ No newline at end of file +- Implement EIP-8148 (custom sweep threshold for validators) as part of the Gloas fork rather than Heze. +- EIP-8148: Allow a `0x02` validator to be created with a custom sweep threshold, encoded in bytes 10-11 of the withdrawal credentials of the deposit creating it.