From 2f517d263bae4198f2568af7aa63f1d7abb2b8f2 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+fornax2@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:10:17 -0300 Subject: [PATCH 01/35] Add IsSaturn2Deployed check --- bindings/utils/version-checker.go | 5 +++++ shared/services/state/update-checks.go | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 shared/services/state/update-checks.go diff --git a/bindings/utils/version-checker.go b/bindings/utils/version-checker.go index cfa87ec2b..b357c5477 100644 --- a/bindings/utils/version-checker.go +++ b/bindings/utils/version-checker.go @@ -27,6 +27,11 @@ func GetCurrentVersion(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*version return nil, fmt.Errorf("error checking deposit pool version: %w", err) } + // Check for v1.5 (Saturn 2) + if depositPoolVersion > 4 { + return version.NewSemver("1.5.0") + } + // Check for v1.4 (Saturn 1) if depositPoolVersion > 3 { diff --git a/shared/services/state/update-checks.go b/shared/services/state/update-checks.go new file mode 100644 index 000000000..4f51e578c --- /dev/null +++ b/shared/services/state/update-checks.go @@ -0,0 +1,19 @@ +package state + +import ( + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/hashicorp/go-version" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/utils" +) + +// Check if Saturn 2 has been deployed +func IsSaturn2Deployed(rp *rocketpool.RocketPool, opts *bind.CallOpts) (bool, error) { + currentVersion, err := utils.GetCurrentVersion(rp, opts) + if err != nil { + return false, err + } + + constraint, _ := version.NewConstraint(">= 1.5.0") + return constraint.Check(currentVersion), nil +} From 1f5f8f75084afa439a7d13a8695d7bce727b96b3 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+fornax2@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:05:08 -0300 Subject: [PATCH 02/35] Add pdao parameters --- bindings/settings/protocol/exit.go | 89 ++++++ bindings/settings/protocol/performance.go | 129 +++++++++ bindings/settings/security/performance.go | 24 ++ rocketpool-cli/pdao/commands.go | 294 ++++++++++++++++++++ rocketpool-cli/pdao/get-settings.go | 19 ++ rocketpool-cli/pdao/propose-settings.go | 40 +++ rocketpool-cli/security/commands.go | 37 +++ rocketpool-cli/security/propose-settings.go | 5 + rocketpool/api/pdao/get-settings.go | 58 ++++ rocketpool/api/pdao/propose-settings.go | 188 +++++++++++++ rocketpool/api/security/propose-settings.go | 28 ++ shared/types/api/pdao.go | 17 +- 12 files changed, 927 insertions(+), 1 deletion(-) create mode 100644 bindings/settings/protocol/exit.go create mode 100644 bindings/settings/protocol/performance.go create mode 100644 bindings/settings/security/performance.go diff --git a/bindings/settings/protocol/exit.go b/bindings/settings/protocol/exit.go new file mode 100644 index 000000000..2949dbb45 --- /dev/null +++ b/bindings/settings/protocol/exit.go @@ -0,0 +1,89 @@ +package protocol + +import ( + "fmt" + "math/big" + "sync" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/dao/protocol" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/types" +) + +// Config +const ( + ExitSettingsContractName string = "rocketDAOProtocolSettingsExit" + CooperativeExitPhaseSettingPath string = "cooperative.exit.phase" + DidNotExitPenaltySettingPath string = "did.not.exit.penalty" + DidNotExitCooldownSettingPath string = "did.not.exit.cooldown" +) + +// Minimum time a validator must remain exit-requested before triggered exit or penalty (hours) +func GetCooperativeExitPhase(rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { + exitSettingsContract, err := getExitSettingsContract(rp, opts) + if err != nil { + return 0, err + } + value := new(*big.Int) + if err := exitSettingsContract.Call(opts, value, "getCooperativeExitPhase"); err != nil { + return 0, fmt.Errorf("error getting cooperative exit phase: %w", err) + } + return time.Duration((*value).Int64()) * time.Hour, nil +} +func ProposeCooperativeExitPhase(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", CooperativeExitPhaseSettingPath), ExitSettingsContractName, CooperativeExitPhaseSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposeCooperativeExitPhaseGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", CooperativeExitPhaseSettingPath), ExitSettingsContractName, CooperativeExitPhaseSettingPath, value, blockNumber, treeNodes, opts) +} + +// Penalty applied to a minipool that fails to exit when requested +func GetDidNotExitPenalty(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { + exitSettingsContract, err := getExitSettingsContract(rp, opts) + if err != nil { + return nil, err + } + value := new(*big.Int) + if err := exitSettingsContract.Call(opts, value, "getDidNotExitPenalty"); err != nil { + return nil, fmt.Errorf("error getting did not exit penalty: %w", err) + } + return *value, nil +} +func ProposeDidNotExitPenalty(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", DidNotExitPenaltySettingPath), ExitSettingsContractName, DidNotExitPenaltySettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposeDidNotExitPenaltyGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", DidNotExitPenaltySettingPath), ExitSettingsContractName, DidNotExitPenaltySettingPath, value, blockNumber, treeNodes, opts) +} + +// Minimum time before a validator can be exit-requested again after a failed exit penalty (days) +func GetDidNotExitCooldown(rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { + exitSettingsContract, err := getExitSettingsContract(rp, opts) + if err != nil { + return 0, err + } + value := new(*big.Int) + if err := exitSettingsContract.Call(opts, value, "getDidNotExitCooldown"); err != nil { + return 0, fmt.Errorf("error getting did not exit cooldown: %w", err) + } + return time.Duration((*value).Int64()) * 24 * time.Hour, nil +} +func ProposeDidNotExitCooldown(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", DidNotExitCooldownSettingPath), ExitSettingsContractName, DidNotExitCooldownSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposeDidNotExitCooldownGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", DidNotExitCooldownSettingPath), ExitSettingsContractName, DidNotExitCooldownSettingPath, value, blockNumber, treeNodes, opts) +} + +// Get contracts +var exitSettingsContractLock sync.Mutex + +func getExitSettingsContract(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*rocketpool.Contract, error) { + exitSettingsContractLock.Lock() + defer exitSettingsContractLock.Unlock() + return rp.GetContract(ExitSettingsContractName, opts) +} diff --git a/bindings/settings/protocol/performance.go b/bindings/settings/protocol/performance.go new file mode 100644 index 000000000..f9909ae22 --- /dev/null +++ b/bindings/settings/protocol/performance.go @@ -0,0 +1,129 @@ +package protocol + +import ( + "fmt" + "math/big" + "sync" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/dao/protocol" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/types" +) + +// Config +const ( + PerformanceSettingsContractName string = "rocketDAOProtocolSettingsPerformance" + PerformanceExitsEnabledSettingPath string = "performance.exits.enabled" + PerformancePeriodSettingPath string = "performance.period" + PerformanceThresholdSettingPath string = "performance.threshold" + PerformanceChallengePeriodSettingPath string = "performance.challenge.period" + PerformanceChallengeBondSettingPath string = "performance.challenge.bond" +) + +// Performance exits currently enabled +func GetPerformanceExitsEnabled(rp *rocketpool.RocketPool, opts *bind.CallOpts) (bool, error) { + performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) + if err != nil { + return false, err + } + value := new(bool) + if err := performanceSettingsContract.Call(opts, value, "getPerformanceExitsEnabled"); err != nil { + return false, fmt.Errorf("error getting performance exits enabled status: %w", err) + } + return *value, nil +} +func ProposePerformanceExitsEnabled(rp *rocketpool.RocketPool, value bool, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetBool(rp, fmt.Sprintf("set %s", PerformanceExitsEnabledSettingPath), PerformanceSettingsContractName, PerformanceExitsEnabledSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposePerformanceExitsEnabledGas(rp *rocketpool.RocketPool, value bool, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return protocol.EstimateProposeSetBoolGas(rp, fmt.Sprintf("set %s", PerformanceExitsEnabledSettingPath), PerformanceSettingsContractName, PerformanceExitsEnabledSettingPath, value, blockNumber, treeNodes, opts) +} + +// Number of epochs over which attestation performance is measured +func GetPerformancePeriod(rp *rocketpool.RocketPool, opts *bind.CallOpts) (uint64, error) { + performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) + if err != nil { + return 0, err + } + value := new(*big.Int) + if err := performanceSettingsContract.Call(opts, value, "getPerformancePeriod"); err != nil { + return 0, fmt.Errorf("error getting performance period: %w", err) + } + return (*value).Uint64(), nil +} +func ProposePerformancePeriod(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformancePeriodSettingPath), PerformanceSettingsContractName, PerformancePeriodSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposePerformancePeriodGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformancePeriodSettingPath), PerformanceSettingsContractName, PerformancePeriodSettingPath, value, blockNumber, treeNodes, opts) +} + +// Minimum target attestation timeliness percentage required to avoid exit +func GetPerformanceThreshold(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { + performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) + if err != nil { + return nil, err + } + value := new(*big.Int) + if err := performanceSettingsContract.Call(opts, value, "getPerformanceThreshold"); err != nil { + return nil, fmt.Errorf("error getting performance threshold: %w", err) + } + return *value, nil +} +func ProposePerformanceThreshold(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformanceThresholdSettingPath), PerformanceSettingsContractName, PerformanceThresholdSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposePerformanceThresholdGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformanceThresholdSettingPath), PerformanceSettingsContractName, PerformanceThresholdSettingPath, value, blockNumber, treeNodes, opts) +} + +// How long a performance exit challenge remains open +func GetPerformanceChallengePeriod(rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { + performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) + if err != nil { + return 0, err + } + value := new(*big.Int) + if err := performanceSettingsContract.Call(opts, value, "getPerformanceChallengePeriod"); err != nil { + return 0, fmt.Errorf("error getting performance challenge period: %w", err) + } + return time.Duration((*value).Int64()) * time.Second, nil +} +func ProposePerformanceChallengePeriod(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformanceChallengePeriodSettingPath), PerformanceSettingsContractName, PerformanceChallengePeriodSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposePerformanceChallengePeriodGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformanceChallengePeriodSettingPath), PerformanceSettingsContractName, PerformanceChallengePeriodSettingPath, value, blockNumber, treeNodes, opts) +} + +// RPL bond required to propose a performance exit +func GetPerformanceChallengeBond(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { + performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) + if err != nil { + return nil, err + } + value := new(*big.Int) + if err := performanceSettingsContract.Call(opts, value, "getPerformanceChallengeBond"); err != nil { + return nil, fmt.Errorf("error getting performance challenge bond: %w", err) + } + return *value, nil +} +func ProposePerformanceChallengeBond(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformanceChallengeBondSettingPath), PerformanceSettingsContractName, PerformanceChallengeBondSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposePerformanceChallengeBondGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformanceChallengeBondSettingPath), PerformanceSettingsContractName, PerformanceChallengeBondSettingPath, value, blockNumber, treeNodes, opts) +} + +// Get contracts +var performanceSettingsContractLock sync.Mutex + +func getPerformanceSettingsContract(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*rocketpool.Contract, error) { + performanceSettingsContractLock.Lock() + defer performanceSettingsContractLock.Unlock() + return rp.GetContract(PerformanceSettingsContractName, opts) +} diff --git a/bindings/settings/security/performance.go b/bindings/settings/security/performance.go new file mode 100644 index 000000000..08c107167 --- /dev/null +++ b/bindings/settings/security/performance.go @@ -0,0 +1,24 @@ +package security + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/dao/security" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + psettings "github.com/rocket-pool/smartnode/bindings/settings/protocol" +) + +const ( + performanceNamespace string = "performance" +) + +// Performance exits currently enabled +func ProposePerformanceExitsEnabled(rp *rocketpool.RocketPool, value bool, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return security.ProposeSetBool(rp, fmt.Sprintf("set %s", psettings.PerformanceExitsEnabledSettingPath), performanceNamespace, psettings.PerformanceExitsEnabledSettingPath, value, opts) +} +func EstimateProposePerformanceExitsEnabledGas(rp *rocketpool.RocketPool, value bool, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return security.EstimateProposeSetBoolGas(rp, fmt.Sprintf("set %s", psettings.PerformanceExitsEnabledSettingPath), performanceNamespace, psettings.PerformanceExitsEnabledSettingPath, value, opts) +} diff --git a/rocketpool-cli/pdao/commands.go b/rocketpool-cli/pdao/commands.go index 7a2676ff3..d1054e07a 100644 --- a/rocketpool-cli/pdao/commands.go +++ b/rocketpool-cli/pdao/commands.go @@ -19,6 +19,8 @@ const ( unboundedPercentUsage string = "specify a percentage that can go over 100% (e.g., '1.5' for 150%)" uintUsage string = "specify an integer (e.g., '50')" epochCountUsage string = "specify a number, in epochs (eg., '100')" + hourCountUsage string = "specify a number, in hours (e.g., '72')" + dayCountUsage string = "specify a number, in days (e.g., '28')" durationUsage string = "specify a duration using hours, minutes, and seconds (e.g., '20m' or '72h0m0s')" addressListUsage string = "specify a list of one or more addresses separated by commas (e.g., '0x1a2b3c4d5e6f7890abcdef1234567890abcdef12,0xabcdefabcdefabcdefabcdefabcdefabcdefabcd')" ) @@ -3311,6 +3313,298 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { }, }, }, + + { + Name: "performance", + Aliases: []string{"perf"}, + Usage: "Performance exit settings (RPIP-73)", + Commands: []*cli.Command{ + + { + Name: "exits-enabled", + Aliases: []string{"ee"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformanceExitsEnabledSettingPath, boolUsage), + UsageText: "rocketpool pdao propose setting performance exits-enabled value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateBool("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingPerformanceExitsEnabled(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "period", + Aliases: []string{"p"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformancePeriodSettingPath, epochCountUsage), + UsageText: "rocketpool pdao propose setting performance period value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidatePositiveUint("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingPerformancePeriod(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "threshold", + Aliases: []string{"t"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformanceThresholdSettingPath, percentUsage), + UsageText: "rocketpool pdao propose setting performance threshold value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "raw", + Usage: "Add this flag if your setting is an 18-decimal-fixed-point-integer (wei) value instead of a float", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateFloat(c.Bool("raw"), "value", c.Args().Get(0), true, c.Bool("yes")) + if err != nil { + return err + } + + // Run + return proposeSettingPerformanceThreshold(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "challenge-period", + Aliases: []string{"cp"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformanceChallengePeriodSettingPath, durationUsage), + UsageText: "rocketpool pdao propose setting performance challenge-period value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateDuration("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingPerformanceChallengePeriod(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "challenge-bond", + Aliases: []string{"cb"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformanceChallengeBondSettingPath, floatRplUsage), + UsageText: "rocketpool pdao propose setting performance challenge-bond value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "raw", + Usage: "Add this flag if your setting is an 18-decimal-fixed-point-integer (wei) value instead of a float", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateFloat(c.Bool("raw"), "value", c.Args().Get(0), false, c.Bool("yes")) + if err != nil { + return err + } + + // Run + return proposeSettingPerformanceChallengeBond(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + }, + }, + + { + Name: "exit", + Aliases: []string{"x"}, + Usage: "Exit request settings (RPIP-80)", + Commands: []*cli.Command{ + + { + Name: "cooperative-exit-phase", + Aliases: []string{"cep"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.CooperativeExitPhaseSettingPath, hourCountUsage), + UsageText: "rocketpool pdao propose setting exit cooperative-exit-phase value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidatePositiveUint("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingCooperativeExitPhase(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "did-not-exit-penalty", + Aliases: []string{"dnep"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.DidNotExitPenaltySettingPath, floatEthUsage), + UsageText: "rocketpool pdao propose setting exit did-not-exit-penalty value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "raw", + Usage: "Add this flag if your setting is an 18-decimal-fixed-point-integer (wei) value instead of a float", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateFloat(c.Bool("raw"), "value", c.Args().Get(0), false, c.Bool("yes")) + if err != nil { + return err + } + + // Run + return proposeSettingDidNotExitPenalty(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "did-not-exit-cooldown", + Aliases: []string{"dnec"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.DidNotExitCooldownSettingPath, dayCountUsage), + UsageText: "rocketpool pdao propose setting exit did-not-exit-cooldown value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidatePositiveUint("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingDidNotExitCooldown(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + }, + }, }, }, diff --git a/rocketpool-cli/pdao/get-settings.go b/rocketpool-cli/pdao/get-settings.go index 7fd553b66..b0dbd61db 100644 --- a/rocketpool-cli/pdao/get-settings.go +++ b/rocketpool-cli/pdao/get-settings.go @@ -2,6 +2,7 @@ package pdao import ( "fmt" + "time" "github.com/rocket-pool/smartnode/shared/math" "github.com/rocket-pool/smartnode/shared/services/rocketpool" @@ -132,6 +133,24 @@ func getSettings() error { fmt.Printf("\tUpgrade Delay: %s\n", response.Security.UpgradeDelay) fmt.Println() + if response.Saturn2Deployed { + // Performance + fmt.Println("== Performance Settings ==") + fmt.Printf("\tPerformance Exits Enabled: %t\n", response.Performance.ExitsEnabled) + fmt.Printf("\tPerformance Period: %d Epochs\n", response.Performance.Period) + fmt.Printf("\tPerformance Threshold: %.2f%%\n", eth.WeiToEth(response.Performance.Threshold)*100) + fmt.Printf("\tChallenge Period: %s\n", response.Performance.ChallengePeriod) + fmt.Printf("\tChallenge Bond: %.6f RPL\n", eth.WeiToEth(response.Performance.ChallengeBond)) + fmt.Println() + + // Exit + fmt.Println("== Exit Settings (RPIP-80) ==") + fmt.Printf("\tCooperative Exit Phase: %d Hours\n", uint64(response.Exit.CooperativeExitPhase/time.Hour)) + fmt.Printf("\tDid Not Exit Penalty: %.6f ETH\n", eth.WeiToEth(response.Exit.DidNotExitPenalty)) + fmt.Printf("\tDid Not Exit Cooldown: %d Days\n", uint64(response.Exit.DidNotExitCooldown/(24*time.Hour))) + fmt.Println() + } + // Megapool fmt.Println("== Megapool Settings ==") fmt.Printf("\tTime Before Dissolve: %s\n", response.Megapool.TimeBeforeDissolve) diff --git a/rocketpool-cli/pdao/propose-settings.go b/rocketpool-cli/pdao/propose-settings.go index b739f83a8..2f7101497 100644 --- a/rocketpool-cli/pdao/propose-settings.go +++ b/rocketpool-cli/pdao/propose-settings.go @@ -365,6 +365,46 @@ func proposeSettingPenaltyThreshold(value *big.Int, yes bool, toJson string) err return proposeSetting(protocol.MegapoolSettingsContractName, protocol.MegapoolPenaltyThreshold, trueValue, yes, toJson) } +func proposeSettingPerformanceExitsEnabled(value bool, yes bool, toJson string) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceExitsEnabledSettingPath, trueValue, yes, toJson) +} + +func proposeSettingPerformancePeriod(value uint64, yes bool, toJson string) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformancePeriodSettingPath, trueValue, yes, toJson) +} + +func proposeSettingPerformanceThreshold(value *big.Int, yes bool, toJson string) error { + trueValue := value.String() + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceThresholdSettingPath, trueValue, yes, toJson) +} + +func proposeSettingPerformanceChallengePeriod(value time.Duration, yes bool, toJson string) error { + trueValue := fmt.Sprint(uint64(value.Seconds())) + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceChallengePeriodSettingPath, trueValue, yes, toJson) +} + +func proposeSettingPerformanceChallengeBond(value *big.Int, yes bool, toJson string) error { + trueValue := value.String() + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceChallengeBondSettingPath, trueValue, yes, toJson) +} + +func proposeSettingCooperativeExitPhase(value uint64, yes bool, toJson string) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.ExitSettingsContractName, protocol.CooperativeExitPhaseSettingPath, trueValue, yes, toJson) +} + +func proposeSettingDidNotExitPenalty(value *big.Int, yes bool, toJson string) error { + trueValue := value.String() + return proposeSetting(protocol.ExitSettingsContractName, protocol.DidNotExitPenaltySettingPath, trueValue, yes, toJson) +} + +func proposeSettingDidNotExitCooldown(value uint64, yes bool, toJson string) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.ExitSettingsContractName, protocol.DidNotExitCooldownSettingPath, trueValue, yes, toJson) +} + func proposeSettingNodeCommissionShare(value *big.Int, yes bool, toJson string) error { trueValue := value.String() return proposeSetting(protocol.NetworkSettingsContractName, protocol.NetworkNodeCommissionSharePath, trueValue, yes, toJson) diff --git a/rocketpool-cli/security/commands.go b/rocketpool-cli/security/commands.go index 58d509d5b..b53ee1c31 100644 --- a/rocketpool-cli/security/commands.go +++ b/rocketpool-cli/security/commands.go @@ -551,6 +551,43 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { }, }, }, + + { + Name: "performance", + Aliases: []string{"perf"}, + Usage: "Performance exit settings (RPIP-73)", + Commands: []*cli.Command{ + + { + Name: "exits-enabled", + Aliases: []string{"ee"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformanceExitsEnabledSettingPath, boolUsage), + UsageText: "rocketpool security propose setting performance exits-enabled value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateBool("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingPerformanceExitsEnabled(value, c.Bool("yes")) + + }, + }, + }, + }, }, }, }, diff --git a/rocketpool-cli/security/propose-settings.go b/rocketpool-cli/security/propose-settings.go index a130c3034..1c7fdf665 100644 --- a/rocketpool-cli/security/propose-settings.go +++ b/rocketpool-cli/security/propose-settings.go @@ -82,6 +82,11 @@ func proposeSettingNodeComissionShareSecurityCouncilAdder(value *big.Int, yes bo return proposeSetting(protocol.NetworkSettingsContractName, protocol.NetworkNodeCommissionShareSecurityCouncilAdderPath, trueValue, yes) } +func proposeSettingPerformanceExitsEnabled(value bool, yes bool) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceExitsEnabledSettingPath, trueValue, yes) +} + // Master general proposal function func proposeSetting(contract string, setting string, value string, yes bool) error { // Get RP client diff --git a/rocketpool/api/pdao/get-settings.go b/rocketpool/api/pdao/get-settings.go index 6796d2cf8..b97a82ef3 100644 --- a/rocketpool/api/pdao/get-settings.go +++ b/rocketpool/api/pdao/get-settings.go @@ -11,6 +11,7 @@ import ( "github.com/rocket-pool/smartnode/rocketpool/api/snroute" "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/state" "github.com/rocket-pool/smartnode/shared/types/api" ) @@ -25,6 +26,11 @@ func getSettings(c *cli.Command) (*api.GetPDAOSettingsResponse, error) { // Response response := api.GetPDAOSettingsResponse{} + response.Saturn2Deployed, err = state.IsSaturn2Deployed(rp, nil) + if err != nil { + return nil, err + } + // Data var wg errgroup.Group @@ -186,6 +192,58 @@ func getSettings(c *cli.Command) (*api.GetPDAOSettingsResponse, error) { return err }) + // === Performance === + + if response.Saturn2Deployed { + wg.Go(func() error { + var err error + response.Performance.ExitsEnabled, err = protocol.GetPerformanceExitsEnabled(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Performance.Period, err = protocol.GetPerformancePeriod(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Performance.Threshold, err = protocol.GetPerformanceThreshold(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Performance.ChallengePeriod, err = protocol.GetPerformanceChallengePeriod(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Performance.ChallengeBond, err = protocol.GetPerformanceChallengeBond(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Exit.CooperativeExitPhase, err = protocol.GetCooperativeExitPhase(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Exit.DidNotExitPenalty, err = protocol.GetDidNotExitPenalty(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Exit.DidNotExitCooldown, err = protocol.GetDidNotExitCooldown(rp, nil) + return err + }) + } + // === Auction === wg.Go(func() error { diff --git a/rocketpool/api/pdao/propose-settings.go b/rocketpool/api/pdao/propose-settings.go index 8a2f2fa4c..83d3a32e5 100644 --- a/rocketpool/api/pdao/propose-settings.go +++ b/rocketpool/api/pdao/propose-settings.go @@ -961,6 +961,100 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, } } + case protocol.PerformanceSettingsContractName: + switch settingName { + // PerformanceExitsEnabled + case protocol.PerformanceExitsEnabledSettingPath: + newValue, err := cliutils.ValidateBool(valueName, value) + if err != nil { + return nil, err + } + response.GasInfo, err = protocol.EstimateProposePerformanceExitsEnabledGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PerformanceExitsEnabled: %w", err) + } + + // PerformancePeriod + case protocol.PerformancePeriodSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasInfo, err = protocol.EstimateProposePerformancePeriodGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PerformancePeriod: %w", err) + } + + // PerformanceThreshold + case protocol.PerformanceThresholdSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasInfo, err = protocol.EstimateProposePerformanceThresholdGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PerformanceThreshold: %w", err) + } + + // PerformanceChallengePeriod + case protocol.PerformanceChallengePeriodSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasInfo, err = protocol.EstimateProposePerformanceChallengePeriodGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PerformanceChallengePeriod: %w", err) + } + + // PerformanceChallengeBond + case protocol.PerformanceChallengeBondSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasInfo, err = protocol.EstimateProposePerformanceChallengeBondGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PerformanceChallengeBond: %w", err) + } + } + + case protocol.ExitSettingsContractName: + switch settingName { + // CooperativeExitPhase + case protocol.CooperativeExitPhaseSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasInfo, err = protocol.EstimateProposeCooperativeExitPhaseGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing CooperativeExitPhase: %w", err) + } + + // DidNotExitPenalty + case protocol.DidNotExitPenaltySettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasInfo, err = protocol.EstimateProposeDidNotExitPenaltyGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing DidNotExitPenalty: %w", err) + } + + // DidNotExitCooldown + case protocol.DidNotExitCooldownSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasInfo, err = protocol.EstimateProposeDidNotExitCooldownGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing DidNotExitCooldown: %w", err) + } + } + } // Make sure a setting was actually hit @@ -1849,6 +1943,100 @@ func proposeSetting(c *cli.Command, contractName string, settingName string, val } + case protocol.PerformanceSettingsContractName: + switch settingName { + // PerformanceExitsEnabled + case protocol.PerformanceExitsEnabledSettingPath: + newValue, err := cliutils.ValidateBool(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposePerformanceExitsEnabled(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PerformanceExitsEnabled: %w", err) + } + + // PerformancePeriod + case protocol.PerformancePeriodSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposePerformancePeriod(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PerformancePeriod: %w", err) + } + + // PerformanceThreshold + case protocol.PerformanceThresholdSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposePerformanceThreshold(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PerformanceThreshold: %w", err) + } + + // PerformanceChallengePeriod + case protocol.PerformanceChallengePeriodSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposePerformanceChallengePeriod(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PerformanceChallengePeriod: %w", err) + } + + // PerformanceChallengeBond + case protocol.PerformanceChallengeBondSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposePerformanceChallengeBond(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PerformanceChallengeBond: %w", err) + } + } + + case protocol.ExitSettingsContractName: + switch settingName { + // CooperativeExitPhase + case protocol.CooperativeExitPhaseSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposeCooperativeExitPhase(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing CooperativeExitPhase: %w", err) + } + + // DidNotExitPenalty + case protocol.DidNotExitPenaltySettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposeDidNotExitPenalty(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing DidNotExitPenalty: %w", err) + } + + // DidNotExitCooldown + case protocol.DidNotExitCooldownSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposeDidNotExitCooldown(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing DidNotExitCooldown: %w", err) + } + } + } // Make sure a setting was actually hit diff --git a/rocketpool/api/security/propose-settings.go b/rocketpool/api/security/propose-settings.go index 656fa9831..060897839 100644 --- a/rocketpool/api/security/propose-settings.go +++ b/rocketpool/api/security/propose-settings.go @@ -202,6 +202,20 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, return nil, fmt.Errorf("error estimating gas for proposing VacantMinipoolsEnabled: %w", err) } } + + case protocol.PerformanceSettingsContractName: + switch settingName { + // PerformanceExitsEnabled + case protocol.PerformanceExitsEnabledSettingPath: + newValue, err := cliutils.ValidateBool(valueName, value) + if err != nil { + return nil, err + } + response.GasInfo, err = security.EstimateProposePerformanceExitsEnabledGas(rp, newValue, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PerformanceExitsEnabled: %w", err) + } + } } // Make sure a setting was actually hit @@ -394,6 +408,20 @@ func proposeSetting(c *cli.Command, contractName string, settingName string, val return nil, fmt.Errorf("error proposing VacantMinipoolsEnabled: %w", err) } } + + case protocol.PerformanceSettingsContractName: + switch settingName { + // PerformanceExitsEnabled + case protocol.PerformanceExitsEnabledSettingPath: + newValue, err := cliutils.ValidateBool(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = security.ProposePerformanceExitsEnabled(rp, newValue, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PerformanceExitsEnabled: %w", err) + } + } } // Make sure a setting was actually hit diff --git a/shared/types/api/pdao.go b/shared/types/api/pdao.go index 075418eeb..3be4d7a42 100644 --- a/shared/types/api/pdao.go +++ b/shared/types/api/pdao.go @@ -69,7 +69,8 @@ type ExecutePDAOProposalResponse struct { type GetPDAOSettingsResponse struct { APIResponse - Auction struct { + Saturn2Deployed bool `json:"saturn2Deployed"` + Auction struct { IsCreateLotEnabled bool `json:"isCreateLotEnabled"` IsBidOnLotEnabled bool `json:"isBidOnLotEnabled"` LotMinimumEthValue *big.Int `json:"lotMinimumEthValue"` @@ -179,6 +180,20 @@ type GetPDAOSettingsResponse struct { UserDistributeDelayWithShortfall uint64 `json:"userDistributeDelayWithShortfall"` PenaltyThreshold *big.Int `json:"penaltyThreshold"` } `json:"megapool"` + + Performance struct { + ExitsEnabled bool `json:"exitsEnabled"` + Period uint64 `json:"period"` + Threshold *big.Int `json:"threshold"` + ChallengePeriod time.Duration `json:"challengePeriod"` + ChallengeBond *big.Int `json:"challengeBond"` + } `json:"performance"` + + Exit struct { + CooperativeExitPhase time.Duration `json:"cooperativeExitPhase"` + DidNotExitPenalty *big.Int `json:"didNotExitPenalty"` + DidNotExitCooldown time.Duration `json:"didNotExitCooldown"` + } `json:"exit"` } type CanProposePDAOSettingResponse struct { From 7b1e60d0c83cad895e1d9b4280756c0681de3467 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:10:10 -0300 Subject: [PATCH 03/35] Add verify-performance commands --- bindings/settings/protocol/exit.go | 8 +- bindings/settings/protocol/performance.go | 12 +- rocketpool-cli/megapool/commands.go | 54 +++ rocketpool-cli/megapool/verify-performance.go | 38 ++ rocketpool-cli/minipool/commands.go | 39 ++ rocketpool-cli/minipool/verify-performance.go | 31 ++ rocketpool/api/megapool/routes.go | 1 + rocketpool/api/megapool/verify-performance.go | 101 ++++ rocketpool/api/minipool/routes.go | 18 +- rocketpool/api/minipool/verify-performance.go | 64 +++ shared/services/beacon/client.go | 10 + .../services/beacon/client/std-http-client.go | 43 +- shared/services/beacon/client/types.go | 8 +- .../performance/target-performance.go | 437 ++++++++++++++++++ shared/services/rocketpool/megapool.go | 31 ++ shared/services/rocketpool/minipool.go | 26 ++ shared/types/api/minipool.go | 20 + shared/types/eth2/fork/deneb/state_deneb.go | 4 + .../types/eth2/fork/electra/state_electra.go | 4 + shared/types/eth2/fork/fulu/state_fulu.go | 4 + shared/types/eth2/types.go | 1 + .../verify-performance/verify-performance.go | 97 ++++ 22 files changed, 1034 insertions(+), 17 deletions(-) create mode 100644 rocketpool-cli/megapool/verify-performance.go create mode 100644 rocketpool-cli/minipool/verify-performance.go create mode 100644 rocketpool/api/megapool/verify-performance.go create mode 100644 rocketpool/api/minipool/verify-performance.go create mode 100644 shared/services/performance/target-performance.go create mode 100644 shared/utils/cli/verify-performance/verify-performance.go diff --git a/bindings/settings/protocol/exit.go b/bindings/settings/protocol/exit.go index 2949dbb45..f08d4c43c 100644 --- a/bindings/settings/protocol/exit.go +++ b/bindings/settings/protocol/exit.go @@ -16,10 +16,10 @@ import ( // Config const ( - ExitSettingsContractName string = "rocketDAOProtocolSettingsExit" - CooperativeExitPhaseSettingPath string = "cooperative.exit.phase" - DidNotExitPenaltySettingPath string = "did.not.exit.penalty" - DidNotExitCooldownSettingPath string = "did.not.exit.cooldown" + ExitSettingsContractName string = "rocketDAOProtocolSettingsExit" + CooperativeExitPhaseSettingPath string = "cooperative.exit.phase" + DidNotExitPenaltySettingPath string = "did.not.exit.penalty" + DidNotExitCooldownSettingPath string = "did.not.exit.cooldown" ) // Minimum time a validator must remain exit-requested before triggered exit or penalty (hours) diff --git a/bindings/settings/protocol/performance.go b/bindings/settings/protocol/performance.go index f9909ae22..4f2876a56 100644 --- a/bindings/settings/protocol/performance.go +++ b/bindings/settings/protocol/performance.go @@ -16,12 +16,12 @@ import ( // Config const ( - PerformanceSettingsContractName string = "rocketDAOProtocolSettingsPerformance" - PerformanceExitsEnabledSettingPath string = "performance.exits.enabled" - PerformancePeriodSettingPath string = "performance.period" - PerformanceThresholdSettingPath string = "performance.threshold" - PerformanceChallengePeriodSettingPath string = "performance.challenge.period" - PerformanceChallengeBondSettingPath string = "performance.challenge.bond" + PerformanceSettingsContractName string = "rocketDAOProtocolSettingsPerformance" + PerformanceExitsEnabledSettingPath string = "performance.exits.enabled" + PerformancePeriodSettingPath string = "performance.period" + PerformanceThresholdSettingPath string = "performance.threshold" + PerformanceChallengePeriodSettingPath string = "performance.challenge.period" + PerformanceChallengeBondSettingPath string = "performance.challenge.bond" ) // Performance exits currently enabled diff --git a/rocketpool-cli/megapool/commands.go b/rocketpool-cli/megapool/commands.go index be57732c0..37628a5d2 100644 --- a/rocketpool-cli/megapool/commands.go +++ b/rocketpool-cli/megapool/commands.go @@ -3,6 +3,7 @@ package megapool import ( "context" + "github.com/ethereum/go-ethereum/common" "github.com/urfave/cli/v3" cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" @@ -432,6 +433,59 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { return delegateUpgradeMegapool(c.Bool("yes")) }, }, + { + Name: "verify-performance", + Aliases: []string{"vp"}, + Usage: "Verify a megapool validator's RPIP-73 target-vote attestation performance over a range of epochs.", + UsageText: "rocketpool megapool verify-performance validator-id [options]", + Flags: []cli.Flag{ + &cli.Uint64Flag{ + Name: "start-epoch", + Aliases: []string{"s"}, + Usage: "The first epoch in the inclusive performance period.", + }, + &cli.Uint64Flag{ + Name: "epochs", + Aliases: []string{"e"}, + Usage: "Number of epochs to inspect starting at --start-epoch. Defaults to the pDAO performance_period setting.", + }, + &cli.StringFlag{ + Name: "megapool", + Aliases: []string{"M"}, + Usage: "The megapool address to inspect. Defaults to the current node's megapool when omitted.", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Skip the warning prompt when --epochs is large.", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + validatorId, err := cliutils.ValidatePositiveUint32("validator-id", c.Args().Get(0)) + if err != nil { + return err + } + + var megapoolAddr common.Address + if c.IsSet("megapool") { + megapoolAddr, err = cliutils.ValidateAddress("megapool", c.String("megapool")) + if err != nil { + return err + } + } + + return verifyMegapoolPerformance( + megapoolAddr, + validatorId, + c.Uint64("start-epoch"), + c.Uint64("epochs"), + c.Bool("yes"), + ) + }, + }, { Name: "set-use-latest-delegate", Aliases: []string{"l"}, diff --git a/rocketpool-cli/megapool/verify-performance.go b/rocketpool-cli/megapool/verify-performance.go new file mode 100644 index 000000000..ad017f7a1 --- /dev/null +++ b/rocketpool-cli/megapool/verify-performance.go @@ -0,0 +1,38 @@ +package megapool + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/shared/services/rocketpool" + verifyperf "github.com/rocket-pool/smartnode/shared/utils/cli/verify-performance" +) + +func verifyMegapoolPerformance(megapoolAddress common.Address, validatorId uint32, startEpoch uint64, epochs uint64, yes bool) error { + rp, err := rocketpool.NewClient().WithReady() + if err != nil { + return err + } + defer rp.Close() + + startEpoch, endEpoch, err := verifyperf.ResolveEpochRange(rp, startEpoch, epochs) + if err != nil { + return err + } + if !yes && !verifyperf.ConfirmLargeRange(endEpoch-startEpoch+1) { + return verifyperf.PrintCancelled() + } + + resp, err := rp.VerifyMegapoolValidatorPerformance(megapoolAddress, validatorId, startEpoch, endEpoch) + if err != nil { + return err + } + + label := fmt.Sprintf("megapool validator %d", validatorId) + if (megapoolAddress != common.Address{}) { + label = fmt.Sprintf("megapool %s validator %d", megapoolAddress.Hex(), validatorId) + } + verifyperf.PrintResult(resp, label) + return nil +} diff --git a/rocketpool-cli/minipool/commands.go b/rocketpool-cli/minipool/commands.go index 91c1acfb2..1c7f02340 100644 --- a/rocketpool-cli/minipool/commands.go +++ b/rocketpool-cli/minipool/commands.go @@ -326,6 +326,45 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { }, }, + { + Name: "verify-performance", + Aliases: []string{"vp"}, + Usage: "Verify a minipool's RPIP-73 target-vote attestation performance over a range of epochs.", + UsageText: "rocketpool minipool verify-performance minipool-address [options]", + Flags: []cli.Flag{ + &cli.Uint64Flag{ + Name: "start-epoch", + Aliases: []string{"s"}, + Usage: "The first epoch in the inclusive performance period.", + }, + &cli.Uint64Flag{ + Name: "epochs", + Aliases: []string{"e"}, + Usage: "Number of epochs to inspect starting at --start-epoch. Defaults to the pDAO performance_period setting.", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Skip the warning prompt when --epochs is large.", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + address, err := cliutils.ValidateAddress("minipool-address", c.Args().Get(0)) + if err != nil { + return err + } + return verifyMinipoolPerformance( + address, + c.Uint64("start-epoch"), + c.Uint64("epochs"), + c.Bool("yes"), + ) + }, + }, + { Name: "rescue-dissolved", Aliases: []string{"rd"}, diff --git a/rocketpool-cli/minipool/verify-performance.go b/rocketpool-cli/minipool/verify-performance.go new file mode 100644 index 000000000..a78a1e6bb --- /dev/null +++ b/rocketpool-cli/minipool/verify-performance.go @@ -0,0 +1,31 @@ +package minipool + +import ( + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/shared/services/rocketpool" + verifyperf "github.com/rocket-pool/smartnode/shared/utils/cli/verify-performance" +) + +func verifyMinipoolPerformance(address common.Address, startEpoch uint64, epochs uint64, yes bool) error { + rp, err := rocketpool.NewClient().WithReady() + if err != nil { + return err + } + defer rp.Close() + + startEpoch, endEpoch, err := verifyperf.ResolveEpochRange(rp, startEpoch, epochs) + if err != nil { + return err + } + if !yes && !verifyperf.ConfirmLargeRange(endEpoch-startEpoch+1) { + return verifyperf.PrintCancelled() + } + + resp, err := rp.VerifyMinipoolPerformance(address, startEpoch, endEpoch) + if err != nil { + return err + } + verifyperf.PrintResult(resp, "minipool "+address.Hex()) + return nil +} diff --git a/rocketpool/api/megapool/routes.go b/rocketpool/api/megapool/routes.go index 94685b55f..44e026ed1 100644 --- a/rocketpool/api/megapool/routes.go +++ b/rocketpool/api/megapool/routes.go @@ -48,6 +48,7 @@ func RegisterRoutes(router *snroute.Router) { snroute.Read("/api/megapool/get-effective-delegate", getEffectiveDelegateHandler).RegisterTo(router) snroute.Read("/api/megapool/latest-block-withdrawals", latestBlockWithdrawalsHandler).RegisterTo(router) snroute.Read("/api/megapool/beacon-withdrawal-queue-estimate", beaconWithdrawalQueueEstimateHandler).RegisterTo(router) + snroute.Read("/api/megapool/verify-performance", verifyPerformanceHandler).RegisterTo(router) } func parseUint64(r *http.Request, name string) (uint64, error) { diff --git a/rocketpool/api/megapool/verify-performance.go b/rocketpool/api/megapool/verify-performance.go new file mode 100644 index 000000000..3142dc550 --- /dev/null +++ b/rocketpool/api/megapool/verify-performance.go @@ -0,0 +1,101 @@ +package megapool + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v3" + + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/node" + + "github.com/rocket-pool/smartnode/rocketpool/api/response" + "github.com/rocket-pool/smartnode/rocketpool/api/snroute" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/performance" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// verifyPerformance computes a megapool validator's RPIP-73 target-vote +// performance over the inclusive epoch range [startEpoch, endEpoch]. +// +// If megapoolAddress is the zero address, the node's own megapool address is +// looked up via rocketNodeManager.getMegapoolAddress. +func verifyPerformance( + c *cli.Command, + megapoolAddress common.Address, + validatorId uint32, + startEpoch uint64, + endEpoch uint64, +) (*api.VerifyPerformanceResponse, error) { + if err := services.RequireBeaconClientSynced(c); err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + if (megapoolAddress == common.Address{}) { + if err := services.RequireNodeRegistered(c); err != nil { + return nil, fmt.Errorf("no megapool address supplied and node is not registered: %w", err) + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + nodeAccount, err := w.GetNodeAccount() + if err != nil { + return nil, err + } + megapoolAddress, err = node.GetMegapoolAddress(rp, nodeAccount.Address, nil) + if err != nil { + return nil, fmt.Errorf("error looking up node's megapool address: %w", err) + } + if (megapoolAddress == common.Address{}) { + return nil, fmt.Errorf("node has no megapool deployed; pass --megapool to specify one") + } + } + + mp, err := megapool.NewMegaPoolV1(rp, megapoolAddress, nil) + if err != nil { + return nil, fmt.Errorf("error creating megapool binding for %s: %w", megapoolAddress.Hex(), err) + } + pubkey, err := mp.GetValidatorPubkey(validatorId, nil) + if err != nil { + return nil, fmt.Errorf("error getting megapool %s validator %d pubkey: %w", megapoolAddress.Hex(), validatorId, err) + } + + return performance.VerifyPerformance(rp, bc, pubkey, startEpoch, endEpoch) +} + +func verifyPerformanceHandler(ctx snroute.Context) { + validatorId, err := parseUint32(ctx.Request, "validatorId") + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + startEpoch, err := parseUint64(ctx.Request, "startEpoch") + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + endEpoch, err := parseUint64(ctx.Request, "endEpoch") + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + // megapoolAddress is optional; zero address means "use the node's own megapool". + var megapoolAddr common.Address + if raw := ctx.Request.URL.Query().Get("megapoolAddress"); raw != "" { + megapoolAddr = common.HexToAddress(raw) + } else if raw := ctx.Request.FormValue("megapoolAddress"); raw != "" { + megapoolAddr = common.HexToAddress(raw) + } + resp, err := verifyPerformance(ctx.Command(), megapoolAddr, validatorId, startEpoch, endEpoch) + response.WriteResponse(ctx.Writer, resp, err) +} diff --git a/rocketpool/api/minipool/routes.go b/rocketpool/api/minipool/routes.go index c0262269e..baef18604 100644 --- a/rocketpool/api/minipool/routes.go +++ b/rocketpool/api/minipool/routes.go @@ -3,6 +3,7 @@ package minipool import ( "fmt" "net/http" + "strconv" "github.com/ethereum/go-ethereum/common" @@ -39,8 +40,8 @@ func RegisterRoutes(router *snroute.Router) { snroute.Read("/api/minipool/can-change-withdrawal-creds", canChangeWithdrawalCredsHandler).RegisterTo(router) snroute.Write("/api/minipool/change-withdrawal-creds", changeWithdrawalCredsHandler).RegisterTo(router) snroute.Read("/api/minipool/get-rescue-dissolved-details-for-node", getRescueDissolvedDetailsForNodeHandler).RegisterTo(router) + snroute.Read("/api/minipool/verify-performance", verifyPerformanceHandler).RegisterTo(router) snroute.Write("/api/minipool/rescue-dissolved", rescueDissolvedHandler).RegisterTo(router) - } func parseAddress(r *http.Request, name string) (common.Address, error) { @@ -53,3 +54,18 @@ func parseAddress(r *http.Request, name string) (common.Address, error) { } return common.HexToAddress(raw), nil } + +func parseUint64Param(r *http.Request, name string) (uint64, error) { + raw := r.URL.Query().Get(name) + if raw == "" { + raw = r.FormValue(name) + } + if raw == "" { + return 0, fmt.Errorf("missing required parameter: %s", name) + } + v, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid %s: %w", name, err) + } + return v, nil +} diff --git a/rocketpool/api/minipool/verify-performance.go b/rocketpool/api/minipool/verify-performance.go new file mode 100644 index 000000000..f492f7bec --- /dev/null +++ b/rocketpool/api/minipool/verify-performance.go @@ -0,0 +1,64 @@ +package minipool + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v3" + + "github.com/rocket-pool/smartnode/bindings/minipool" + + "github.com/rocket-pool/smartnode/rocketpool/api/response" + "github.com/rocket-pool/smartnode/rocketpool/api/snroute" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/performance" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// verifyPerformance computes a minipool validator's RPIP-73 target-vote +// performance over the inclusive epoch range [startEpoch, endEpoch]. +func verifyPerformance( + c *cli.Command, + minipoolAddress common.Address, + startEpoch uint64, + endEpoch uint64, +) (*api.VerifyPerformanceResponse, error) { + if err := services.RequireBeaconClientSynced(c); err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + pubkey, err := minipool.GetMinipoolPubkey(rp, minipoolAddress, nil) + if err != nil { + return nil, fmt.Errorf("error getting minipool %s pubkey: %w", minipoolAddress.Hex(), err) + } + + return performance.VerifyPerformance(rp, bc, pubkey, startEpoch, endEpoch) +} + +func verifyPerformanceHandler(ctx snroute.Context) { + addr, err := parseAddress(ctx.Request, "address") + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + startEpoch, err := parseUint64Param(ctx.Request, "startEpoch") + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + endEpoch, err := parseUint64Param(ctx.Request, "endEpoch") + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + resp, err := verifyPerformance(ctx.Command(), addr, startEpoch, endEpoch) + response.WriteResponse(ctx.Writer, resp, err) +} diff --git a/shared/services/beacon/client.go b/shared/services/beacon/client.go index b80ec67ad..d5265a08c 100644 --- a/shared/services/beacon/client.go +++ b/shared/services/beacon/client.go @@ -69,6 +69,9 @@ type BeaconBlock struct { type BeaconBlockHeader struct { Slot uint64 ProposerIndex string + // Root is the block root for this header. Used by RPIP-73 target-vote + // verification to resolve the canonical target root at an epoch boundary. + Root common.Hash } // Committees is an interface as an optimization- since committees responses @@ -102,6 +105,13 @@ type AttestationInfo struct { SlotIndex uint64 // Committees represented by AggregationBits Committees bitfield.Bitvector64 + // TargetEpoch is the epoch this attestation voted as its target. Populated + // by parsers that need it (e.g. RPIP-73 target-vote verification); other + // callers may leave it zero-valued. + TargetEpoch uint64 + // TargetRoot is the block root this attestation voted as its target. + // Populated alongside TargetEpoch. + TargetRoot common.Hash } func (a *AttestationInfo) CommitteeIndices() []int { diff --git a/shared/services/beacon/client/std-http-client.go b/shared/services/beacon/client/std-http-client.go index 60e67d5ad..2d8fec936 100644 --- a/shared/services/beacon/client/std-http-client.go +++ b/shared/services/beacon/client/std-http-client.go @@ -636,6 +636,8 @@ func (c *StandardHttpClient) GetAttestations(blockId string) ([]beacon.Attestati for i, attestation := range attestations.Data { bitString := hexutil.RemovePrefix(attestation.AggregationBits) attestationInfo[i].SlotIndex = uint64(attestation.Data.Slot) + attestationInfo[i].TargetEpoch = uint64(attestation.Data.Target.Epoch) + attestationInfo[i].TargetRoot = common.BytesToHash(attestation.Data.Target.Root) attestationInfo[i].AggregationBits, err = hex.DecodeString(bitString) if err != nil { return nil, false, fmt.Errorf("Error decoding aggregation bits for attestation %d of block %s: %w", i, blockId, err) @@ -692,7 +694,9 @@ func (c *StandardHttpClient) GetBeaconBlock(blockId string) (beacon.BeaconBlock, for i, attestation := range block.Data.Message.Body.Attestations { bitString := hexutil.RemovePrefix(attestation.AggregationBits) info := beacon.AttestationInfo{ - SlotIndex: uint64(attestation.Data.Slot), + SlotIndex: uint64(attestation.Data.Slot), + TargetEpoch: uint64(attestation.Data.Target.Epoch), + TargetRoot: common.BytesToHash(attestation.Data.Target.Root), } info.AggregationBits, err = hex.DecodeString(bitString) if err != nil { @@ -746,18 +750,49 @@ func (c *StandardHttpClient) GetBeaconBlockHeader(blockId string) (beacon.Beacon beaconBlock := beacon.BeaconBlockHeader{ Slot: uint64(block.Data.Header.Message.Slot), ProposerIndex: block.Data.Header.Message.ProposerIndex, + Root: common.HexToHash(block.Data.Root), } return beaconBlock, true, nil } -// Get the attestation committees for the given epoch, or the current epoch if nil +// Get the attestation committees for the given epoch, or the current epoch if nil. +// For historical epochs the request uses the beacon state at the epoch's first +// slot so archival nodes return the correct shuffling. If that state is +// unavailable, head is tried as a fallback. func (c *StandardHttpClient) GetCommitteesForEpoch(epoch *uint64) (beacon.Committees, error) { - response, err := c.getCommittees("head", epoch) + if epoch == nil { + response, err := c.getCommittees("head", nil) + if err != nil { + return nil, err + } + return &response, nil + } + + eth2Config, err := c.getEth2Config() if err != nil { return nil, err } - return &response, nil + stateSlot := *epoch * uint64(eth2Config.Data.SlotsPerEpoch) + response, err := c.getCommittees(strconv.FormatUint(stateSlot, 10), epoch) + if err == nil && len(response.Data) > 0 { + return &response, nil + } + + // Some clients can resolve historical shuffling from head; others only + // serve epoch E from a state at or after epoch E. + headResponse, headErr := c.getCommittees("head", epoch) + if headErr == nil && len(headResponse.Data) > 0 { + return &headResponse, nil + } + + if err != nil { + return nil, err + } + if headErr != nil { + return nil, headErr + } + return nil, fmt.Errorf("Could not get committees for epoch %d: no committee data returned (archival beacon node may be required)", *epoch) } // Perform a withdrawal credentials change on a validator diff --git a/shared/services/beacon/client/types.go b/shared/services/beacon/client/types.go index 799ac3415..cff7b0679 100644 --- a/shared/services/beacon/client/types.go +++ b/shared/services/beacon/client/types.go @@ -174,8 +174,12 @@ type CommitteesResponse struct { type Attestation struct { AggregationBits string `json:"aggregation_bits"` Data struct { - Slot uinteger `json:"slot"` - Index uinteger `json:"index"` + Slot uinteger `json:"slot"` + Index uinteger `json:"index"` + Target struct { + Epoch uinteger `json:"epoch"` + Root byteArray `json:"root"` + } `json:"target"` } `json:"data"` CommitteeBits string `json:"committee_bits"` } diff --git a/shared/services/performance/target-performance.go b/shared/services/performance/target-performance.go new file mode 100644 index 000000000..4942f036a --- /dev/null +++ b/shared/services/performance/target-performance.go @@ -0,0 +1,437 @@ +// Package performance implements RPIP-73 target-vote performance verification. +// +// RPIP-73 measures attestation performance using the "target" timeliness flag +// from the Beacon State's previous_epoch_participation vector. The flag is +// defined to be set for validator v in epoch E iff some attestation by v with +// data.target.epoch == E was: +// +// 1. included in a block (which implies source-checkpoint matching), AND +// 2. voting for the correct target root, i.e. data.target.root equals the +// canonical block root at the first slot of epoch E, AND +// 3. included within SLOTS_PER_EPOCH slots of data.slot. +// +// This package recomputes the flag by inspecting block attestations rather +// than downloading the full Beacon State SSZ. Per epoch under inspection the +// cost is roughly: one committees fetch + one block-header fetch (target +// root) + up to SLOTS_PER_EPOCH block fetches (inclusion window). This is +// orders of magnitude cheaper than fetching beacon states, and works against +// any standard Beacon API node (archival is still required for old slots). +package performance + +import ( + "fmt" + "strconv" + + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/rocketpool" + rptypes "github.com/rocket-pool/smartnode/bindings/types" + + // "github.com/rocket-pool/smartnode/bindings/settings/protocol" + // "github.com/rocket-pool/smartnode/bindings/utils/eth" + + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// Beacon participation flag indices, see the Ethereum consensus spec +// (Altair upgrade). previous_epoch_participation packs these as bit flags +// per validator in a single byte. +const ( + TimelySourceFlagIndex = 0 + TimelyTargetFlagIndex = 1 + TimelyHeadFlagIndex = 2 +) + +// defaultPerformanceThresholdPct is the RPIP-73 initial pDAO +// performance_threshold value (94%). It is used while the on-chain +// rocketDAOProtocolSettingsPerformance contract is not yet deployed; once +// available, replace this with a live call to protocol.GetPerformanceThreshold. +const defaultPerformanceThresholdPct = 94.0 + +// farFutureEpoch is the spec's FAR_FUTURE_EPOCH sentinel (2^64-1). +const farFutureEpoch = ^uint64(0) + +// maxTargetRootWalkback bounds the number of skipped slots we will walk back +// to find the canonical target block root for an epoch. In any healthy chain +// this is 0 (the boundary slot has a block) or a small handful. +const maxTargetRootWalkback = 64 + +// EpochPerformance is the per-epoch result of a target-vote check. +type EpochPerformance struct { + Epoch uint64 `json:"epoch"` + TimelyTarget bool `json:"timelyTarget"` +} + +// PerformanceSummary aggregates the per-epoch results of a target-vote check +// over the inclusive range [StartEpoch, EndEpoch]. Epochs in which the +// validator was not assigned to any committee (i.e. not yet active or already +// exited) are counted in InactiveEpochs and excluded from PerformancePct's +// denominator. +type PerformanceSummary struct { + ValidatorIndex uint64 `json:"validatorIndex"` + StartEpoch uint64 `json:"startEpoch"` + EndEpoch uint64 `json:"endEpoch"` + TotalEpochs uint64 `json:"totalEpochs"` + TimelyEpochs uint64 `json:"timelyEpochs"` + MissedEpochs uint64 `json:"missedEpochs"` + InactiveEpochs uint64 `json:"inactiveEpochs"` + PerformancePct float64 `json:"performancePct"` + MissedEpochList []uint64 `json:"missedEpochList"` + TimelyEpochList []uint64 `json:"timelyEpochList"` +} + +// PerformanceBeaconClient is the minimal beacon client surface used by the +// block-based target-vote engine. +type PerformanceBeaconClient interface { + GetEth2Config() (beacon.Eth2Config, error) + GetCommitteesForEpoch(epoch *uint64) (beacon.Committees, error) + GetBeaconBlock(blockId string) (beacon.BeaconBlock, bool, error) + GetBeaconBlockHeader(blockId string) (beacon.BeaconBlockHeader, bool, error) + GetValidatorStatusByIndex(index string, opts *beacon.ValidatorStatusOptions) (beacon.ValidatorStatus, error) +} + +// pubkeyBeaconClient is the beacon client surface needed to resolve a +// validator pubkey to a beacon-chain index in addition to the engine +// requirements. +type pubkeyBeaconClient interface { + PerformanceBeaconClient + GetValidatorIndex(pubkey rptypes.ValidatorPubkey) (string, error) +} + +// VerifyPerformance is the end-to-end RPIP-73 target-vote verification flow +// shared by the minipool and megapool API endpoints. It resolves the +// validator's beacon-chain index from the supplied pubkey, runs +// CheckTargetPerformance, and packages the result alongside the pDAO +// performance_threshold for pass/fail reporting. +func VerifyPerformance( + rp *rocketpool.RocketPool, + bc pubkeyBeaconClient, + pubkey rptypes.ValidatorPubkey, + startEpoch uint64, + endEpoch uint64, +) (*api.VerifyPerformanceResponse, error) { + if pubkey == (rptypes.ValidatorPubkey{}) { + return nil, fmt.Errorf("validator has no pubkey on-chain yet (not deposited?)") + } + + cfg, err := bc.GetEth2Config() + if err != nil { + return nil, fmt.Errorf("error getting beacon config: %w", err) + } + + indexStr, err := bc.GetValidatorIndex(pubkey) + if err != nil { + return nil, fmt.Errorf("error getting beacon-chain index for validator %s: %w", pubkey.Hex(), err) + } + validatorIndex, err := strconv.ParseUint(indexStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("error parsing validator index %q: %w", indexStr, err) + } + + summary, err := CheckTargetPerformance(bc, cfg, validatorIndex, startEpoch, endEpoch) + if err != nil { + return nil, err + } + + // The performance_threshold setting is scaled by 1e18 (1e18 = 100%). + // thresholdWei, err := protocol.GetPerformanceThreshold(rp, nil) + // if err != nil { + // return nil, fmt.Errorf("error getting performance threshold: %w", err) + // } + // thresholdPct := eth.WeiToEth(thresholdWei) * 100 + // + // TODO: The rocketDAOProtocolSettingsPerformance contract is not yet deployed. + // Use the RPIP-73 initial value until it is. + thresholdPct := defaultPerformanceThresholdPct + + return &api.VerifyPerformanceResponse{ + ValidatorPubkey: pubkey, + ValidatorIndex: summary.ValidatorIndex, + StartEpoch: summary.StartEpoch, + EndEpoch: summary.EndEpoch, + TotalEpochs: summary.TotalEpochs, + TimelyEpochs: summary.TimelyEpochs, + MissedEpochs: summary.MissedEpochs, + InactiveEpochs: summary.InactiveEpochs, + PerformancePct: summary.PerformancePct, + PerformanceThresholdPct: thresholdPct, + PassesThreshold: summary.PerformancePct >= thresholdPct, + MissedEpochList: summary.MissedEpochList, + TimelyEpochList: summary.TimelyEpochList, + }, nil +} + +// CheckTargetPerformance evaluates a single validator's target-vote +// performance over the inclusive epoch range [startEpoch, endEpoch] by +// reading the canonical target root, the validator's committee assignment, +// and the attestations in the inclusion window per epoch. +func CheckTargetPerformance( + bc PerformanceBeaconClient, + cfg beacon.Eth2Config, + validatorIndex uint64, + startEpoch uint64, + endEpoch uint64, +) (*PerformanceSummary, error) { + if endEpoch < startEpoch { + return nil, fmt.Errorf("end epoch %d is before start epoch %d", endEpoch, startEpoch) + } + if cfg.SlotsPerEpoch == 0 { + return nil, fmt.Errorf("invalid beacon config: SlotsPerEpoch is 0") + } + + summary := &PerformanceSummary{ + ValidatorIndex: validatorIndex, + StartEpoch: startEpoch, + EndEpoch: endEpoch, + TotalEpochs: endEpoch - startEpoch + 1, + MissedEpochList: []uint64{}, + TimelyEpochList: []uint64{}, + } + + for epoch := startEpoch; epoch <= endEpoch; epoch++ { + state, err := checkEpoch(bc, cfg, validatorIndex, epoch) + if err != nil { + return nil, fmt.Errorf("error checking epoch %d: %w", epoch, err) + } + switch state { + case epochResultTimely: + summary.TimelyEpochs++ + summary.TimelyEpochList = append(summary.TimelyEpochList, epoch) + case epochResultMissed: + summary.MissedEpochs++ + summary.MissedEpochList = append(summary.MissedEpochList, epoch) + case epochResultInactive: + summary.InactiveEpochs++ + } + } + + activeEpochs := summary.TimelyEpochs + summary.MissedEpochs + if activeEpochs > 0 { + summary.PerformancePct = float64(summary.TimelyEpochs) / float64(activeEpochs) * 100.0 + } + + return summary, nil +} + +// CheckEpochTargetVote returns true if the validator made a timely target +// vote for the given epoch. Returns (false, nil) for missed-target epochs; +// errors are reserved for I/O / parsing failures. If the validator was not +// in a committee for this epoch, the result is (true, nil) — there was no +// duty to perform, so it is not exit-eligible under RPIP-73. +// +// This is the lightweight single-epoch entry point intended for use by a +// challenge defender, who only needs to find one timely epoch within the +// challenged range. +func CheckEpochTargetVote( + bc PerformanceBeaconClient, + cfg beacon.Eth2Config, + validatorIndex uint64, + epoch uint64, +) (bool, error) { + state, err := checkEpoch(bc, cfg, validatorIndex, epoch) + if err != nil { + return false, err + } + return state != epochResultMissed, nil +} + +// epochResult enumerates the per-epoch verdicts. +type epochResult int + +const ( + epochResultMissed epochResult = iota + epochResultTimely + epochResultInactive +) + +// attestationDuty describes the unique (slot, committee, position) tuple +// assigned to a validator for an epoch. +type attestationDuty struct { + slot uint64 + committeeIndex uint64 + position int + committeeSizesAtDay map[uint64]int // committee_index -> validator count, for all committees at duty.slot +} + +// checkEpoch performs the per-epoch evaluation. It returns epochResultTimely +// if a matching, timely target vote was found for the validator; +// epochResultMissed if the validator had a duty but no matching attestation +// landed; epochResultInactive if the validator had no committee assignment +// for the epoch. +func checkEpoch( + bc PerformanceBeaconClient, + cfg beacon.Eth2Config, + validatorIndex uint64, + epoch uint64, +) (epochResult, error) { + // Step 1: resolve the canonical target block root for the epoch. This is + // the canonical block root at slot epoch*SlotsPerEpoch, walking back if + // the boundary slot was skipped. + targetRoot, err := resolveTargetRoot(bc, cfg, epoch) + if err != nil { + return epochResultMissed, err + } + + // Step 2: find the validator's attestation duty for the epoch by walking + // the committees response and matching against validatorIndex. + duty, found, err := findAttestationDuty(bc, epoch, validatorIndex) + if err != nil { + return epochResultMissed, err + } + if !found { + indexStr := strconv.FormatUint(validatorIndex, 10) + status, err := bc.GetValidatorStatusByIndex(indexStr, nil) + if err != nil { + return epochResultMissed, fmt.Errorf("error getting validator status for index %d: %w", validatorIndex, err) + } + if !validatorHadAttestationDuty(status, epoch) { + return epochResultInactive, nil + } + return epochResultMissed, fmt.Errorf( + "validator index %d was active in epoch %d but was not found in attestation committees; ensure your beacon node provides historical committee data (archival node required)", + validatorIndex, epoch, + ) + } + + // Step 3: scan the inclusion window for a matching attestation. The + // inclusion window for the target flag is up to SLOTS_PER_EPOCH slots + // after the duty slot. Return as soon as we find a match. + inclusionEndExclusive := duty.slot + 1 + cfg.SlotsPerEpoch + for slot := duty.slot + 1; slot < inclusionEndExclusive; slot++ { + block, exists, err := bc.GetBeaconBlock(strconv.FormatUint(slot, 10)) + if err != nil { + return epochResultMissed, fmt.Errorf("error getting block at slot %d: %w", slot, err) + } + if !exists { + continue + } + if matchesDuty(block.Attestations, duty, epoch, targetRoot) { + return epochResultTimely, nil + } + } + + return epochResultMissed, nil +} + +// resolveTargetRoot returns the canonical block root for epoch E, i.e. +// get_block_root(state, E) = canonical block root at slot E*SlotsPerEpoch +// (walking back if the boundary slot was skipped). +func resolveTargetRoot(bc PerformanceBeaconClient, cfg beacon.Eth2Config, epoch uint64) (common.Hash, error) { + boundarySlot := epoch * cfg.SlotsPerEpoch + for attempt := uint64(0); attempt < maxTargetRootWalkback; attempt++ { + if attempt > boundarySlot { + break + } + slot := boundarySlot - attempt + header, exists, err := bc.GetBeaconBlockHeader(strconv.FormatUint(slot, 10)) + if err != nil { + return common.Hash{}, fmt.Errorf("error getting beacon block header at slot %d: %w", slot, err) + } + if exists { + return header.Root, nil + } + } + return common.Hash{}, fmt.Errorf("could not find a non-skipped slot within %d slots of epoch %d boundary to resolve target root", maxTargetRootWalkback, epoch) +} + +// findAttestationDuty walks the committees response for the epoch and +// returns the (slot, committee_index, position) assignment of the requested +// validator, plus the committee-size map for the duty slot (needed to compute +// the aggregation-bits offset for post-Electra attestations). +func findAttestationDuty(bc PerformanceBeaconClient, epoch uint64, validatorIndex uint64) (attestationDuty, bool, error) { + committees, err := bc.GetCommitteesForEpoch(&epoch) + if err != nil { + return attestationDuty{}, false, fmt.Errorf("error getting committees for epoch %d: %w", epoch, err) + } + defer committees.Release() + + indexStr := strconv.FormatUint(validatorIndex, 10) + + // First pass: locate the validator's duty. + var duty attestationDuty + found := false + for i := 0; i < committees.Count(); i++ { + validators := committees.Validators(i) + for pos, vIdx := range validators { + if vIdx == indexStr { + duty.slot = committees.Slot(i) + duty.committeeIndex = committees.Index(i) + duty.position = pos + found = true + break + } + } + if found { + break + } + } + + if !found { + return attestationDuty{}, false, nil + } + + // Second pass: collect committee sizes for all committees at duty.slot + // so that ValidatorAttested can compute the correct aggregation-bits + // offset under post-Electra attestation aggregation. + duty.committeeSizesAtDay = map[uint64]int{} + for i := 0; i < committees.Count(); i++ { + if committees.Slot(i) != duty.slot { + continue + } + duty.committeeSizesAtDay[committees.Index(i)] = committees.ValidatorCount(i) + } + + return duty, true, nil +} + +// matchesDuty returns true if any attestation in atts is a timely-target +// match for the given duty. The caller has already constrained inclusion +// delay by the slot range it iterated over. +func matchesDuty(atts []beacon.AttestationInfo, duty attestationDuty, epoch uint64, targetRoot common.Hash) bool { + for _, att := range atts { + if att.SlotIndex != duty.slot { + continue + } + if att.TargetEpoch != epoch { + continue + } + if att.TargetRoot != targetRoot { + continue + } + // The attestation must cover the duty committee. Pre-Electra + // attestations cover exactly one committee; post-Electra ones may + // cover several. Either way, CommitteeIndices() returns the set. + committeeIdxInt := int(duty.committeeIndex) + hasCommittee := false + for _, ci := range att.CommitteeIndices() { + if ci == committeeIdxInt { + hasCommittee = true + break + } + } + if !hasCommittee { + continue + } + if att.ValidatorAttested(committeeIdxInt, duty.position, duty.committeeSizesAtDay) { + return true + } + } + return false +} + +// validatorHadAttestationDuty reports whether the validator was required to +// perform an attestation duty in the given epoch based on its activation and +// exit epochs. +func validatorHadAttestationDuty(status beacon.ValidatorStatus, epoch uint64) bool { + if !status.Exists { + return false + } + if status.ActivationEpoch == farFutureEpoch || status.ActivationEpoch > epoch { + return false + } + if status.ExitEpoch != farFutureEpoch && status.ExitEpoch <= epoch { + return false + } + return true +} diff --git a/shared/services/rocketpool/megapool.go b/shared/services/rocketpool/megapool.go index 98af2bd41..283810f76 100644 --- a/shared/services/rocketpool/megapool.go +++ b/shared/services/rocketpool/megapool.go @@ -1,16 +1,47 @@ package rocketpool import ( + "context" "fmt" "math/big" "net/url" "strconv" "github.com/ethereum/go-ethereum/common" + "github.com/goccy/go-json" "github.com/rocket-pool/smartnode/shared/types/api" ) +// VerifyMegapoolValidatorPerformance computes RPIP-73 target-vote performance +// for a megapool validator over [startEpoch, endEpoch]. If megapoolAddress is +// the zero address the daemon resolves the node's own megapool. This call has +// no client-side deadline because each epoch requires a full Beacon State SSZ +// download, which can take several minutes per epoch on an archival beacon +// node. +func (c *Client) VerifyMegapoolValidatorPerformance(megapoolAddress common.Address, validatorId uint32, startEpoch, endEpoch uint64) (api.VerifyPerformanceResponse, error) { + values := url.Values{ + "validatorId": {strconv.FormatUint(uint64(validatorId), 10)}, + "startEpoch": {strconv.FormatUint(startEpoch, 10)}, + "endEpoch": {strconv.FormatUint(endEpoch, 10)}, + } + if (megapoolAddress != common.Address{}) { + values.Set("megapoolAddress", megapoolAddress.Hex()) + } + responseBytes, err := c.callHTTPAPICtx(context.Background(), "GET", "/api/megapool/verify-performance", values) + if err != nil { + return api.VerifyPerformanceResponse{}, fmt.Errorf("Could not verify megapool validator performance: %w", err) + } + var response api.VerifyPerformanceResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return api.VerifyPerformanceResponse{}, fmt.Errorf("Could not decode verify-performance response: %w", err) + } + if response.Error != "" { + return api.VerifyPerformanceResponse{}, fmt.Errorf("Could not verify megapool validator performance: %s", response.Error) + } + return response, nil +} + // Get megapool status func (c *Client) MegapoolStatus(finalizedState bool) (api.MegapoolStatusResponse, error) { finalizedStr := "false" diff --git a/shared/services/rocketpool/minipool.go b/shared/services/rocketpool/minipool.go index 9c3434b1a..1083e6478 100644 --- a/shared/services/rocketpool/minipool.go +++ b/shared/services/rocketpool/minipool.go @@ -1,11 +1,14 @@ package rocketpool import ( + "context" + "fmt" "math/big" "net/url" "strconv" "github.com/ethereum/go-ethereum/common" + "github.com/goccy/go-json" "github.com/rocket-pool/smartnode/shared/types/api" ) @@ -170,6 +173,29 @@ func (c *Client) GetMinipoolRescueDissolvedDetailsForNode() (api.GetMinipoolResc return c.callAPI[api.GetMinipoolRescueDissolvedDetailsForNodeResponse]("GET", "/api/minipool/get-rescue-dissolved-details-for-node", nil, "Could not get get-minipool-rescue-dissolved-details-for-node status") } +// VerifyMinipoolPerformance computes RPIP-73 target-vote performance for a +// minipool's validator over [startEpoch, endEpoch]. This call has no client- +// side deadline because each epoch requires a full Beacon State SSZ download, +// which can take several minutes per epoch on an archival beacon node. +func (c *Client) VerifyMinipoolPerformance(address common.Address, startEpoch, endEpoch uint64) (api.VerifyPerformanceResponse, error) { + responseBytes, err := c.callHTTPAPICtx(context.Background(), "GET", "/api/minipool/verify-performance", url.Values{ + "address": {address.Hex()}, + "startEpoch": {strconv.FormatUint(startEpoch, 10)}, + "endEpoch": {strconv.FormatUint(endEpoch, 10)}, + }) + if err != nil { + return api.VerifyPerformanceResponse{}, fmt.Errorf("Could not verify minipool performance: %w", err) + } + var response api.VerifyPerformanceResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return api.VerifyPerformanceResponse{}, fmt.Errorf("Could not decode verify-performance response: %w", err) + } + if response.Error != "" { + return api.VerifyPerformanceResponse{}, fmt.Errorf("Could not verify minipool performance: %s", response.Error) + } + return response, nil +} + // Rescue a dissolved minipool by depositing ETH for it to the Beacon deposit contract func (c *Client) RescueDissolvedMinipool(address common.Address, amount *big.Int, submit bool) (api.RescueDissolvedMinipoolResponse, error) { submitStr := "false" diff --git a/shared/types/api/minipool.go b/shared/types/api/minipool.go index c6ccbcde2..c764a0d63 100644 --- a/shared/types/api/minipool.go +++ b/shared/types/api/minipool.go @@ -301,3 +301,23 @@ type GetBondReductionEnabledResponse struct { APIResponse BondReductionEnabled bool `json:"bondReductionEnabled"` } + +// VerifyPerformanceResponse reports the result of an RPIP-73 target-vote +// performance check for a single validator over a range of epochs. +type VerifyPerformanceResponse struct { + Status string `json:"status"` + Error string `json:"error"` + ValidatorPubkey types.ValidatorPubkey `json:"validatorPubkey"` + ValidatorIndex uint64 `json:"validatorIndex"` + StartEpoch uint64 `json:"startEpoch"` + EndEpoch uint64 `json:"endEpoch"` + TotalEpochs uint64 `json:"totalEpochs"` + TimelyEpochs uint64 `json:"timelyEpochs"` + MissedEpochs uint64 `json:"missedEpochs"` + InactiveEpochs uint64 `json:"inactiveEpochs"` + PerformancePct float64 `json:"performancePct"` + PerformanceThresholdPct float64 `json:"performanceThresholdPct"` + PassesThreshold bool `json:"passesThreshold"` + MissedEpochList []uint64 `json:"missedEpochList"` + TimelyEpochList []uint64 `json:"timelyEpochList"` +} diff --git a/shared/types/eth2/fork/deneb/state_deneb.go b/shared/types/eth2/fork/deneb/state_deneb.go index 48c9e34b2..0dbd6bfe6 100644 --- a/shared/types/eth2/fork/deneb/state_deneb.go +++ b/shared/types/eth2/fork/deneb/state_deneb.go @@ -243,3 +243,7 @@ func (state *BeaconState) GetValidators() []*generic.Validator { func (state *BeaconState) GetSlot() uint64 { return state.Slot } + +func (state *BeaconState) GetPreviousEpochParticipation() []byte { + return state.PreviousEpochParticipation +} diff --git a/shared/types/eth2/fork/electra/state_electra.go b/shared/types/eth2/fork/electra/state_electra.go index d711cef65..1dff699b9 100644 --- a/shared/types/eth2/fork/electra/state_electra.go +++ b/shared/types/eth2/fork/electra/state_electra.go @@ -279,6 +279,10 @@ func (state *BeaconState) GetSlot() uint64 { return state.Slot } +func (state *BeaconState) GetPreviousEpochParticipation() []byte { + return state.PreviousEpochParticipation +} + // Added for compatibility func (state *BeaconState) BlockHeaderProof() ([][]byte, error) { return nil, nil diff --git a/shared/types/eth2/fork/fulu/state_fulu.go b/shared/types/eth2/fork/fulu/state_fulu.go index d28338ba3..64e848ac6 100644 --- a/shared/types/eth2/fork/fulu/state_fulu.go +++ b/shared/types/eth2/fork/fulu/state_fulu.go @@ -297,3 +297,7 @@ func (state *BeaconState) GetValidators() []*generic.Validator { func (state *BeaconState) GetSlot() uint64 { return state.Slot } + +func (state *BeaconState) GetPreviousEpochParticipation() []byte { + return state.PreviousEpochParticipation +} diff --git a/shared/types/eth2/types.go b/shared/types/eth2/types.go index 22e0fc671..d3f0076e6 100644 --- a/shared/types/eth2/types.go +++ b/shared/types/eth2/types.go @@ -31,6 +31,7 @@ type BeaconState interface { BlockRootProof(slot uint64) ([][]byte, error) BlockHeaderProof() ([][]byte, error) GetValidators() []*generic.Validator + GetPreviousEpochParticipation() []byte } type SignedBeaconBlock interface { diff --git a/shared/utils/cli/verify-performance/verify-performance.go b/shared/utils/cli/verify-performance/verify-performance.go new file mode 100644 index 000000000..00ae9fcda --- /dev/null +++ b/shared/utils/cli/verify-performance/verify-performance.go @@ -0,0 +1,97 @@ +// Package verifyperformance contains shared CLI helpers for the +// `rocketpool minipool verify-performance` and +// `rocketpool megapool verify-performance` commands. +package verifyperformance + +import ( + "fmt" + + "github.com/rocket-pool/smartnode/shared/services/rocketpool" + "github.com/rocket-pool/smartnode/shared/types/api" + "github.com/rocket-pool/smartnode/shared/utils/cli/prompt" +) + +// LargeEpochRangeWarning is the number of epochs above which the CLI prompts +// the user to confirm, because each epoch issues a committees fetch plus a +// few-dozen block fetches against the beacon node. +const LargeEpochRangeWarning uint64 = 256 + +// ResolveEpochRange fills in defaults for the start/length range. If epochs +// is 0, it defaults to the on-chain performance_period setting. Returns the +// inclusive [startEpoch, endEpoch] range. +func ResolveEpochRange(rp *rocketpool.Client, startEpoch, epochs uint64) (uint64, uint64, error) { + if epochs == 0 { + settings, err := rp.PDAOGetSettings() + if err != nil { + return 0, 0, fmt.Errorf("error fetching pDAO settings for default performance_period: %w", err) + } + epochs = settings.Performance.Period + if epochs == 0 { + return 0, 0, fmt.Errorf("on-chain performance_period is 0 and no --epochs was provided") + } + } + endEpoch := startEpoch + epochs - 1 + return startEpoch, endEpoch, nil +} + +// ConfirmLargeRange prompts the user when the requested epoch range exceeds +// LargeEpochRangeWarning and returns true if the user accepts the warning. +// Small ranges return true immediately. +func ConfirmLargeRange(total uint64) bool { + if total <= LargeEpochRangeWarning { + return true + } + return prompt.Confirm( + "This will fetch attestation data for %d epochs from your beacon node. "+ + "For historical epochs this requires an archival beacon node and may take a while. "+ + "Continue?", + total, + ) +} + +// PrintCancelled prints a generic cancellation message and returns nil so it +// can be returned directly from a CLI Action. +func PrintCancelled() error { + fmt.Println("Cancelled.") + return nil +} + +// PrintResult writes a VerifyPerformanceResponse to stdout in a human-readable +// format. `label` is the human-facing string identifying the validator being +// verified, e.g. "minipool 0x...". +func PrintResult(resp api.VerifyPerformanceResponse, label string) { + fmt.Printf("RPIP-73 target-vote performance for %s\n", label) + fmt.Printf(" Validator: %s (index %d)\n", resp.ValidatorPubkey.Hex(), resp.ValidatorIndex) + fmt.Printf(" Epoch range: %d - %d (inclusive, %d epochs)\n", resp.StartEpoch, resp.EndEpoch, resp.TotalEpochs) + fmt.Printf(" Timely target: %d epochs\n", resp.TimelyEpochs) + fmt.Printf(" Missed target: %d epochs\n", resp.MissedEpochs) + if resp.InactiveEpochs > 0 { + fmt.Printf(" Inactive: %d epochs (no committee assignment, excluded from %%)\n", resp.InactiveEpochs) + } + fmt.Printf(" Performance: %.2f%%\n", resp.PerformancePct) + fmt.Printf(" Threshold: %.2f%%\n", resp.PerformanceThresholdPct) + if resp.PassesThreshold { + fmt.Println(" Result: PASS (not exit-eligible under RPIP-73 with these parameters)") + } else { + fmt.Println(" Result: FAIL (exit-eligible under RPIP-73 with these parameters)") + } + + if len(resp.MissedEpochList) > 0 { + fmt.Printf("\nMissed target epochs (challengeable):\n") + printEpochList(resp.MissedEpochList) + } +} + +func printEpochList(epochs []uint64) { + const perLine = 8 + for i, e := range epochs { + if i%perLine == 0 { + if i > 0 { + fmt.Println() + } + fmt.Print(" ") + } + fmt.Printf("%d ", e) + } + fmt.Println() +} From 5cbb995f68a441d16062e81143321c6d910b30e4 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:31:54 -0300 Subject: [PATCH 04/35] Accept multiple validators --- rocketpool-cli/megapool/commands.go | 16 +- rocketpool-cli/megapool/verify-performance.go | 45 +- rocketpool-cli/minipool/commands.go | 15 +- rocketpool-cli/minipool/verify-performance.go | 41 +- rocketpool/api/megapool/verify-performance.go | 100 +++- rocketpool/api/minipool/verify-performance.go | 104 +++- .../performance/target-performance.go | 539 +++++++++++++----- shared/services/rocketpool/megapool.go | 27 +- shared/services/rocketpool/minipool.go | 21 +- shared/types/api/minipool.go | 20 + .../verify-performance/verify-performance.go | 62 +- 11 files changed, 772 insertions(+), 218 deletions(-) diff --git a/rocketpool-cli/megapool/commands.go b/rocketpool-cli/megapool/commands.go index 37628a5d2..f0719f8d8 100644 --- a/rocketpool-cli/megapool/commands.go +++ b/rocketpool-cli/megapool/commands.go @@ -434,10 +434,11 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { }, }, { - Name: "verify-performance", - Aliases: []string{"vp"}, - Usage: "Verify a megapool validator's RPIP-73 target-vote attestation performance over a range of epochs.", - UsageText: "rocketpool megapool verify-performance validator-id [options]", + Name: "verify-performance", + Aliases: []string{"vp"}, + Usage: "Verify the RPIP-73 target-vote attestation performance of one or more megapool validators over a range of epochs.", + UsageText: "rocketpool megapool verify-performance validator-ids [options]", + Description: "validator-ids is either a single validator id, a comma-separated list of validator ids, or 'all' to check every validator on the megapool.", Flags: []cli.Flag{ &cli.Uint64Flag{ Name: "start-epoch", @@ -464,13 +465,14 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { if err := cliutils.ValidateArgCount(c, 1); err != nil { return err } - validatorId, err := cliutils.ValidatePositiveUint32("validator-id", c.Args().Get(0)) - if err != nil { + targets := c.Args().Get(0) + if err := validateMegapoolTargets(targets); err != nil { return err } var megapoolAddr common.Address if c.IsSet("megapool") { + var err error megapoolAddr, err = cliutils.ValidateAddress("megapool", c.String("megapool")) if err != nil { return err @@ -479,7 +481,7 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { return verifyMegapoolPerformance( megapoolAddr, - validatorId, + targets, c.Uint64("start-epoch"), c.Uint64("epochs"), c.Bool("yes"), diff --git a/rocketpool-cli/megapool/verify-performance.go b/rocketpool-cli/megapool/verify-performance.go index ad017f7a1..ff005f5b4 100644 --- a/rocketpool-cli/megapool/verify-performance.go +++ b/rocketpool-cli/megapool/verify-performance.go @@ -2,14 +2,41 @@ package megapool import ( "fmt" + "strings" + "time" "github.com/ethereum/go-ethereum/common" "github.com/rocket-pool/smartnode/shared/services/rocketpool" + "github.com/rocket-pool/smartnode/shared/types/api" + cliutils "github.com/rocket-pool/smartnode/shared/utils/cli" verifyperf "github.com/rocket-pool/smartnode/shared/utils/cli/verify-performance" ) -func verifyMegapoolPerformance(megapoolAddress common.Address, validatorId uint32, startEpoch uint64, epochs uint64, yes bool) error { +// validateMegapoolTargets checks that the verify-performance targets argument +// is either "all" or a comma-separated list of valid validator ids. +func validateMegapoolTargets(targets string) error { + if strings.EqualFold(strings.TrimSpace(targets), "all") { + return nil + } + found := false + for _, raw := range strings.Split(targets, ",") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + if _, err := cliutils.ValidateUint32("validator-id", raw); err != nil { + return err + } + found = true + } + if !found { + return fmt.Errorf("no validator id provided; supply an id, a comma-separated list, or 'all'") + } + return nil +} + +func verifyMegapoolPerformance(megapoolAddress common.Address, targets string, startEpoch uint64, epochs uint64, yes bool) error { rp, err := rocketpool.NewClient().WithReady() if err != nil { return err @@ -24,15 +51,19 @@ func verifyMegapoolPerformance(megapoolAddress common.Address, validatorId uint3 return verifyperf.PrintCancelled() } - resp, err := rp.VerifyMegapoolValidatorPerformance(megapoolAddress, validatorId, startEpoch, endEpoch) + start := time.Now() + resp, err := rp.VerifyMegapoolValidatorPerformance(megapoolAddress, targets, startEpoch, endEpoch) if err != nil { return err } + elapsed := time.Since(start) - label := fmt.Sprintf("megapool validator %d", validatorId) - if (megapoolAddress != common.Address{}) { - label = fmt.Sprintf("megapool %s validator %d", megapoolAddress.Hex(), validatorId) - } - verifyperf.PrintResult(resp, label) + verifyperf.PrintBatchResults(resp, func(r api.VerifyPerformanceResult) string { + if (megapoolAddress != common.Address{}) { + return fmt.Sprintf("megapool %s validator %d", megapoolAddress.Hex(), r.ValidatorId) + } + return fmt.Sprintf("megapool validator %d", r.ValidatorId) + }) + verifyperf.PrintElapsed(elapsed) return nil } diff --git a/rocketpool-cli/minipool/commands.go b/rocketpool-cli/minipool/commands.go index 1c7f02340..6dd6cc9ce 100644 --- a/rocketpool-cli/minipool/commands.go +++ b/rocketpool-cli/minipool/commands.go @@ -327,10 +327,11 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { }, { - Name: "verify-performance", - Aliases: []string{"vp"}, - Usage: "Verify a minipool's RPIP-73 target-vote attestation performance over a range of epochs.", - UsageText: "rocketpool minipool verify-performance minipool-address [options]", + Name: "verify-performance", + Aliases: []string{"vp"}, + Usage: "Verify the RPIP-73 target-vote attestation performance of one or more minipools over a range of epochs.", + UsageText: "rocketpool minipool verify-performance minipools [options]", + Description: "minipools is either a single minipool address, a comma-separated list of minipool addresses, or 'all' to check every minipool owned by the node.", Flags: []cli.Flag{ &cli.Uint64Flag{ Name: "start-epoch", @@ -352,12 +353,12 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { if err := cliutils.ValidateArgCount(c, 1); err != nil { return err } - address, err := cliutils.ValidateAddress("minipool-address", c.Args().Get(0)) - if err != nil { + targets := c.Args().Get(0) + if err := validateMinipoolTargets(targets); err != nil { return err } return verifyMinipoolPerformance( - address, + targets, c.Uint64("start-epoch"), c.Uint64("epochs"), c.Bool("yes"), diff --git a/rocketpool-cli/minipool/verify-performance.go b/rocketpool-cli/minipool/verify-performance.go index a78a1e6bb..6ec7c2aaf 100644 --- a/rocketpool-cli/minipool/verify-performance.go +++ b/rocketpool-cli/minipool/verify-performance.go @@ -1,13 +1,40 @@ package minipool import ( - "github.com/ethereum/go-ethereum/common" + "fmt" + "strings" + "time" "github.com/rocket-pool/smartnode/shared/services/rocketpool" + "github.com/rocket-pool/smartnode/shared/types/api" + cliutils "github.com/rocket-pool/smartnode/shared/utils/cli" verifyperf "github.com/rocket-pool/smartnode/shared/utils/cli/verify-performance" ) -func verifyMinipoolPerformance(address common.Address, startEpoch uint64, epochs uint64, yes bool) error { +// validateMinipoolTargets checks that the verify-performance targets argument +// is either "all" or a comma-separated list of valid minipool addresses. +func validateMinipoolTargets(targets string) error { + if strings.EqualFold(strings.TrimSpace(targets), "all") { + return nil + } + found := false + for _, raw := range strings.Split(targets, ",") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + if _, err := cliutils.ValidateAddress("minipool address", raw); err != nil { + return err + } + found = true + } + if !found { + return fmt.Errorf("no minipool address provided; supply an address, a comma-separated list, or 'all'") + } + return nil +} + +func verifyMinipoolPerformance(targets string, startEpoch uint64, epochs uint64, yes bool) error { rp, err := rocketpool.NewClient().WithReady() if err != nil { return err @@ -22,10 +49,16 @@ func verifyMinipoolPerformance(address common.Address, startEpoch uint64, epochs return verifyperf.PrintCancelled() } - resp, err := rp.VerifyMinipoolPerformance(address, startEpoch, endEpoch) + start := time.Now() + resp, err := rp.VerifyMinipoolPerformance(targets, startEpoch, endEpoch) if err != nil { return err } - verifyperf.PrintResult(resp, "minipool "+address.Hex()) + elapsed := time.Since(start) + + verifyperf.PrintBatchResults(resp, func(r api.VerifyPerformanceResult) string { + return "minipool " + r.MinipoolAddress.Hex() + }) + verifyperf.PrintElapsed(elapsed) return nil } diff --git a/rocketpool/api/megapool/verify-performance.go b/rocketpool/api/megapool/verify-performance.go index 3142dc550..38a164d0f 100644 --- a/rocketpool/api/megapool/verify-performance.go +++ b/rocketpool/api/megapool/verify-performance.go @@ -2,32 +2,34 @@ package megapool import ( "fmt" + "strings" "github.com/ethereum/go-ethereum/common" "github.com/urfave/cli/v3" "github.com/rocket-pool/smartnode/bindings/megapool" "github.com/rocket-pool/smartnode/bindings/node" + rptypes "github.com/rocket-pool/smartnode/bindings/types" "github.com/rocket-pool/smartnode/rocketpool/api/response" "github.com/rocket-pool/smartnode/rocketpool/api/snroute" "github.com/rocket-pool/smartnode/shared/services" "github.com/rocket-pool/smartnode/shared/services/performance" "github.com/rocket-pool/smartnode/shared/types/api" + cliutils "github.com/rocket-pool/smartnode/shared/utils/cli" ) -// verifyPerformance computes a megapool validator's RPIP-73 target-vote -// performance over the inclusive epoch range [startEpoch, endEpoch]. -// -// If megapoolAddress is the zero address, the node's own megapool address is -// looked up via rocketNodeManager.getMegapoolAddress. +// verifyPerformance computes the RPIP-73 target-vote performance over the +// inclusive epoch range [startEpoch, endEpoch] for one or more validators of a +// megapool. If megapoolAddress is the zero address, the node's own megapool +// address is used func verifyPerformance( c *cli.Command, megapoolAddress common.Address, - validatorId uint32, + targets string, startEpoch uint64, endEpoch uint64, -) (*api.VerifyPerformanceResponse, error) { +) (*api.VerifyPerformanceBatchResponse, error) { if err := services.RequireBeaconClientSynced(c); err != nil { return nil, err } @@ -65,18 +67,88 @@ func verifyPerformance( if err != nil { return nil, fmt.Errorf("error creating megapool binding for %s: %w", megapoolAddress.Hex(), err) } - pubkey, err := mp.GetValidatorPubkey(validatorId, nil) + + validatorIds, err := resolveMegapoolTargets(mp, targets) if err != nil { - return nil, fmt.Errorf("error getting megapool %s validator %d pubkey: %w", megapoolAddress.Hex(), validatorId, err) + return nil, err + } + if len(validatorIds) == 0 { + return nil, fmt.Errorf("no megapool validators to verify") + } + + // Resolve each validator's pubkey up front. Per-validator pubkey failures + // are recorded and excluded from the beacon batch. + pubkeys := make([]rptypes.ValidatorPubkey, len(validatorIds)) + pubkeyErrs := make([]string, len(validatorIds)) + for i, validatorId := range validatorIds { + pubkey, err := mp.GetValidatorPubkey(validatorId, nil) + if err != nil { + pubkeyErrs[i] = fmt.Sprintf("error getting megapool %s validator %d pubkey: %s", megapoolAddress.Hex(), validatorId, err.Error()) + continue + } + pubkeys[i] = pubkey + } + + batch, err := performance.VerifyPerformanceBatch(rp, bc, pubkeys, startEpoch, endEpoch) + if err != nil { + return nil, err + } + + response := &api.VerifyPerformanceBatchResponse{ + Results: make([]api.VerifyPerformanceResult, 0, len(validatorIds)), + } + for i, validatorId := range validatorIds { + if !batch[i].Active { + continue + } + result := api.VerifyPerformanceResult{ValidatorId: validatorId} + switch { + case pubkeyErrs[i] != "": + result.Error = pubkeyErrs[i] + case batch[i].Err != nil: + result.Error = batch[i].Err.Error() + default: + result.Performance = batch[i].Response + } + response.Results = append(response.Results, result) } - return performance.VerifyPerformance(rp, bc, pubkey, startEpoch, endEpoch) + return response, nil +} + +// resolveMegapoolTargets turns the targets string into a concrete list of validator IDs +func resolveMegapoolTargets(mp megapool.Megapool, targets string) ([]uint32, error) { + if strings.EqualFold(strings.TrimSpace(targets), "all") { + count, err := mp.GetValidatorCount(nil) + if err != nil { + return nil, fmt.Errorf("error getting megapool validator count: %w", err) + } + ids := make([]uint32, 0, count) + for i := uint32(0); i < count; i++ { + ids = append(ids, i) + } + return ids, nil + } + + var ids []uint32 + for _, raw := range strings.Split(targets, ",") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + id, err := cliutils.ValidateUint32("validator-id", raw) + if err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, nil } func verifyPerformanceHandler(ctx snroute.Context) { - validatorId, err := parseUint32(ctx.Request, "validatorId") - if err != nil { - response.WriteErrorResponse(ctx.Writer, err) + targets := ctx.Request.FormValue("targets") + if targets == "" { + response.WriteErrorResponse(ctx.Writer, fmt.Errorf("missing required parameter 'targets'")) return } startEpoch, err := parseUint64(ctx.Request, "startEpoch") @@ -96,6 +168,6 @@ func verifyPerformanceHandler(ctx snroute.Context) { } else if raw := ctx.Request.FormValue("megapoolAddress"); raw != "" { megapoolAddr = common.HexToAddress(raw) } - resp, err := verifyPerformance(ctx.Command(), megapoolAddr, validatorId, startEpoch, endEpoch) + resp, err := verifyPerformance(ctx.Command(), megapoolAddr, targets, startEpoch, endEpoch) response.WriteResponse(ctx.Writer, resp, err) } diff --git a/rocketpool/api/minipool/verify-performance.go b/rocketpool/api/minipool/verify-performance.go index f492f7bec..c3cd28ab4 100644 --- a/rocketpool/api/minipool/verify-performance.go +++ b/rocketpool/api/minipool/verify-performance.go @@ -2,27 +2,32 @@ package minipool import ( "fmt" + "strings" "github.com/ethereum/go-ethereum/common" "github.com/urfave/cli/v3" "github.com/rocket-pool/smartnode/bindings/minipool" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + rptypes "github.com/rocket-pool/smartnode/bindings/types" "github.com/rocket-pool/smartnode/rocketpool/api/response" "github.com/rocket-pool/smartnode/rocketpool/api/snroute" "github.com/rocket-pool/smartnode/shared/services" "github.com/rocket-pool/smartnode/shared/services/performance" "github.com/rocket-pool/smartnode/shared/types/api" + cliutils "github.com/rocket-pool/smartnode/shared/utils/cli" ) -// verifyPerformance computes a minipool validator's RPIP-73 target-vote -// performance over the inclusive epoch range [startEpoch, endEpoch]. +// verifyPerformance computes the RPIP-73 target-vote performance over the +// inclusive epoch range [startEpoch, endEpoch] for one or more of the node's +// minipools func verifyPerformance( c *cli.Command, - minipoolAddress common.Address, + targets string, startEpoch uint64, endEpoch uint64, -) (*api.VerifyPerformanceResponse, error) { +) (*api.VerifyPerformanceBatchResponse, error) { if err := services.RequireBeaconClientSynced(c); err != nil { return nil, err } @@ -35,18 +40,95 @@ func verifyPerformance( return nil, err } - pubkey, err := minipool.GetMinipoolPubkey(rp, minipoolAddress, nil) + addresses, err := resolveMinipoolTargets(c, rp, targets) if err != nil { - return nil, fmt.Errorf("error getting minipool %s pubkey: %w", minipoolAddress.Hex(), err) + return nil, err + } + if len(addresses) == 0 { + return nil, fmt.Errorf("no minipools to verify") + } + + // Resolve each minipool's validator pubkey up front. Per-minipool pubkey + // failures are recorded and excluded from the beacon batch. + pubkeys := make([]rptypes.ValidatorPubkey, len(addresses)) + pubkeyErrs := make([]string, len(addresses)) + for i, address := range addresses { + pubkey, err := minipool.GetMinipoolPubkey(rp, address, nil) + if err != nil { + pubkeyErrs[i] = fmt.Sprintf("error getting minipool %s pubkey: %s", address.Hex(), err.Error()) + continue + } + pubkeys[i] = pubkey + } + + batch, err := performance.VerifyPerformanceBatch(rp, bc, pubkeys, startEpoch, endEpoch) + if err != nil { + return nil, err + } + + response := &api.VerifyPerformanceBatchResponse{ + Results: make([]api.VerifyPerformanceResult, 0, len(addresses)), + } + for i, address := range addresses { + if !batch[i].Active { + continue + } + result := api.VerifyPerformanceResult{MinipoolAddress: address} + switch { + case pubkeyErrs[i] != "": + result.Error = pubkeyErrs[i] + case batch[i].Err != nil: + result.Error = batch[i].Err.Error() + default: + result.Performance = batch[i].Response + } + response.Results = append(response.Results, result) } - return performance.VerifyPerformance(rp, bc, pubkey, startEpoch, endEpoch) + return response, nil +} + +// resolveMinipoolTargets turns the targets string ("all" or a comma-separated +// list of addresses) into a concrete list of minipool addresses +func resolveMinipoolTargets(c *cli.Command, rp *rocketpool.RocketPool, targets string) ([]common.Address, error) { + if strings.EqualFold(strings.TrimSpace(targets), "all") { + if err := services.RequireNodeRegistered(c); err != nil { + return nil, err + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + nodeAccount, err := w.GetNodeAccount() + if err != nil { + return nil, err + } + addresses, err := minipool.GetNodeMinipoolAddresses(rp, nodeAccount.Address, nil) + if err != nil { + return nil, fmt.Errorf("error getting node minipool addresses: %w", err) + } + return addresses, nil + } + + var addresses []common.Address + for _, raw := range strings.Split(targets, ",") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + address, err := cliutils.ValidateAddress("minipool address", raw) + if err != nil { + return nil, err + } + addresses = append(addresses, address) + } + return addresses, nil } func verifyPerformanceHandler(ctx snroute.Context) { - addr, err := parseAddress(ctx.Request, "address") - if err != nil { - response.WriteErrorResponse(ctx.Writer, err) + targets := ctx.Request.FormValue("targets") + if targets == "" { + response.WriteErrorResponse(ctx.Writer, fmt.Errorf("missing required parameter 'targets'")) return } startEpoch, err := parseUint64Param(ctx.Request, "startEpoch") @@ -59,6 +141,6 @@ func verifyPerformanceHandler(ctx snroute.Context) { response.WriteErrorResponse(ctx.Writer, err) return } - resp, err := verifyPerformance(ctx.Command(), addr, startEpoch, endEpoch) + resp, err := verifyPerformance(ctx.Command(), targets, startEpoch, endEpoch) response.WriteResponse(ctx.Writer, resp, err) } diff --git a/shared/services/performance/target-performance.go b/shared/services/performance/target-performance.go index 4942f036a..67425fb2f 100644 --- a/shared/services/performance/target-performance.go +++ b/shared/services/performance/target-performance.go @@ -16,6 +16,13 @@ // root) + up to SLOTS_PER_EPOCH block fetches (inclusion window). This is // orders of magnitude cheaper than fetching beacon states, and works against // any standard Beacon API node (archival is still required for old slots). +// +// When several validators are verified over the same epoch range in a single +// run, the per-epoch beacon data (target roots, committee assignments, and +// inclusion-window blocks) is identical for every validator. The epochCache +// type fetches each of those once and reuses them across all validators, so a +// batch of N validators over E epochs costs roughly the same beacon I/O as a +// single validator over E epochs instead of N times as much. package performance import ( @@ -91,19 +98,21 @@ type PerformanceBeaconClient interface { GetValidatorStatusByIndex(index string, opts *beacon.ValidatorStatusOptions) (beacon.ValidatorStatus, error) } -// pubkeyBeaconClient is the beacon client surface needed to resolve a -// validator pubkey to a beacon-chain index in addition to the engine -// requirements. +// pubkeyBeaconClient is the beacon client surface needed to resolve validator +// pubkeys to beacon-chain indices in addition to the engine requirements. type pubkeyBeaconClient interface { PerformanceBeaconClient GetValidatorIndex(pubkey rptypes.ValidatorPubkey) (string, error) + GetValidatorStatuses(pubkeys []rptypes.ValidatorPubkey, opts *beacon.ValidatorStatusOptions) (map[rptypes.ValidatorPubkey]beacon.ValidatorStatus, error) } -// VerifyPerformance is the end-to-end RPIP-73 target-vote verification flow -// shared by the minipool and megapool API endpoints. It resolves the -// validator's beacon-chain index from the supplied pubkey, runs -// CheckTargetPerformance, and packages the result alongside the pDAO -// performance_threshold for pass/fail reporting. +// VerifyPerformance is the end-to-end RPIP-73 target-vote verification flow for +// a single validator. It resolves the validator's beacon-chain index from the +// supplied pubkey, runs CheckTargetPerformance, and packages the result +// alongside the pDAO performance_threshold for pass/fail reporting. +// +// To verify several validators in one run, prefer VerifyPerformanceBatch, which +// shares per-epoch beacon data across all of them. func VerifyPerformance( rp *rocketpool.RocketPool, bc pubkeyBeaconClient, @@ -134,84 +143,156 @@ func VerifyPerformance( return nil, err } - // The performance_threshold setting is scaled by 1e18 (1e18 = 100%). - // thresholdWei, err := protocol.GetPerformanceThreshold(rp, nil) - // if err != nil { - // return nil, fmt.Errorf("error getting performance threshold: %w", err) - // } - // thresholdPct := eth.WeiToEth(thresholdWei) * 100 - // - // TODO: The rocketDAOProtocolSettingsPerformance contract is not yet deployed. - // Use the RPIP-73 initial value until it is. - thresholdPct := defaultPerformanceThresholdPct + return summaryToResponse(pubkey, summary), nil +} - return &api.VerifyPerformanceResponse{ - ValidatorPubkey: pubkey, - ValidatorIndex: summary.ValidatorIndex, - StartEpoch: summary.StartEpoch, - EndEpoch: summary.EndEpoch, - TotalEpochs: summary.TotalEpochs, - TimelyEpochs: summary.TimelyEpochs, - MissedEpochs: summary.MissedEpochs, - InactiveEpochs: summary.InactiveEpochs, - PerformancePct: summary.PerformancePct, - PerformanceThresholdPct: thresholdPct, - PassesThreshold: summary.PerformancePct >= thresholdPct, - MissedEpochList: summary.MissedEpochList, - TimelyEpochList: summary.TimelyEpochList, - }, nil +// BatchValidatorResult is one validator's outcome from VerifyPerformanceBatch. +// Exactly one of Response or Err is set: Response when the check succeeded, Err +// when that single validator could not be verified (the rest of the batch is +// unaffected). The slice returned by VerifyPerformanceBatch is aligned +// positionally with the input pubkeys, so callers can map results back to their +// own identifiers (minipool address, megapool validator id, etc). +type BatchValidatorResult struct { + Pubkey rptypes.ValidatorPubkey + // Active reports whether the validator is currently active on the beacon + // chain (activated and not yet exited) according to its live head status. + // Callers that target "all" validators use this to skip validators that are + // not actively attesting. + Active bool + Response *api.VerifyPerformanceResponse + Err error } -// CheckTargetPerformance evaluates a single validator's target-vote -// performance over the inclusive epoch range [startEpoch, endEpoch] by -// reading the canonical target root, the validator's committee assignment, -// and the attestations in the inclusion window per epoch. -func CheckTargetPerformance( - bc PerformanceBeaconClient, - cfg beacon.Eth2Config, - validatorIndex uint64, +// isActiveValidatorState reports whether a beacon validator state counts as +// actively attesting (activated on the beacon chain and not yet exited). +func isActiveValidatorState(state beacon.ValidatorState) bool { + switch state { + case beacon.ValidatorState_ActiveOngoing, + beacon.ValidatorState_ActiveExiting, + beacon.ValidatorState_ActiveSlashed: + return true + default: + return false + } +} + +// VerifyPerformanceBatch verifies the RPIP-73 target-vote performance of many +// validators over the same inclusive epoch range in a single pass. All +// per-epoch beacon data (target roots, committee assignments, inclusion-window +// blocks) is fetched once via a shared epochCache and reused for every +// validator, and the validators' indices and statuses are resolved in a single +// batched beacon call. +// +// The returned slice is positionally aligned with pubkeys. A fatal error +// (returned as the second value) only occurs for failures that prevent the +// whole batch from running, such as being unable to read the beacon config or +// resolve any validator statuses; per-validator problems are reported in each +// entry's Err field instead. +func VerifyPerformanceBatch( + rp *rocketpool.RocketPool, + bc pubkeyBeaconClient, + pubkeys []rptypes.ValidatorPubkey, startEpoch uint64, endEpoch uint64, -) (*PerformanceSummary, error) { +) ([]BatchValidatorResult, error) { if endEpoch < startEpoch { return nil, fmt.Errorf("end epoch %d is before start epoch %d", endEpoch, startEpoch) } + + cfg, err := bc.GetEth2Config() + if err != nil { + return nil, fmt.Errorf("error getting beacon config: %w", err) + } if cfg.SlotsPerEpoch == 0 { return nil, fmt.Errorf("invalid beacon config: SlotsPerEpoch is 0") } - summary := &PerformanceSummary{ - ValidatorIndex: validatorIndex, - StartEpoch: startEpoch, - EndEpoch: endEpoch, - TotalEpochs: endEpoch - startEpoch + 1, - MissedEpochList: []uint64{}, - TimelyEpochList: []uint64{}, + // Resolve every non-zero pubkey to its index and status in a single call. + uniquePubkeys := make([]rptypes.ValidatorPubkey, 0, len(pubkeys)) + seen := map[rptypes.ValidatorPubkey]struct{}{} + for _, pk := range pubkeys { + if pk == (rptypes.ValidatorPubkey{}) { + continue + } + if _, ok := seen[pk]; ok { + continue + } + seen[pk] = struct{}{} + uniquePubkeys = append(uniquePubkeys, pk) } - for epoch := startEpoch; epoch <= endEpoch; epoch++ { - state, err := checkEpoch(bc, cfg, validatorIndex, epoch) + statusByPubkey := map[rptypes.ValidatorPubkey]beacon.ValidatorStatus{} + if len(uniquePubkeys) > 0 { + statusByPubkey, err = bc.GetValidatorStatuses(uniquePubkeys, nil) if err != nil { - return nil, fmt.Errorf("error checking epoch %d: %w", epoch, err) + return nil, fmt.Errorf("error resolving validator statuses: %w", err) } - switch state { - case epochResultTimely: - summary.TimelyEpochs++ - summary.TimelyEpochList = append(summary.TimelyEpochList, epoch) - case epochResultMissed: - summary.MissedEpochs++ - summary.MissedEpochList = append(summary.MissedEpochList, epoch) - case epochResultInactive: - summary.InactiveEpochs++ + } + + // Build the set of indices to track and a pre-populated status map keyed by + // index string so the cache never has to re-query a status. + indexSet := map[string]struct{}{} + statusByIndex := map[string]beacon.ValidatorStatus{} + for _, status := range statusByPubkey { + if !status.Exists || status.Index == "" { + continue } + indexSet[status.Index] = struct{}{} + statusByIndex[status.Index] = status } - activeEpochs := summary.TimelyEpochs + summary.MissedEpochs - if activeEpochs > 0 { - summary.PerformancePct = float64(summary.TimelyEpochs) / float64(activeEpochs) * 100.0 + cache := newEpochCache(bc, cfg, indexSet, statusByIndex) + + results := make([]BatchValidatorResult, len(pubkeys)) + for i, pk := range pubkeys { + results[i].Pubkey = pk + if pk == (rptypes.ValidatorPubkey{}) { + results[i].Err = fmt.Errorf("validator has no pubkey on-chain yet (not deposited?)") + continue + } + status, ok := statusByPubkey[pk] + if !ok || !status.Exists || status.Index == "" { + results[i].Err = fmt.Errorf("validator %s not found on the beacon chain yet", pk.Hex()) + continue + } + results[i].Active = isActiveValidatorState(status.Status) + indexU64, err := strconv.ParseUint(status.Index, 10, 64) + if err != nil { + results[i].Err = fmt.Errorf("error parsing validator index %q: %w", status.Index, err) + continue + } + summary, err := cache.computeSummary(status.Index, indexU64, startEpoch, endEpoch) + if err != nil { + results[i].Err = err + continue + } + results[i].Response = summaryToResponse(pk, summary) } - return summary, nil + return results, nil +} + +// CheckTargetPerformance evaluates a single validator's target-vote +// performance over the inclusive epoch range [startEpoch, endEpoch] by +// reading the canonical target root, the validator's committee assignment, +// and the attestations in the inclusion window per epoch. +func CheckTargetPerformance( + bc PerformanceBeaconClient, + cfg beacon.Eth2Config, + validatorIndex uint64, + startEpoch uint64, + endEpoch uint64, +) (*PerformanceSummary, error) { + if endEpoch < startEpoch { + return nil, fmt.Errorf("end epoch %d is before start epoch %d", endEpoch, startEpoch) + } + if cfg.SlotsPerEpoch == 0 { + return nil, fmt.Errorf("invalid beacon config: SlotsPerEpoch is 0") + } + + indexStr := strconv.FormatUint(validatorIndex, 10) + cache := newEpochCache(bc, cfg, map[string]struct{}{indexStr: {}}, nil) + return cache.computeSummary(indexStr, validatorIndex, startEpoch, endEpoch) } // CheckEpochTargetVote returns true if the validator made a timely target @@ -229,7 +310,9 @@ func CheckEpochTargetVote( validatorIndex uint64, epoch uint64, ) (bool, error) { - state, err := checkEpoch(bc, cfg, validatorIndex, epoch) + indexStr := strconv.FormatUint(validatorIndex, 10) + cache := newEpochCache(bc, cfg, map[string]struct{}{indexStr: {}}, nil) + state, err := cache.evaluateEpoch(indexStr, validatorIndex, epoch) if err != nil { return false, err } @@ -254,52 +337,132 @@ type attestationDuty struct { committeeSizesAtDay map[uint64]int // committee_index -> validator count, for all committees at duty.slot } -// checkEpoch performs the per-epoch evaluation. It returns epochResultTimely -// if a matching, timely target vote was found for the validator; -// epochResultMissed if the validator had a duty but no matching attestation -// landed; epochResultInactive if the validator had no committee assignment -// for the epoch. -func checkEpoch( +// cachedBlock memoizes a single GetBeaconBlock lookup, including the "missing" +// (skipped slot) case. +type cachedBlock struct { + block beacon.BeaconBlock + exists bool +} + +// rootResult memoizes a target-root resolution, including its error. +type rootResult struct { + root common.Hash + err error +} + +// epochCache fetches and memoizes the per-epoch beacon data needed for +// target-vote verification so it can be shared across many validators in a +// single run. It is not safe for concurrent use. +type epochCache struct { + bc PerformanceBeaconClient + cfg beacon.Eth2Config + indexSet map[string]struct{} + + targetRoots map[uint64]rootResult // epoch -> target root + epochDuties map[uint64]map[string]attestationDuty // epoch -> validator index string -> duty + blocks map[uint64]cachedBlock // slot -> block + statuses map[string]beacon.ValidatorStatus // validator index string -> status +} + +// newEpochCache creates a cache that tracks the supplied validator index set. +// statuses may be nil; when provided it pre-populates validator statuses (keyed +// by index string) so the cache never has to query them again for inactive +// detection. +func newEpochCache( bc PerformanceBeaconClient, cfg beacon.Eth2Config, - validatorIndex uint64, - epoch uint64, -) (epochResult, error) { - // Step 1: resolve the canonical target block root for the epoch. This is - // the canonical block root at slot epoch*SlotsPerEpoch, walking back if - // the boundary slot was skipped. - targetRoot, err := resolveTargetRoot(bc, cfg, epoch) - if err != nil { - return epochResultMissed, err + indexSet map[string]struct{}, + statuses map[string]beacon.ValidatorStatus, +) *epochCache { + if statuses == nil { + statuses = map[string]beacon.ValidatorStatus{} + } + return &epochCache{ + bc: bc, + cfg: cfg, + indexSet: indexSet, + targetRoots: map[uint64]rootResult{}, + epochDuties: map[uint64]map[string]attestationDuty{}, + blocks: map[uint64]cachedBlock{}, + statuses: statuses, + } +} + +// computeSummary evaluates one validator over the inclusive epoch range using +// the shared cache. +func (c *epochCache) computeSummary(indexStr string, indexU64 uint64, startEpoch, endEpoch uint64) (*PerformanceSummary, error) { + summary := &PerformanceSummary{ + ValidatorIndex: indexU64, + StartEpoch: startEpoch, + EndEpoch: endEpoch, + TotalEpochs: endEpoch - startEpoch + 1, + MissedEpochList: []uint64{}, + TimelyEpochList: []uint64{}, + } + + for epoch := startEpoch; epoch <= endEpoch; epoch++ { + state, err := c.evaluateEpoch(indexStr, indexU64, epoch) + if err != nil { + return nil, fmt.Errorf("error checking epoch %d: %w", epoch, err) + } + switch state { + case epochResultTimely: + summary.TimelyEpochs++ + summary.TimelyEpochList = append(summary.TimelyEpochList, epoch) + case epochResultMissed: + summary.MissedEpochs++ + summary.MissedEpochList = append(summary.MissedEpochList, epoch) + case epochResultInactive: + summary.InactiveEpochs++ + } + } + + activeEpochs := summary.TimelyEpochs + summary.MissedEpochs + if activeEpochs > 0 { + summary.PerformancePct = float64(summary.TimelyEpochs) / float64(activeEpochs) * 100.0 } - // Step 2: find the validator's attestation duty for the epoch by walking - // the committees response and matching against validatorIndex. - duty, found, err := findAttestationDuty(bc, epoch, validatorIndex) + return summary, nil +} + +// evaluateEpoch performs the per-epoch evaluation for a single validator. It +// returns epochResultTimely if a matching, timely target vote was found; +// epochResultMissed if the validator had a duty but no matching attestation +// landed; epochResultInactive if the validator had no committee assignment for +// the epoch (and was not required to attest). +func (c *epochCache) evaluateEpoch(indexStr string, indexU64 uint64, epoch uint64) (epochResult, error) { + // Find the validator's attestation duty for the epoch from the (cached) + // committees. + duty, found, err := c.dutyFor(epoch, indexStr) if err != nil { return epochResultMissed, err } if !found { - indexStr := strconv.FormatUint(validatorIndex, 10) - status, err := bc.GetValidatorStatusByIndex(indexStr, nil) + status, err := c.status(indexStr) if err != nil { - return epochResultMissed, fmt.Errorf("error getting validator status for index %d: %w", validatorIndex, err) + return epochResultMissed, fmt.Errorf("error getting validator status for index %s: %w", indexStr, err) } if !validatorHadAttestationDuty(status, epoch) { return epochResultInactive, nil } return epochResultMissed, fmt.Errorf( - "validator index %d was active in epoch %d but was not found in attestation committees; ensure your beacon node provides historical committee data (archival node required)", - validatorIndex, epoch, + "validator index %s was active in epoch %d but was not found in attestation committees; ensure your beacon node provides historical committee data (archival node required)", + indexStr, epoch, ) } - // Step 3: scan the inclusion window for a matching attestation. The - // inclusion window for the target flag is up to SLOTS_PER_EPOCH slots - // after the duty slot. Return as soon as we find a match. - inclusionEndExclusive := duty.slot + 1 + cfg.SlotsPerEpoch + // Resolve the canonical target block root for the epoch (cached, shared). + targetRoot, err := c.targetRoot(epoch) + if err != nil { + return epochResultMissed, err + } + + // Scan the inclusion window for a matching attestation. The inclusion + // window for the target flag is up to SLOTS_PER_EPOCH slots after the duty + // slot. Return as soon as we find a match. + inclusionEndExclusive := duty.slot + 1 + c.cfg.SlotsPerEpoch for slot := duty.slot + 1; slot < inclusionEndExclusive; slot++ { - block, exists, err := bc.GetBeaconBlock(strconv.FormatUint(slot, 10)) + block, exists, err := c.block(slot) if err != nil { return epochResultMissed, fmt.Errorf("error getting block at slot %d: %w", slot, err) } @@ -314,75 +477,132 @@ func checkEpoch( return epochResultMissed, nil } -// resolveTargetRoot returns the canonical block root for epoch E, i.e. -// get_block_root(state, E) = canonical block root at slot E*SlotsPerEpoch -// (walking back if the boundary slot was skipped). -func resolveTargetRoot(bc PerformanceBeaconClient, cfg beacon.Eth2Config, epoch uint64) (common.Hash, error) { - boundarySlot := epoch * cfg.SlotsPerEpoch - for attempt := uint64(0); attempt < maxTargetRootWalkback; attempt++ { - if attempt > boundarySlot { - break - } - slot := boundarySlot - attempt - header, exists, err := bc.GetBeaconBlockHeader(strconv.FormatUint(slot, 10)) - if err != nil { - return common.Hash{}, fmt.Errorf("error getting beacon block header at slot %d: %w", slot, err) - } - if exists { - return header.Root, nil - } +// targetRoot returns the canonical block root for epoch E, memoized per epoch. +func (c *epochCache) targetRoot(epoch uint64) (common.Hash, error) { + if r, ok := c.targetRoots[epoch]; ok { + return r.root, r.err } - return common.Hash{}, fmt.Errorf("could not find a non-skipped slot within %d slots of epoch %d boundary to resolve target root", maxTargetRootWalkback, epoch) + root, err := resolveTargetRoot(c.bc, c.cfg, epoch) + c.targetRoots[epoch] = rootResult{root: root, err: err} + return root, err +} + +// block returns the beacon block at the given slot, memoized per slot. The +// returned bool reports whether a block exists at that slot (false for a +// skipped slot). +func (c *epochCache) block(slot uint64) (beacon.BeaconBlock, bool, error) { + if b, ok := c.blocks[slot]; ok { + return b.block, b.exists, nil + } + block, exists, err := c.bc.GetBeaconBlock(strconv.FormatUint(slot, 10)) + if err != nil { + return beacon.BeaconBlock{}, false, err + } + c.blocks[slot] = cachedBlock{block: block, exists: exists} + return block, exists, nil +} + +// status returns the validator status for the given index string, memoized and +// using any pre-populated statuses first. +func (c *epochCache) status(indexStr string) (beacon.ValidatorStatus, error) { + if status, ok := c.statuses[indexStr]; ok { + return status, nil + } + status, err := c.bc.GetValidatorStatusByIndex(indexStr, nil) + if err != nil { + return beacon.ValidatorStatus{}, err + } + c.statuses[indexStr] = status + return status, nil +} + +// dutyFor returns the requested validator's attestation duty for the epoch, +// building (and caching) the duty map for all tracked indices on first access. +func (c *epochCache) dutyFor(epoch uint64, indexStr string) (attestationDuty, bool, error) { + if err := c.ensureEpochDuties(epoch); err != nil { + return attestationDuty{}, false, err + } + duty, found := c.epochDuties[epoch][indexStr] + return duty, found, nil } -// findAttestationDuty walks the committees response for the epoch and -// returns the (slot, committee_index, position) assignment of the requested -// validator, plus the committee-size map for the duty slot (needed to compute -// the aggregation-bits offset for post-Electra attestations). -func findAttestationDuty(bc PerformanceBeaconClient, epoch uint64, validatorIndex uint64) (attestationDuty, bool, error) { - committees, err := bc.GetCommitteesForEpoch(&epoch) +// ensureEpochDuties fetches the committees for the epoch once and, in a single +// pass, records the attestation duties of every tracked validator index along +// with the committee sizes needed to compute aggregation-bits offsets. +func (c *epochCache) ensureEpochDuties(epoch uint64) error { + if _, ok := c.epochDuties[epoch]; ok { + return nil + } + + committees, err := c.bc.GetCommitteesForEpoch(&epoch) if err != nil { - return attestationDuty{}, false, fmt.Errorf("error getting committees for epoch %d: %w", epoch, err) + return fmt.Errorf("error getting committees for epoch %d: %w", epoch, err) } defer committees.Release() - indexStr := strconv.FormatUint(validatorIndex, 10) + duties := map[string]attestationDuty{} + // slot -> committee_index -> validator count, for every committee in the + // epoch. Needed because post-Electra aggregation_bits pack multiple + // committees per slot, so the offset of a validator depends on the sizes of + // the lower-indexed committees at its slot. + slotSizes := map[uint64]map[uint64]int{} - // First pass: locate the validator's duty. - var duty attestationDuty - found := false for i := 0; i < committees.Count(); i++ { + slot := committees.Slot(i) + committeeIndex := committees.Index(i) validators := committees.Validators(i) + + if slotSizes[slot] == nil { + slotSizes[slot] = map[uint64]int{} + } + slotSizes[slot][committeeIndex] = len(validators) + + // Only bother matching when there are still tracked validators left to + // find that could be in this committee. for pos, vIdx := range validators { - if vIdx == indexStr { - duty.slot = committees.Slot(i) - duty.committeeIndex = committees.Index(i) - duty.position = pos - found = true - break + if _, tracked := c.indexSet[vIdx]; !tracked { + continue + } + if _, already := duties[vIdx]; already { + continue + } + duties[vIdx] = attestationDuty{ + slot: slot, + committeeIndex: committeeIndex, + position: pos, } - } - if found { - break } } - if !found { - return attestationDuty{}, false, nil + // Attach the committee-size map for each duty's slot. + for vIdx, duty := range duties { + duty.committeeSizesAtDay = slotSizes[duty.slot] + duties[vIdx] = duty } - // Second pass: collect committee sizes for all committees at duty.slot - // so that ValidatorAttested can compute the correct aggregation-bits - // offset under post-Electra attestation aggregation. - duty.committeeSizesAtDay = map[uint64]int{} - for i := 0; i < committees.Count(); i++ { - if committees.Slot(i) != duty.slot { - continue + c.epochDuties[epoch] = duties + return nil +} + +// resolveTargetRoot returns the canonical block root for epoch E, i.e. +// get_block_root(state, E) = canonical block root at slot E*SlotsPerEpoch +// (walking back if the boundary slot was skipped). +func resolveTargetRoot(bc PerformanceBeaconClient, cfg beacon.Eth2Config, epoch uint64) (common.Hash, error) { + boundarySlot := epoch * cfg.SlotsPerEpoch + for attempt := uint64(0); attempt < maxTargetRootWalkback; attempt++ { + if attempt > boundarySlot { + break + } + slot := boundarySlot - attempt + header, exists, err := bc.GetBeaconBlockHeader(strconv.FormatUint(slot, 10)) + if err != nil { + return common.Hash{}, fmt.Errorf("error getting beacon block header at slot %d: %w", slot, err) + } + if exists { + return header.Root, nil } - duty.committeeSizesAtDay[committees.Index(i)] = committees.ValidatorCount(i) } - - return duty, true, nil + return common.Hash{}, fmt.Errorf("could not find a non-skipped slot within %d slots of epoch %d boundary to resolve target root", maxTargetRootWalkback, epoch) } // matchesDuty returns true if any attestation in atts is a timely-target @@ -435,3 +655,34 @@ func validatorHadAttestationDuty(status beacon.ValidatorStatus, epoch uint64) bo } return true } + +// summaryToResponse packages a PerformanceSummary into the API response, +// attaching the pDAO performance_threshold and pass/fail verdict. +func summaryToResponse(pubkey rptypes.ValidatorPubkey, summary *PerformanceSummary) *api.VerifyPerformanceResponse { + // The performance_threshold setting is scaled by 1e18 (1e18 = 100%). + // thresholdWei, err := protocol.GetPerformanceThreshold(rp, nil) + // if err != nil { + // return nil, fmt.Errorf("error getting performance threshold: %w", err) + // } + // thresholdPct := eth.WeiToEth(thresholdWei) * 100 + // + // TODO: The rocketDAOProtocolSettingsPerformance contract is not yet deployed. + // Use the RPIP-73 initial value until it is. + thresholdPct := defaultPerformanceThresholdPct + + return &api.VerifyPerformanceResponse{ + ValidatorPubkey: pubkey, + ValidatorIndex: summary.ValidatorIndex, + StartEpoch: summary.StartEpoch, + EndEpoch: summary.EndEpoch, + TotalEpochs: summary.TotalEpochs, + TimelyEpochs: summary.TimelyEpochs, + MissedEpochs: summary.MissedEpochs, + InactiveEpochs: summary.InactiveEpochs, + PerformancePct: summary.PerformancePct, + PerformanceThresholdPct: thresholdPct, + PassesThreshold: summary.PerformancePct >= thresholdPct, + MissedEpochList: summary.MissedEpochList, + TimelyEpochList: summary.TimelyEpochList, + } +} diff --git a/shared/services/rocketpool/megapool.go b/shared/services/rocketpool/megapool.go index 283810f76..45df550c7 100644 --- a/shared/services/rocketpool/megapool.go +++ b/shared/services/rocketpool/megapool.go @@ -14,30 +14,31 @@ import ( ) // VerifyMegapoolValidatorPerformance computes RPIP-73 target-vote performance -// for a megapool validator over [startEpoch, endEpoch]. If megapoolAddress is -// the zero address the daemon resolves the node's own megapool. This call has -// no client-side deadline because each epoch requires a full Beacon State SSZ -// download, which can take several minutes per epoch on an archival beacon -// node. -func (c *Client) VerifyMegapoolValidatorPerformance(megapoolAddress common.Address, validatorId uint32, startEpoch, endEpoch uint64) (api.VerifyPerformanceResponse, error) { +// over [startEpoch, endEpoch] for one or more validators of a megapool. If +// megapoolAddress is the zero address the daemon resolves the node's own +// megapool. targets is either the literal "all" or a comma-separated list of +// validator IDs. This call has no client-side deadline because each epoch +// requires fetching attestation data that can take a while per epoch on an +// archival beacon node. +func (c *Client) VerifyMegapoolValidatorPerformance(megapoolAddress common.Address, targets string, startEpoch, endEpoch uint64) (api.VerifyPerformanceBatchResponse, error) { values := url.Values{ - "validatorId": {strconv.FormatUint(uint64(validatorId), 10)}, - "startEpoch": {strconv.FormatUint(startEpoch, 10)}, - "endEpoch": {strconv.FormatUint(endEpoch, 10)}, + "targets": {targets}, + "startEpoch": {strconv.FormatUint(startEpoch, 10)}, + "endEpoch": {strconv.FormatUint(endEpoch, 10)}, } if (megapoolAddress != common.Address{}) { values.Set("megapoolAddress", megapoolAddress.Hex()) } responseBytes, err := c.callHTTPAPICtx(context.Background(), "GET", "/api/megapool/verify-performance", values) if err != nil { - return api.VerifyPerformanceResponse{}, fmt.Errorf("Could not verify megapool validator performance: %w", err) + return api.VerifyPerformanceBatchResponse{}, fmt.Errorf("Could not verify megapool validator performance: %w", err) } - var response api.VerifyPerformanceResponse + var response api.VerifyPerformanceBatchResponse if err := json.Unmarshal(responseBytes, &response); err != nil { - return api.VerifyPerformanceResponse{}, fmt.Errorf("Could not decode verify-performance response: %w", err) + return api.VerifyPerformanceBatchResponse{}, fmt.Errorf("Could not decode verify-performance response: %w", err) } if response.Error != "" { - return api.VerifyPerformanceResponse{}, fmt.Errorf("Could not verify megapool validator performance: %s", response.Error) + return api.VerifyPerformanceBatchResponse{}, fmt.Errorf("Could not verify megapool validator performance: %s", response.Error) } return response, nil } diff --git a/shared/services/rocketpool/minipool.go b/shared/services/rocketpool/minipool.go index 1083e6478..3c58fc4d8 100644 --- a/shared/services/rocketpool/minipool.go +++ b/shared/services/rocketpool/minipool.go @@ -173,25 +173,26 @@ func (c *Client) GetMinipoolRescueDissolvedDetailsForNode() (api.GetMinipoolResc return c.callAPI[api.GetMinipoolRescueDissolvedDetailsForNodeResponse]("GET", "/api/minipool/get-rescue-dissolved-details-for-node", nil, "Could not get get-minipool-rescue-dissolved-details-for-node status") } -// VerifyMinipoolPerformance computes RPIP-73 target-vote performance for a -// minipool's validator over [startEpoch, endEpoch]. This call has no client- -// side deadline because each epoch requires a full Beacon State SSZ download, -// which can take several minutes per epoch on an archival beacon node. -func (c *Client) VerifyMinipoolPerformance(address common.Address, startEpoch, endEpoch uint64) (api.VerifyPerformanceResponse, error) { +// VerifyMinipoolPerformance computes RPIP-73 target-vote performance over +// [startEpoch, endEpoch] for one or more of the node's minipools. targets is +// either the literal "all" or a comma-separated list of minipool addresses. +// This call has no client-side deadline because each epoch requires fetching +// attestation data that can take a while per epoch on an archival beacon node. +func (c *Client) VerifyMinipoolPerformance(targets string, startEpoch, endEpoch uint64) (api.VerifyPerformanceBatchResponse, error) { responseBytes, err := c.callHTTPAPICtx(context.Background(), "GET", "/api/minipool/verify-performance", url.Values{ - "address": {address.Hex()}, + "targets": {targets}, "startEpoch": {strconv.FormatUint(startEpoch, 10)}, "endEpoch": {strconv.FormatUint(endEpoch, 10)}, }) if err != nil { - return api.VerifyPerformanceResponse{}, fmt.Errorf("Could not verify minipool performance: %w", err) + return api.VerifyPerformanceBatchResponse{}, fmt.Errorf("Could not verify minipool performance: %w", err) } - var response api.VerifyPerformanceResponse + var response api.VerifyPerformanceBatchResponse if err := json.Unmarshal(responseBytes, &response); err != nil { - return api.VerifyPerformanceResponse{}, fmt.Errorf("Could not decode verify-performance response: %w", err) + return api.VerifyPerformanceBatchResponse{}, fmt.Errorf("Could not decode verify-performance response: %w", err) } if response.Error != "" { - return api.VerifyPerformanceResponse{}, fmt.Errorf("Could not verify minipool performance: %s", response.Error) + return api.VerifyPerformanceBatchResponse{}, fmt.Errorf("Could not verify minipool performance: %s", response.Error) } return response, nil } diff --git a/shared/types/api/minipool.go b/shared/types/api/minipool.go index c764a0d63..1bbc7bef6 100644 --- a/shared/types/api/minipool.go +++ b/shared/types/api/minipool.go @@ -321,3 +321,23 @@ type VerifyPerformanceResponse struct { MissedEpochList []uint64 `json:"missedEpochList"` TimelyEpochList []uint64 `json:"timelyEpochList"` } + +// VerifyPerformanceResult is one validator's entry in a batch verify-performance +// response. Either MinipoolAddress (for minipools) or ValidatorId (for megapool +// validators) identifies the target. Performance holds the result when the +// check succeeded; Error is populated instead when that single target failed so +// one bad target does not abort the rest of the batch. +type VerifyPerformanceResult struct { + MinipoolAddress common.Address `json:"minipoolAddress"` + ValidatorId uint32 `json:"validatorId"` + Performance *VerifyPerformanceResponse `json:"performance"` + Error string `json:"error"` +} + +// VerifyPerformanceBatchResponse reports RPIP-73 target-vote performance for one +// or more validators verified in a single run. +type VerifyPerformanceBatchResponse struct { + Status string `json:"status"` + Error string `json:"error"` + Results []VerifyPerformanceResult `json:"results"` +} diff --git a/shared/utils/cli/verify-performance/verify-performance.go b/shared/utils/cli/verify-performance/verify-performance.go index 00ae9fcda..faa0fd7e8 100644 --- a/shared/utils/cli/verify-performance/verify-performance.go +++ b/shared/utils/cli/verify-performance/verify-performance.go @@ -5,6 +5,7 @@ package verifyperformance import ( "fmt" + "time" "github.com/rocket-pool/smartnode/shared/services/rocketpool" "github.com/rocket-pool/smartnode/shared/types/api" @@ -36,7 +37,6 @@ func ResolveEpochRange(rp *rocketpool.Client, startEpoch, epochs uint64) (uint64 // ConfirmLargeRange prompts the user when the requested epoch range exceeds // LargeEpochRangeWarning and returns true if the user accepts the warning. -// Small ranges return true immediately. func ConfirmLargeRange(total uint64) bool { if total <= LargeEpochRangeWarning { return true @@ -56,6 +56,12 @@ func PrintCancelled() error { return nil } +// PrintElapsed prints the wall-clock time the verification took, rounded to the +// millisecond. +func PrintElapsed(elapsed time.Duration) { + fmt.Printf("\nCompleted in %s\n", elapsed.Round(time.Millisecond)) +} + // PrintResult writes a VerifyPerformanceResponse to stdout in a human-readable // format. `label` is the human-facing string identifying the validator being // verified, e.g. "minipool 0x...". @@ -82,6 +88,60 @@ func PrintResult(resp api.VerifyPerformanceResponse, label string) { } } +// PrintBatchResults writes a batch of verify-performance results to stdout. For +// each result it prints the per-validator detail (or the per-validator error), +// then a closing summary. labelFor produces the human-facing label for a +// result, e.g. "minipool 0x..." or "megapool validator 3". +func PrintBatchResults(resp api.VerifyPerformanceBatchResponse, labelFor func(api.VerifyPerformanceResult) string) { + var passed int + var failedLabels []string + var erroredLabels []string + for i, result := range resp.Results { + if i > 0 { + fmt.Println() + } + label := labelFor(result) + if result.Error != "" { + fmt.Printf("RPIP-73 target-vote performance for %s\n", label) + fmt.Printf(" Error: %s\n", result.Error) + erroredLabels = append(erroredLabels, label) + continue + } + if result.Performance == nil { + fmt.Printf("RPIP-73 target-vote performance for %s\n", label) + fmt.Printf(" Error: no result returned\n") + erroredLabels = append(erroredLabels, label) + continue + } + PrintResult(*result.Performance, label) + if result.Performance.PassesThreshold { + passed++ + } else { + failedLabels = append(failedLabels, label) + } + } + + if len(resp.Results) > 1 { + fmt.Printf("\nSummary: %d validator(s) checked - %d pass, %d fail, %d errored\n", + len(resp.Results), passed, len(failedLabels), len(erroredLabels)) + } + + // List the validators that failed (and any that errored) at the very end so + // they are easy to spot without scrolling back through every result. + if len(failedLabels) > 0 { + fmt.Printf("\nFailed validators (exit-eligible under RPIP-73):\n") + for _, label := range failedLabels { + fmt.Printf(" - %s\n", label) + } + } + if len(erroredLabels) > 0 { + fmt.Printf("\nErrored validators (could not be verified):\n") + for _, label := range erroredLabels { + fmt.Printf(" - %s\n", label) + } + } +} + func printEpochList(epochs []uint64) { const perLine = 8 for i, e := range epochs { From b3b40e5db90d3c391e6d48eb86ccb9f7e38423d3 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:52:28 -0300 Subject: [PATCH 05/35] Add saturn2deployed to the state --- shared/services/state/network-state.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/shared/services/state/network-state.go b/shared/services/state/network-state.go index 7dc2b70b9..a4b844e56 100644 --- a/shared/services/state/network-state.go +++ b/shared/services/state/network-state.go @@ -103,6 +103,9 @@ type NetworkState struct { // Protocol DAO proposals ProtocolDaoProposalDetails []protocol.ProtocolDaoProposalDetails `json:"protocol_dao_proposal_details,omitempty"` + + // Saturn 2 deployed + Saturn2Deployed bool `json:"saturn2_deployed"` } func (s NetworkState) MarshalJSON() ([]byte, error) { @@ -523,6 +526,12 @@ func (m *NetworkStateManager) createNetworkState(slotNumber uint64, nodeAddresse currentStep++ m.logLine("%d/%d - Retrieved Protocol DAO proposals (total time: %s)", currentStep, steps, time.Since(start)) + // Check if Saturn 2 is deployed + state.Saturn2Deployed, err = IsSaturn2Deployed(m.rp, opts) + if err != nil { + return nil, fmt.Errorf("error checking if Saturn 2 is deployed: %w", err) + } + return state, state.Validate() } From 7595859d91bcfe861508758d07cbf17dfd0898cd Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:53:41 -0300 Subject: [PATCH 06/35] Add defendChallengePerformance task --- .../node/defend-challenge-performance.go | 320 ++++++++++++++++++ rocketpool/node/node.go | 48 ++- .../performance/target-performance.go | 47 +++ 3 files changed, 397 insertions(+), 18 deletions(-) create mode 100644 rocketpool/node/defend-challenge-performance.go diff --git a/rocketpool/node/defend-challenge-performance.go b/rocketpool/node/defend-challenge-performance.go new file mode 100644 index 000000000..a1509fce7 --- /dev/null +++ b/rocketpool/node/defend-challenge-performance.go @@ -0,0 +1,320 @@ +package node + +import ( + "fmt" + "math/big" + "strconv" + + "github.com/docker/docker/client" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + coretypes "github.com/ethereum/go-ethereum/core/types" + "github.com/urfave/cli/v3" + + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/types" + "github.com/rocket-pool/smartnode/bindings/utils/eth" + + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/config" + rpgas "github.com/rocket-pool/smartnode/shared/services/gas" + "github.com/rocket-pool/smartnode/shared/services/performance" + "github.com/rocket-pool/smartnode/shared/services/state" + "github.com/rocket-pool/smartnode/shared/services/wallet" + "github.com/rocket-pool/smartnode/shared/utils/api" + "github.com/rocket-pool/smartnode/shared/utils/log" +) + +// Stake megapool validator task +type defendChallengePerformance struct { + c *cli.Command + log log.ColorLogger + cfg *config.RocketPoolConfig + w wallet.Wallet + rp *rocketpool.RocketPool + bc beacon.Client + d *client.Client + gasThreshold float64 + maxFee *big.Int + maxPriorityFee *big.Int + gasLimit uint64 +} + +type megapoolPerformanceChallenge struct { + megapoolAddress common.Address + validatorIds []uint32 + startEpoch uint64 + participationCallData []*big.Int +} + +// challengedValidator holds a challenged megapool validator's on-chain id +// alongside the beacon-chain identifiers needed to verify its target-vote +// participation. +type challengedValidator struct { + validatorId uint32 + pubkey types.ValidatorPubkey + index uint64 +} + +// participationCallData word (a Solidity uint256). +const bitsPerParticipationWord = 256 + +func (c *megapoolPerformanceChallenge) getChallengedEpochs() []uint64 { + // The challenged epochs are represented as 1s in the bitmaps in the + // participationCallData. The words are concatenated into a single bit + // stream starting at startEpoch + challengedEpochs := []uint64{} + for wordIndex, participationCallData := range c.participationCallData { + wordOffset := uint64(wordIndex) * bitsPerParticipationWord + for i := 0; i < participationCallData.BitLen(); i++ { + if participationCallData.Bit(i) == 1 { + challengedEpochs = append(challengedEpochs, c.startEpoch+wordOffset+uint64(i)) + } + } + } + return challengedEpochs +} + +// Create stake megapool validator task +func newDefendChallengePerformance(c *cli.Command, logger log.ColorLogger) (*defendChallengePerformance, error) { + + // Get services + cfg, err := services.GetConfig(c) + if err != nil { + return nil, err + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + d, err := services.GetDocker(c) + if err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + gasThreshold := cfg.Smartnode.AutoTxGasThreshold.Value.(float64) + + // Get the user-requested max fee + maxFeeGwei := cfg.Smartnode.ManualMaxFee.Value.(float64) + var maxFee *big.Int + if maxFeeGwei == 0 { + maxFee = nil + } else { + maxFee = eth.GweiToWei(maxFeeGwei) + } + + // Get the user-requested max fee + priorityFeeGwei := cfg.Smartnode.PriorityFee.Value.(float64) + var priorityFee *big.Int + if priorityFeeGwei == 0 { + logger.Printlnf("WARNING: priority fee was missing or 0, setting a default of %.2f.", rpgas.DefaultPriorityFeeGwei) + priorityFee = eth.GweiToWei(rpgas.DefaultPriorityFeeGwei) + } else { + priorityFee = eth.GweiToWei(priorityFeeGwei) + } + + // Return task + return &defendChallengePerformance{ + c: c, + log: logger, + cfg: cfg, + w: w, + rp: rp, + bc: bc, + d: d, + gasThreshold: gasThreshold, + maxFee: maxFee, + maxPriorityFee: priorityFee, + gasLimit: 0, + }, nil + +} + +// Check for performance challenges +func (t *defendChallengePerformance) run(state *state.NetworkState) error { + // Check if Saturn 2 is deployed + if !state.Saturn2Deployed { + t.log.Println("Saturn 2 is not deployed, skipping performance challenges check.") + return nil + } + + // Log + t.log.Println("Checking for performance challenges ...") + + // Get the latest state + opts := &bind.CallOpts{ + BlockNumber: big.NewInt(0).SetUint64(state.ElBlockNumber), + } + + // Get node account + nodeAccount, err := t.w.GetNodeAccount() + if err != nil { + return err + } + + // Check if the megapool is deployed + deployed, err := megapool.GetMegapoolDeployed(t.rp, nodeAccount.Address, opts) + if err != nil { + return err + } + if !deployed { + return nil + } + + // Get the megapool address + megapoolAddress, err := megapool.GetMegapoolExpectedAddress(t.rp, nodeAccount.Address, opts) + if err != nil { + return err + } + + // Load the megapool + mp, err := megapool.NewMegaPoolV1(t.rp, megapoolAddress, nil) + if err != nil { + return err + } + + participationCallData := []*big.Int{new(big.Int).Sub( + new(big.Int).Lsh(big.NewInt(1), 200), + big.NewInt(1)), + } + // Use a megapool challenge stub for now + challenge := megapoolPerformanceChallenge{ + megapoolAddress: megapoolAddress, + validatorIds: []uint32{0, 1, 2}, + participationCallData: participationCallData, + startEpoch: 105000, + } + + challengedEpochs := challenge.getChallengedEpochs() + t.log.Printlnf("Challenged epochs: %v", challengedEpochs) + + // Resolve every challenged validator's pubkey and beacon-chain index up + // front so the per-epoch beacon data can be fetched once and shared across + // all of them when verifying target-vote participation. + validatorsByIndex := make(map[uint64]challengedValidator, len(challenge.validatorIds)) + validatorIndices := make([]uint64, 0, len(challenge.validatorIds)) + for _, validatorId := range challenge.validatorIds { + pubkey, err := mp.GetValidatorPubkey(validatorId, opts) + if err != nil { + t.log.Printlnf("error getting pubkey for megapool validator %d: %v", validatorId, err) + continue + } + beaconStatus, err := t.bc.GetValidatorStatus(pubkey, nil) + if err != nil { + t.log.Printlnf("error getting beacon status for megapool validator %d (%s): %v", validatorId, pubkey.Hex(), err) + continue + } + if !beaconStatus.Exists || beaconStatus.Index == "" { + t.log.Printlnf("Megapool validator %d (%s) is not on the beacon chain yet, skipping.", validatorId, pubkey.Hex()) + continue + } + validatorIndex, err := strconv.ParseUint(beaconStatus.Index, 10, 64) + if err != nil { + t.log.Printlnf("error parsing beacon index %q for megapool validator %d: %v", beaconStatus.Index, validatorId, err) + continue + } + validatorsByIndex[validatorIndex] = challengedValidator{ + validatorId: validatorId, + pubkey: pubkey, + index: validatorIndex, + } + validatorIndices = append(validatorIndices, validatorIndex) + } + + // Find the first challenged validator that made a successful target vote + // within the challenged range. A single (validator, epoch) proof is enough + // to defend the challenge + validatorIndex, epoch, found, err := performance.FindFirstTimelyTargetVote(t.bc, state.BeaconConfig, validatorIndices, challengedEpochs) + if err != nil { + return fmt.Errorf("error verifying target-vote participation for challenged megapool validators: %w", err) + } + if !found { + t.log.Println("No challenged validator made a successful target vote in the challenged epochs.") + return nil + } + + // Defend the challenge using that validator id and epoch. + defender := validatorsByIndex[validatorIndex] + t.log.Printlnf("Megapool validator %d made a timely target vote in epoch %d; defending the performance challenge.", defender.validatorId, epoch) + if err := t.defendChallenge(t.rp, mp, defender.validatorId, state, defender.pubkey, epoch, opts); err != nil { + t.log.Printlnf("error defending performance challenge for megapool validator %d: %v", defender.validatorId, err) + } + + // Return + return nil + +} + +func (t *defendChallengePerformance) defendChallenge(rp *rocketpool.RocketPool, mp megapool.Megapool, validatorId uint32, state *state.NetworkState, validatorPubkey types.ValidatorPubkey, challengeEpoch uint64, callopts *bind.CallOpts) error { + + // Get transactor + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + + t.log.Printlnf("Creating a validator performance proof that validator id %d participated in the epoch %v.", validatorId, challengeEpoch) + + validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(t.c, 0, t.w, state.BeaconConfig, mp.GetAddress(), validatorPubkey, nil) + if err != nil { + t.log.Printlnf("[ERROR] There was an error during the proof creation process: %w", err) + return err + } + + t.log.Printlnf("The validator performance proof has been successfully created.") + var gasInfo rocketpool.GasInfo + + gasInfo, err = megapool.EstimateNotifyExitGas(rp, mp.GetAddress(), validatorId, slotTimestamp, validatorProof, slotProof, opts) + if err != nil { + return err + } + + gas := big.NewInt(int64(gasInfo.SafeGasLimit)) + // Get the max fee + maxFee := t.maxFee + if maxFee == nil || maxFee.Uint64() == 0 { + maxFee, err = rpgas.GetHeadlessMaxFeeWeiWithLatestBlock(t.cfg, t.rp) + if err != nil { + return err + } + } + + // Print the gas info + if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + return nil + } + + opts.GasFeeCap = maxFee + opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) + opts.GasLimit = gas.Uint64() + + var tx *coretypes.Transaction + + t.log.Printlnf("Notifying that validator %d is exiting.", validatorId) + tx, err = megapool.NotifyExit(rp, mp.GetAddress(), validatorId, slotTimestamp, validatorProof, slotProof, opts) + if err != nil { + return err + } + + // Print TX info and wait for it to be included in a block + err = api.PrintAndWaitForTransaction(t.cfg, tx.Hash(), t.rp.Client, &t.log) + if err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully responded the performance challenge for validator %d.", validatorId) + + // Return + return nil +} diff --git a/rocketpool/node/node.go b/rocketpool/node/node.go index 2dcda9358..b68debe91 100644 --- a/rocketpool/node/node.go +++ b/rocketpool/node/node.go @@ -39,24 +39,25 @@ var ( const ( MaxConcurrentEth1Requests = 200 - DownloadRewardsTreesColor = color.FgGreen - MetricsColor = color.FgHiYellow - ManageFeeRecipientColor = color.FgHiCyan - DefendPdaoPropsColor = color.FgYellow - VerifyPdaoPropsColor = color.FgYellow - DistributeMinipoolsColor = color.FgHiGreen - ErrorColor = color.FgRed - WarningColor = color.FgYellow - ObserveWarningColor = color.FgHiRed - UpdateColor = color.FgHiWhite - PrestakeMegapoolValidatorColor = color.FgHiGreen - StakeMegapoolValidatorColor = color.FgHiBlue - NotifyValidatorExitColor = color.FgHiYellow - NotifyFinalBalanceColor = color.FgHiMagenta - DefendChallengeExitColor = color.FgHiGreen - ProvisionExpressTickets = color.FgMagenta - SetUseLatestDelegateColor = color.FgBlue - CheckPortConnectivityColor = color.FgHiYellow + DownloadRewardsTreesColor = color.FgGreen + MetricsColor = color.FgHiYellow + ManageFeeRecipientColor = color.FgHiCyan + DefendPdaoPropsColor = color.FgYellow + VerifyPdaoPropsColor = color.FgYellow + DistributeMinipoolsColor = color.FgHiGreen + ErrorColor = color.FgRed + WarningColor = color.FgYellow + ObserveWarningColor = color.FgHiRed + UpdateColor = color.FgHiWhite + PrestakeMegapoolValidatorColor = color.FgHiGreen + StakeMegapoolValidatorColor = color.FgHiBlue + NotifyValidatorExitColor = color.FgHiYellow + NotifyFinalBalanceColor = color.FgHiMagenta + DefendChallengeExitColor = color.FgHiGreen + DefendChallengePerformanceColor = color.FgHiBlue + ProvisionExpressTickets = color.FgMagenta + SetUseLatestDelegateColor = color.FgBlue + CheckPortConnectivityColor = color.FgHiYellow ) // Register node command @@ -205,6 +206,12 @@ func run(c *cli.Command) error { if err != nil { return err } + + defendChallengePerformance, err := newDefendChallengePerformance(c, log.NewColorLogger(DefendChallengePerformanceColor)) + if err != nil { + return err + } + distributeMinipools, err := newDistributeMinipools(c, log.NewColorLogger(DistributeMinipoolsColor)) if err != nil { return err @@ -366,6 +373,11 @@ func run(c *cli.Command) error { errorLog.Println(err) } + // Run the defend challenge performance task + if err := defendChallengePerformance.run(state); err != nil { + errorLog.Println(err) + } + // Run the rewards download check if err := downloadRewardsTrees.run(state); err != nil { errorLog.Println(err) diff --git a/shared/services/performance/target-performance.go b/shared/services/performance/target-performance.go index 67425fb2f..44bc2bd34 100644 --- a/shared/services/performance/target-performance.go +++ b/shared/services/performance/target-performance.go @@ -319,6 +319,53 @@ func CheckEpochTargetVote( return state != epochResultMissed, nil } +// FindFirstTimelyTargetVote scans the supplied validator indices over the +// supplied epochs and returns the first validator index (and the epoch) that +// made a valid target vote +func FindFirstTimelyTargetVote( + bc PerformanceBeaconClient, + cfg beacon.Eth2Config, + validatorIndices []uint64, + epochs []uint64, +) (validatorIndex uint64, epoch uint64, found bool, err error) { + if cfg.SlotsPerEpoch == 0 { + return 0, 0, false, fmt.Errorf("invalid beacon config: SlotsPerEpoch is 0") + } + + // Build the tracked index set (keyed by index string, as the cache keys on + // those) while de-duplicating the input. + indexSet := make(map[string]struct{}, len(validatorIndices)) + indexStrByIndex := make(map[uint64]string, len(validatorIndices)) + uniqueIndices := make([]uint64, 0, len(validatorIndices)) + for _, idx := range validatorIndices { + if _, seen := indexStrByIndex[idx]; seen { + continue + } + s := strconv.FormatUint(idx, 10) + indexSet[s] = struct{}{} + indexStrByIndex[idx] = s + uniqueIndices = append(uniqueIndices, idx) + } + + cache := newEpochCache(bc, cfg, indexSet, nil) + + // Inspect each validator over the challenged epochs, returning as soon as + // one of them is found to have made a timely target vote. + for _, idx := range uniqueIndices { + for _, ep := range epochs { + state, err := cache.evaluateEpoch(indexStrByIndex[idx], idx, ep) + if err != nil { + return 0, 0, false, fmt.Errorf("error checking validator index %d in epoch %d: %w", idx, ep, err) + } + if state == epochResultTimely { + return idx, ep, true, nil + } + } + } + + return 0, 0, false, nil +} + // epochResult enumerates the per-epoch verdicts. type epochResult int From c4a41151d33704ce64d516c5b579fd049b7588cb Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:38:46 -0300 Subject: [PATCH 07/35] Add tests --- .../node/defend-challenge-performance.go | 2 + .../node/defend-challenge-performance_test.go | 89 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 rocketpool/node/defend-challenge-performance_test.go diff --git a/rocketpool/node/defend-challenge-performance.go b/rocketpool/node/defend-challenge-performance.go index a1509fce7..78e2623a4 100644 --- a/rocketpool/node/defend-challenge-performance.go +++ b/rocketpool/node/defend-challenge-performance.go @@ -183,6 +183,8 @@ func (t *defendChallengePerformance) run(state *state.NetworkState) error { return err } + // TODO: Fetch megapool challenges + participationCallData := []*big.Int{new(big.Int).Sub( new(big.Int).Lsh(big.NewInt(1), 200), big.NewInt(1)), diff --git a/rocketpool/node/defend-challenge-performance_test.go b/rocketpool/node/defend-challenge-performance_test.go new file mode 100644 index 000000000..e46aa81e8 --- /dev/null +++ b/rocketpool/node/defend-challenge-performance_test.go @@ -0,0 +1,89 @@ +package node + +import ( + "math/big" + "reflect" + "testing" +) + +func TestGetChallengedEpochs(t *testing.T) { + tests := []struct { + name string + startEpoch uint64 + words []*big.Int + want []uint64 + }{ + { + name: "no words", + startEpoch: 100, + words: []*big.Int{}, + want: []uint64{}, + }, + { + name: "single word, no bits set", + startEpoch: 100, + words: []*big.Int{big.NewInt(0)}, + want: []uint64{}, + }, + { + name: "single word, single bit set", + startEpoch: 100, + words: []*big.Int{big.NewInt(1)}, + want: []uint64{100}, + }, + { + name: "single word, scattered bits", + startEpoch: 100, + // bits 0, 3, and 5 set -> 1 + 8 + 32 = 41 + words: []*big.Int{big.NewInt(41)}, + want: []uint64{100, 103, 105}, + }, + { + name: "single word, non-zero start offset within word", + startEpoch: 50, + words: []*big.Int{new(big.Int).Lsh(big.NewInt(1), 10)}, // bit 10 set + want: []uint64{60}, + }, + { + name: "multiple words, bits in each", + startEpoch: 105000, + words: []*big.Int{ + big.NewInt(1), // bit 0 of word 0 -> epoch 105000 + new(big.Int).Lsh(big.NewInt(1), 5), // bit 5 of word 1 -> epoch 105000 + 256 + 5 + new(big.Int).Lsh(big.NewInt(1), 255), // bit 255 of word 2 -> epoch 105000 + 512 + 255 + }, + want: []uint64{105000, 105261, 105767}, + }, + { + name: "all bits set in a word", + startEpoch: 0, + words: []*big.Int{ + new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 200), big.NewInt(1)), // bits 0..199 set + }, + want: allEpochsInRange(0, 200), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + challenge := megapoolPerformanceChallenge{ + startEpoch: tc.startEpoch, + participationCallData: tc.words, + } + got := challenge.getChallengedEpochs() + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("getChallengedEpochs() = %v, want %v", got, tc.want) + } + }) + } +} + +// allEpochsInRange returns [start, start+count) as a slice, matching the +// order getChallengedEpochs produces for a fully-set bitmap word. +func allEpochsInRange(start uint64, count int) []uint64 { + epochs := make([]uint64, count) + for i := 0; i < count; i++ { + epochs[i] = start + uint64(i) + } + return epochs +} From 7d37ba9c6e720129861eb315aab914b973a3fd48 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:21:17 -0300 Subject: [PATCH 08/35] Add the proofBuffer param --- bindings/settings/protocol/performance.go | 22 ++++++++++++++- rocketpool-cli/pdao/commands.go | 33 +++++++++++++++++++++++ rocketpool-cli/pdao/get-settings.go | 6 ++--- rocketpool-cli/pdao/propose-settings.go | 7 ++++- rocketpool/api/pdao/get-settings.go | 6 +++++ rocketpool/api/pdao/propose-settings.go | 22 +++++++++++++++ shared/types/api/pdao.go | 1 + 7 files changed, 92 insertions(+), 5 deletions(-) diff --git a/bindings/settings/protocol/performance.go b/bindings/settings/protocol/performance.go index 4f2876a56..57be7c9f1 100644 --- a/bindings/settings/protocol/performance.go +++ b/bindings/settings/protocol/performance.go @@ -19,6 +19,7 @@ const ( PerformanceSettingsContractName string = "rocketDAOProtocolSettingsPerformance" PerformanceExitsEnabledSettingPath string = "performance.exits.enabled" PerformancePeriodSettingPath string = "performance.period" + PerformanceProofBufferSettingPath string = "performance.proof.buffer" PerformanceThresholdSettingPath string = "performance.threshold" PerformanceChallengePeriodSettingPath string = "performance.challenge.period" PerformanceChallengeBondSettingPath string = "performance.challenge.bond" @@ -62,6 +63,25 @@ func EstimateProposePerformancePeriodGas(rp *rocketpool.RocketPool, value *big.I return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformancePeriodSettingPath), PerformanceSettingsContractName, PerformancePeriodSettingPath, value, blockNumber, treeNodes, opts) } +// Time buffer to detect underperformance and generate proofs before a validator can be challenged +func GetPerformanceProofBuffer(rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { + performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) + if err != nil { + return 0, err + } + value := new(*big.Int) + if err := performanceSettingsContract.Call(opts, value, "getPerformanceProofBuffer"); err != nil { + return 0, fmt.Errorf("error getting performance proof buffer: %w", err) + } + return time.Duration((*value).Int64()) * time.Hour, nil +} +func ProposePerformanceProofBuffer(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformanceProofBufferSettingPath), PerformanceSettingsContractName, PerformanceProofBufferSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposePerformanceProofBufferGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformanceProofBufferSettingPath), PerformanceSettingsContractName, PerformanceProofBufferSettingPath, value, blockNumber, treeNodes, opts) +} + // Minimum target attestation timeliness percentage required to avoid exit func GetPerformanceThreshold(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) @@ -91,7 +111,7 @@ func GetPerformanceChallengePeriod(rp *rocketpool.RocketPool, opts *bind.CallOpt if err := performanceSettingsContract.Call(opts, value, "getPerformanceChallengePeriod"); err != nil { return 0, fmt.Errorf("error getting performance challenge period: %w", err) } - return time.Duration((*value).Int64()) * time.Second, nil + return time.Duration((*value).Int64()) * time.Hour, nil } func ProposePerformanceChallengePeriod(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformanceChallengePeriodSettingPath), PerformanceSettingsContractName, PerformanceChallengePeriodSettingPath, value, blockNumber, treeNodes, opts) diff --git a/rocketpool-cli/pdao/commands.go b/rocketpool-cli/pdao/commands.go index d1054e07a..29f098719 100644 --- a/rocketpool-cli/pdao/commands.go +++ b/rocketpool-cli/pdao/commands.go @@ -3386,6 +3386,39 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { }, }, + { + Name: "proof-buffer", + Aliases: []string{"pb"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformanceProofBufferSettingPath, durationUsage), + UsageText: "rocketpool pdao propose setting performance proof-buffer value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateDuration("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingPerformanceProofBuffer(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + { Name: "threshold", Aliases: []string{"t"}, diff --git a/rocketpool-cli/pdao/get-settings.go b/rocketpool-cli/pdao/get-settings.go index b0dbd61db..11e8da57e 100644 --- a/rocketpool-cli/pdao/get-settings.go +++ b/rocketpool-cli/pdao/get-settings.go @@ -2,7 +2,6 @@ package pdao import ( "fmt" - "time" "github.com/rocket-pool/smartnode/shared/math" "github.com/rocket-pool/smartnode/shared/services/rocketpool" @@ -138,6 +137,7 @@ func getSettings() error { fmt.Println("== Performance Settings ==") fmt.Printf("\tPerformance Exits Enabled: %t\n", response.Performance.ExitsEnabled) fmt.Printf("\tPerformance Period: %d Epochs\n", response.Performance.Period) + fmt.Printf("\tProof Buffer: %s\n", response.Performance.ProofBuffer) fmt.Printf("\tPerformance Threshold: %.2f%%\n", eth.WeiToEth(response.Performance.Threshold)*100) fmt.Printf("\tChallenge Period: %s\n", response.Performance.ChallengePeriod) fmt.Printf("\tChallenge Bond: %.6f RPL\n", eth.WeiToEth(response.Performance.ChallengeBond)) @@ -145,9 +145,9 @@ func getSettings() error { // Exit fmt.Println("== Exit Settings (RPIP-80) ==") - fmt.Printf("\tCooperative Exit Phase: %d Hours\n", uint64(response.Exit.CooperativeExitPhase/time.Hour)) + fmt.Printf("\tCooperative Exit Phase: %.0f Hours\n", response.Exit.CooperativeExitPhase.Hours()) fmt.Printf("\tDid Not Exit Penalty: %.6f ETH\n", eth.WeiToEth(response.Exit.DidNotExitPenalty)) - fmt.Printf("\tDid Not Exit Cooldown: %d Days\n", uint64(response.Exit.DidNotExitCooldown/(24*time.Hour))) + fmt.Printf("\tDid Not Exit Cooldown: %s\n", response.Exit.DidNotExitCooldown) fmt.Println() } diff --git a/rocketpool-cli/pdao/propose-settings.go b/rocketpool-cli/pdao/propose-settings.go index 2f7101497..2eaeef484 100644 --- a/rocketpool-cli/pdao/propose-settings.go +++ b/rocketpool-cli/pdao/propose-settings.go @@ -375,13 +375,18 @@ func proposeSettingPerformancePeriod(value uint64, yes bool, toJson string) erro return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformancePeriodSettingPath, trueValue, yes, toJson) } +func proposeSettingPerformanceProofBuffer(value time.Duration, yes bool, toJson string) error { + trueValue := fmt.Sprint(uint64(value.Hours())) + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceProofBufferSettingPath, trueValue, yes, toJson) +} + func proposeSettingPerformanceThreshold(value *big.Int, yes bool, toJson string) error { trueValue := value.String() return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceThresholdSettingPath, trueValue, yes, toJson) } func proposeSettingPerformanceChallengePeriod(value time.Duration, yes bool, toJson string) error { - trueValue := fmt.Sprint(uint64(value.Seconds())) + trueValue := fmt.Sprint(uint64(value.Hours())) return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceChallengePeriodSettingPath, trueValue, yes, toJson) } diff --git a/rocketpool/api/pdao/get-settings.go b/rocketpool/api/pdao/get-settings.go index b97a82ef3..3c27bf21c 100644 --- a/rocketpool/api/pdao/get-settings.go +++ b/rocketpool/api/pdao/get-settings.go @@ -207,6 +207,12 @@ func getSettings(c *cli.Command) (*api.GetPDAOSettingsResponse, error) { return err }) + wg.Go(func() error { + var err error + response.Performance.ProofBuffer, err = protocol.GetPerformanceProofBuffer(rp, nil) + return err + }) + wg.Go(func() error { var err error response.Performance.Threshold, err = protocol.GetPerformanceThreshold(rp, nil) diff --git a/rocketpool/api/pdao/propose-settings.go b/rocketpool/api/pdao/propose-settings.go index 83d3a32e5..a57ec98ce 100644 --- a/rocketpool/api/pdao/propose-settings.go +++ b/rocketpool/api/pdao/propose-settings.go @@ -985,6 +985,17 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, return nil, fmt.Errorf("error estimating gas for proposing PerformancePeriod: %w", err) } + // PerformanceProofBuffer + case protocol.PerformanceProofBufferSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasInfo, err = protocol.EstimateProposePerformanceProofBufferGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PerformanceProofBuffer: %w", err) + } + // PerformanceThreshold case protocol.PerformanceThresholdSettingPath: newValue, err := cliutils.ValidateBigInt(valueName, value) @@ -1967,6 +1978,17 @@ func proposeSetting(c *cli.Command, contractName string, settingName string, val return nil, fmt.Errorf("error proposing PerformancePeriod: %w", err) } + // PerformanceProofBuffer + case protocol.PerformanceProofBufferSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposePerformanceProofBuffer(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PerformanceProofBuffer: %w", err) + } + // PerformanceThreshold case protocol.PerformanceThresholdSettingPath: newValue, err := cliutils.ValidateBigInt(valueName, value) diff --git a/shared/types/api/pdao.go b/shared/types/api/pdao.go index 3be4d7a42..ad55074a0 100644 --- a/shared/types/api/pdao.go +++ b/shared/types/api/pdao.go @@ -184,6 +184,7 @@ type GetPDAOSettingsResponse struct { Performance struct { ExitsEnabled bool `json:"exitsEnabled"` Period uint64 `json:"period"` + ProofBuffer time.Duration `json:"proofBuffer"` Threshold *big.Int `json:"threshold"` ChallengePeriod time.Duration `json:"challengePeriod"` ChallengeBond *big.Int `json:"challengeBond"` From 73fd2026abde1dbb972a0ffbc6dd8b820d196116 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:09:43 -0300 Subject: [PATCH 09/35] Add challenge functions --- bindings/megapool/megapool-contract.go | 7 ++++ bindings/megapool/performance.go | 57 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 bindings/megapool/performance.go diff --git a/bindings/megapool/megapool-contract.go b/bindings/megapool/megapool-contract.go index f22e04d1d..f984656dd 100644 --- a/bindings/megapool/megapool-contract.go +++ b/bindings/megapool/megapool-contract.go @@ -17,6 +17,13 @@ import ( rptypes "github.com/rocket-pool/smartnode/bindings/types" ) +type ParticipationProof struct { + ParticipationSlot uint64 + ValidatorIndex uint64 + ParticipationFlags uint8 + Witnesses [][32]byte +} + type SlotProof struct { Slot uint64 `json:"slot"` Witnesses [][32]byte `json:"witnesses"` diff --git a/bindings/megapool/performance.go b/bindings/megapool/performance.go new file mode 100644 index 000000000..f1a66ff65 --- /dev/null +++ b/bindings/megapool/performance.go @@ -0,0 +1,57 @@ +package megapool + +import ( + "fmt" + "math/big" + "sync" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/bindings/rocketpool" +) + +// Challenge the megapool +func ChallengeMegapool(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkParticipation.Transact(opts, "challengeMegapool", megapoolAddress, validatorIds, startEpoch, participation, slotTimestamp, slotProof) + if err != nil { + return common.Hash{}, fmt.Errorf("error challenging megapool: %w", err) + } + return tx.Hash(), nil +} + +func Respond(rp *rocketpool.RocketPool, challengeId uint64, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkParticipation.Transact(opts, "respond", challengeId, offset, challengeLeaf, challengeWitness, slotTimestamp, participationProof, slotProof) + if err != nil { + return common.Hash{}, fmt.Errorf("error responding to challenge: %w", err) + } + return tx.Hash(), nil +} + +func FinaliseChallenge(rp *rocketpool.RocketPool, challengeId uint64, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkParticipation.Transact(opts, "finaliseChallenge", challengeId) + if err != nil { + return common.Hash{}, fmt.Errorf("error finalising challenge: %w", err) + } + return tx.Hash(), nil +} + +// Get contracts +var rocketNetworkParticipationLock sync.Mutex + +func getRocketNetworkParticipation(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*rocketpool.Contract, error) { + rocketNetworkParticipationLock.Lock() + defer rocketNetworkParticipationLock.Unlock() + return rp.GetContract("rocketNetworkParticipation", opts) +} From 7b4c8db12b2107cb44c7cd9770029c5207754432 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:40:37 -0300 Subject: [PATCH 10/35] Add saturn2 check and default values --- .../performance/target-performance.go | 68 +++++++++---------- .../verify-performance/verify-performance.go | 3 +- 2 files changed, 35 insertions(+), 36 deletions(-) diff --git a/shared/services/performance/target-performance.go b/shared/services/performance/target-performance.go index 44bc2bd34..06a133e14 100644 --- a/shared/services/performance/target-performance.go +++ b/shared/services/performance/target-performance.go @@ -9,20 +9,6 @@ // 2. voting for the correct target root, i.e. data.target.root equals the // canonical block root at the first slot of epoch E, AND // 3. included within SLOTS_PER_EPOCH slots of data.slot. -// -// This package recomputes the flag by inspecting block attestations rather -// than downloading the full Beacon State SSZ. Per epoch under inspection the -// cost is roughly: one committees fetch + one block-header fetch (target -// root) + up to SLOTS_PER_EPOCH block fetches (inclusion window). This is -// orders of magnitude cheaper than fetching beacon states, and works against -// any standard Beacon API node (archival is still required for old slots). -// -// When several validators are verified over the same epoch range in a single -// run, the per-epoch beacon data (target roots, committee assignments, and -// inclusion-window blocks) is identical for every validator. The epochCache -// type fetches each of those once and reuses them across all validators, so a -// batch of N validators over E epochs costs roughly the same beacon I/O as a -// single validator over E epochs instead of N times as much. package performance import ( @@ -32,12 +18,12 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/settings/protocol" rptypes "github.com/rocket-pool/smartnode/bindings/types" - - // "github.com/rocket-pool/smartnode/bindings/settings/protocol" - // "github.com/rocket-pool/smartnode/bindings/utils/eth" + "github.com/rocket-pool/smartnode/bindings/utils/eth" "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/state" "github.com/rocket-pool/smartnode/shared/types/api" ) @@ -50,12 +36,24 @@ const ( TimelyHeadFlagIndex = 2 ) -// defaultPerformanceThresholdPct is the RPIP-73 initial pDAO -// performance_threshold value (94%). It is used while the on-chain -// rocketDAOProtocolSettingsPerformance contract is not yet deployed; once -// available, replace this with a live call to protocol.GetPerformanceThreshold. +// defaultPerformanceThresholdPct is the RPIP-73 initial pDAO value const defaultPerformanceThresholdPct = 94.0 +func GetPerformanceThresholdPct(rp *rocketpool.RocketPool) (float64, error) { + saturn2Deployed, err := state.IsSaturn2Deployed(rp, nil) + if err != nil { + return 0, fmt.Errorf("error checking if Saturn 2 is deployed: %w", err) + } + if !saturn2Deployed { + return defaultPerformanceThresholdPct, nil + } + thresholdWei, err := protocol.GetPerformanceThreshold(rp, nil) + if err != nil { + return 0, fmt.Errorf("error getting performance threshold: %w", err) + } + return eth.WeiToEth(thresholdWei) * 100.0, nil +} + // farFutureEpoch is the spec's FAR_FUTURE_EPOCH sentinel (2^64-1). const farFutureEpoch = ^uint64(0) @@ -138,12 +136,17 @@ func VerifyPerformance( return nil, fmt.Errorf("error parsing validator index %q: %w", indexStr, err) } + thresholdPct, err := GetPerformanceThresholdPct(rp) + if err != nil { + return nil, err + } + summary, err := CheckTargetPerformance(bc, cfg, validatorIndex, startEpoch, endEpoch) if err != nil { return nil, err } - return summaryToResponse(pubkey, summary), nil + return summaryToResponse(pubkey, summary, thresholdPct), nil } // BatchValidatorResult is one validator's outcome from VerifyPerformanceBatch. @@ -207,6 +210,12 @@ func VerifyPerformanceBatch( return nil, fmt.Errorf("invalid beacon config: SlotsPerEpoch is 0") } + // Fetch the pDAO performance threshold once for the whole batch. + thresholdPct, err := GetPerformanceThresholdPct(rp) + if err != nil { + return nil, err + } + // Resolve every non-zero pubkey to its index and status in a single call. uniquePubkeys := make([]rptypes.ValidatorPubkey, 0, len(pubkeys)) seen := map[rptypes.ValidatorPubkey]struct{}{} @@ -266,7 +275,7 @@ func VerifyPerformanceBatch( results[i].Err = err continue } - results[i].Response = summaryToResponse(pk, summary) + results[i].Response = summaryToResponse(pk, summary, thresholdPct) } return results, nil @@ -705,18 +714,7 @@ func validatorHadAttestationDuty(status beacon.ValidatorStatus, epoch uint64) bo // summaryToResponse packages a PerformanceSummary into the API response, // attaching the pDAO performance_threshold and pass/fail verdict. -func summaryToResponse(pubkey rptypes.ValidatorPubkey, summary *PerformanceSummary) *api.VerifyPerformanceResponse { - // The performance_threshold setting is scaled by 1e18 (1e18 = 100%). - // thresholdWei, err := protocol.GetPerformanceThreshold(rp, nil) - // if err != nil { - // return nil, fmt.Errorf("error getting performance threshold: %w", err) - // } - // thresholdPct := eth.WeiToEth(thresholdWei) * 100 - // - // TODO: The rocketDAOProtocolSettingsPerformance contract is not yet deployed. - // Use the RPIP-73 initial value until it is. - thresholdPct := defaultPerformanceThresholdPct - +func summaryToResponse(pubkey rptypes.ValidatorPubkey, summary *PerformanceSummary, thresholdPct float64) *api.VerifyPerformanceResponse { return &api.VerifyPerformanceResponse{ ValidatorPubkey: pubkey, ValidatorIndex: summary.ValidatorIndex, diff --git a/shared/utils/cli/verify-performance/verify-performance.go b/shared/utils/cli/verify-performance/verify-performance.go index faa0fd7e8..51aebd382 100644 --- a/shared/utils/cli/verify-performance/verify-performance.go +++ b/shared/utils/cli/verify-performance/verify-performance.go @@ -16,6 +16,7 @@ import ( // the user to confirm, because each epoch issues a committees fetch plus a // few-dozen block fetches against the beacon node. const LargeEpochRangeWarning uint64 = 256 +const defaultPerformancePeriodEpochs = 44032 // ResolveEpochRange fills in defaults for the start/length range. If epochs // is 0, it defaults to the on-chain performance_period setting. Returns the @@ -28,7 +29,7 @@ func ResolveEpochRange(rp *rocketpool.Client, startEpoch, epochs uint64) (uint64 } epochs = settings.Performance.Period if epochs == 0 { - return 0, 0, fmt.Errorf("on-chain performance_period is 0 and no --epochs was provided") + epochs = defaultPerformancePeriodEpochs } } endEpoch := startEpoch + epochs - 1 From d4f0ded9b963939fb3cb5da18d155e72c112e8f2 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:57:45 -0300 Subject: [PATCH 11/35] Add EncodeParticipationBitset --- rocketpool-cli/megapool/verify-performance.go | 4 +- .../node/defend-challenge-performance_test.go | 52 ++++++++++++ .../performance/target-performance.go | 31 +++++++ .../performance/target-performance_test.go | 81 +++++++++++++++++++ shared/types/api/minipool.go | 3 + 5 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 shared/services/performance/target-performance_test.go diff --git a/rocketpool-cli/megapool/verify-performance.go b/rocketpool-cli/megapool/verify-performance.go index ff005f5b4..244b61987 100644 --- a/rocketpool-cli/megapool/verify-performance.go +++ b/rocketpool-cli/megapool/verify-performance.go @@ -36,7 +36,7 @@ func validateMegapoolTargets(targets string) error { return nil } -func verifyMegapoolPerformance(megapoolAddress common.Address, targets string, startEpoch uint64, epochs uint64, yes bool) error { +func verifyMegapoolPerformance(megapoolAddress common.Address, targetValidators string, startEpoch uint64, epochs uint64, yes bool) error { rp, err := rocketpool.NewClient().WithReady() if err != nil { return err @@ -52,7 +52,7 @@ func verifyMegapoolPerformance(megapoolAddress common.Address, targets string, s } start := time.Now() - resp, err := rp.VerifyMegapoolValidatorPerformance(megapoolAddress, targets, startEpoch, endEpoch) + resp, err := rp.VerifyMegapoolValidatorPerformance(megapoolAddress, targetValidators, startEpoch, endEpoch) if err != nil { return err } diff --git a/rocketpool/node/defend-challenge-performance_test.go b/rocketpool/node/defend-challenge-performance_test.go index e46aa81e8..1cf88bea3 100644 --- a/rocketpool/node/defend-challenge-performance_test.go +++ b/rocketpool/node/defend-challenge-performance_test.go @@ -4,6 +4,8 @@ import ( "math/big" "reflect" "testing" + + "github.com/rocket-pool/smartnode/shared/services/performance" ) func TestGetChallengedEpochs(t *testing.T) { @@ -78,6 +80,56 @@ func TestGetChallengedEpochs(t *testing.T) { } } +// TestParticipationBitsetRoundTrip pins the encoder in the performance +// package and the decoder here to the same bit-layout convention: encoding a +// missed-epoch list and decoding it back must return the original list. +func TestParticipationBitsetRoundTrip(t *testing.T) { + tests := []struct { + name string + startEpoch uint64 + endEpoch uint64 + missedEpochs []uint64 + }{ + { + name: "no missed epochs", + startEpoch: 105000, + endEpoch: 105500, + missedEpochs: []uint64{}, + }, + { + name: "scattered epochs across word boundaries", + startEpoch: 105000, + endEpoch: 106000, + missedEpochs: []uint64{105000, 105255, 105256, 105511, 105767, 106000}, + }, + { + name: "every epoch in a small range", + startEpoch: 200, + endEpoch: 209, + missedEpochs: allEpochsInRange(200, 10), + }, + { + name: "every epoch in a large range", + startEpoch: 1, + endEpoch: 44032, + missedEpochs: allEpochsInRange(1, 44032), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + challenge := megapoolPerformanceChallenge{ + startEpoch: tc.startEpoch, + participationCallData: performance.EncodeParticipationBitset(tc.startEpoch, tc.endEpoch, tc.missedEpochs), + } + got := challenge.getChallengedEpochs() + if !reflect.DeepEqual(got, tc.missedEpochs) { + t.Errorf("round trip = %v, want %v", got, tc.missedEpochs) + } + }) + } +} + // allEpochsInRange returns [start, start+count) as a slice, matching the // order getChallengedEpochs produces for a fully-set bitmap word. func allEpochsInRange(start uint64, count int) []uint64 { diff --git a/shared/services/performance/target-performance.go b/shared/services/performance/target-performance.go index 06a133e14..377fbc13c 100644 --- a/shared/services/performance/target-performance.go +++ b/shared/services/performance/target-performance.go @@ -13,6 +13,7 @@ package performance import ( "fmt" + "math/big" "strconv" "github.com/ethereum/go-ethereum/common" @@ -712,6 +713,35 @@ func validatorHadAttestationDuty(status beacon.ValidatorStatus, epoch uint64) bo return true } +// bitsPerParticipationWord matches the Solidity uint256 word width +const bitsPerParticipationWord = 256 + +// EncodeParticipationBitset encodes the missed epochs of the inclusive range +// [startEpoch, endEpoch] as the uint256[] bitset expected by the +// challengeMegapool participation calldata: the words form a single bit +// stream starting at startEpoch, LSB-first within each word, with a 1 bit +// marking a not-timely target attestation. +func EncodeParticipationBitset(startEpoch, endEpoch uint64, missedEpochs []uint64) []*big.Int { + if endEpoch < startEpoch { + return []*big.Int{} + } + totalEpochs := endEpoch - startEpoch + 1 + wordCount := (totalEpochs + bitsPerParticipationWord - 1) / bitsPerParticipationWord + words := make([]*big.Int, wordCount) + for i := range words { + words[i] = new(big.Int) + } + for _, epoch := range missedEpochs { + if epoch < startEpoch || epoch > endEpoch { + continue + } + offset := epoch - startEpoch + word := words[offset/bitsPerParticipationWord] + word.SetBit(word, int(offset%bitsPerParticipationWord), 1) + } + return words +} + // summaryToResponse packages a PerformanceSummary into the API response, // attaching the pDAO performance_threshold and pass/fail verdict. func summaryToResponse(pubkey rptypes.ValidatorPubkey, summary *PerformanceSummary, thresholdPct float64) *api.VerifyPerformanceResponse { @@ -729,5 +759,6 @@ func summaryToResponse(pubkey rptypes.ValidatorPubkey, summary *PerformanceSumma PassesThreshold: summary.PerformancePct >= thresholdPct, MissedEpochList: summary.MissedEpochList, TimelyEpochList: summary.TimelyEpochList, + Participation: EncodeParticipationBitset(summary.StartEpoch, summary.EndEpoch, summary.MissedEpochList), } } diff --git a/shared/services/performance/target-performance_test.go b/shared/services/performance/target-performance_test.go new file mode 100644 index 000000000..43e457b7c --- /dev/null +++ b/shared/services/performance/target-performance_test.go @@ -0,0 +1,81 @@ +package performance + +import ( + "math/big" + "reflect" + "testing" +) + +func TestEncodeParticipationBitset(t *testing.T) { + tests := []struct { + name string + startEpoch uint64 + endEpoch uint64 + missedEpochs []uint64 + want []*big.Int + }{ + { + name: "end before start", + startEpoch: 100, + endEpoch: 99, + missedEpochs: []uint64{}, + want: []*big.Int{}, + }, + { + name: "no missed epochs, range spanning two words", + startEpoch: 100, + endEpoch: 399, // 300 epochs -> 2 words + missedEpochs: []uint64{}, + want: []*big.Int{big.NewInt(0), big.NewInt(0)}, + }, + { + name: "single missed epoch at start", + startEpoch: 100, + endEpoch: 355, // exactly 256 epochs -> 1 word + missedEpochs: []uint64{100}, + want: []*big.Int{big.NewInt(1)}, + }, + { + name: "scattered bits within one word", + startEpoch: 100, + endEpoch: 200, + missedEpochs: []uint64{100, 103, 105}, + // bits 0, 3, and 5 set -> 1 + 8 + 32 = 41 + want: []*big.Int{big.NewInt(41)}, + }, + { + name: "word boundary: offsets 255 and 256", + startEpoch: 1000, + endEpoch: 1511, // 512 epochs -> 2 words + missedEpochs: []uint64{1255, 1256}, + want: []*big.Int{ + new(big.Int).Lsh(big.NewInt(1), 255), // bit 255 of word 0 + big.NewInt(1), // bit 0 of word 1 + }, + }, + { + name: "range not a multiple of the word size", + startEpoch: 0, + endEpoch: 256, // 257 epochs -> 2 words + missedEpochs: []uint64{256}, + want: []*big.Int{big.NewInt(0), big.NewInt(1)}, + }, + { + name: "epochs outside the range are ignored", + startEpoch: 100, + endEpoch: 200, + missedEpochs: []uint64{99, 150, 201}, + want: []*big.Int{new(big.Int).Lsh(big.NewInt(1), 50)}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := EncodeParticipationBitset(tc.startEpoch, tc.endEpoch, tc.missedEpochs) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("EncodeParticipationBitset(%d, %d, %v) = %v, want %v", + tc.startEpoch, tc.endEpoch, tc.missedEpochs, got, tc.want) + } + }) + } +} diff --git a/shared/types/api/minipool.go b/shared/types/api/minipool.go index 1bbc7bef6..19490620d 100644 --- a/shared/types/api/minipool.go +++ b/shared/types/api/minipool.go @@ -320,6 +320,9 @@ type VerifyPerformanceResponse struct { PassesThreshold bool `json:"passesThreshold"` MissedEpochList []uint64 `json:"missedEpochList"` TimelyEpochList []uint64 `json:"timelyEpochList"` + // Participation is the challengeMegapool participation calldata (uint256[]): + // the epochs [StartEpoch, EndEpoch] as a bitset, 1 = not-timely target vote. + Participation []*big.Int `json:"participation"` } // VerifyPerformanceResult is one validator's entry in a batch verify-performance From 47fe8d0ed90bc3b4c44afae5c20bdfefc97c66a6 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:58:17 -0300 Subject: [PATCH 12/35] Add estimate functions --- bindings/megapool/performance.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/bindings/megapool/performance.go b/bindings/megapool/performance.go index f1a66ff65..18b291c89 100644 --- a/bindings/megapool/performance.go +++ b/bindings/megapool/performance.go @@ -10,6 +10,15 @@ import ( "github.com/rocket-pool/smartnode/bindings/rocketpool" ) +// Estimate the gas to call ChallengeMegapool +func EstimateChallengeMegapoolGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return rocketpool.GasInfo{}, err + } + return rocketNetworkParticipation.GetTransactionGasInfo(opts, "challengeMegapool", megapoolAddress, validatorIds, startEpoch, participation, slotTimestamp, slotProof) +} + // Challenge the megapool func ChallengeMegapool(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) @@ -23,6 +32,15 @@ func ChallengeMegapool(rp *rocketpool.RocketPool, megapoolAddress common.Address return tx.Hash(), nil } +// Estimate the gas to call Respond +func EstimateRespondGas(rp *rocketpool.RocketPool, challengeId uint64, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return rocketpool.GasInfo{}, err + } + return rocketNetworkParticipation.GetTransactionGasInfo(opts, "respond", challengeId, offset, challengeLeaf, challengeWitness, slotTimestamp, participationProof, slotProof) +} + func Respond(rp *rocketpool.RocketPool, challengeId uint64, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { @@ -35,6 +53,15 @@ func Respond(rp *rocketpool.RocketPool, challengeId uint64, offset uint64, chall return tx.Hash(), nil } +// Estimate the gas to call FinaliseChallenge +func EstimateFinaliseChallengeGas(rp *rocketpool.RocketPool, challengeId uint64, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return rocketpool.GasInfo{}, err + } + return rocketNetworkParticipation.GetTransactionGasInfo(opts, "finaliseChallenge", challengeId) +} + func FinaliseChallenge(rp *rocketpool.RocketPool, challengeId uint64, opts *bind.TransactOpts) (common.Hash, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { From 42878e316efa5dd33d8306d104466aaf4d20a68c Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:05:59 -0300 Subject: [PATCH 13/35] Add IsRangeChallengeable --- rocketpool/api/megapool/verify-performance.go | 6 + rocketpool/api/minipool/verify-performance.go | 6 + .../performance/target-performance.go | 108 +++++++++++ .../performance/target-performance_test.go | 179 ++++++++++++++++++ shared/types/api/minipool.go | 5 + .../verify-performance/verify-performance.go | 10 +- 6 files changed, 311 insertions(+), 3 deletions(-) diff --git a/rocketpool/api/megapool/verify-performance.go b/rocketpool/api/megapool/verify-performance.go index 38a164d0f..6e91d2b75 100644 --- a/rocketpool/api/megapool/verify-performance.go +++ b/rocketpool/api/megapool/verify-performance.go @@ -94,6 +94,11 @@ func verifyPerformance( return nil, err } + challengeable, err := performance.IsRangeChallengeable(rp, bc, startEpoch, endEpoch) + if err != nil { + return nil, err + } + response := &api.VerifyPerformanceBatchResponse{ Results: make([]api.VerifyPerformanceResult, 0, len(validatorIds)), } @@ -109,6 +114,7 @@ func verifyPerformance( result.Error = batch[i].Err.Error() default: result.Performance = batch[i].Response + result.Performance.Challengeable = challengeable && performance.ExceedsChallengeThreshold(result.Performance) } response.Results = append(response.Results, result) } diff --git a/rocketpool/api/minipool/verify-performance.go b/rocketpool/api/minipool/verify-performance.go index c3cd28ab4..678bf26d3 100644 --- a/rocketpool/api/minipool/verify-performance.go +++ b/rocketpool/api/minipool/verify-performance.go @@ -66,6 +66,11 @@ func verifyPerformance( return nil, err } + challengeable, err := performance.IsRangeChallengeable(rp, bc, startEpoch, endEpoch) + if err != nil { + return nil, err + } + response := &api.VerifyPerformanceBatchResponse{ Results: make([]api.VerifyPerformanceResult, 0, len(addresses)), } @@ -81,6 +86,7 @@ func verifyPerformance( result.Error = batch[i].Err.Error() default: result.Performance = batch[i].Response + result.Performance.Challengeable = challengeable && performance.ExceedsChallengeThreshold(result.Performance) } response.Results = append(response.Results, result) } diff --git a/shared/services/performance/target-performance.go b/shared/services/performance/target-performance.go index 377fbc13c..1e8b760ee 100644 --- a/shared/services/performance/target-performance.go +++ b/shared/services/performance/target-performance.go @@ -15,6 +15,7 @@ import ( "fmt" "math/big" "strconv" + "time" "github.com/ethereum/go-ethereum/common" @@ -55,6 +56,113 @@ func GetPerformanceThresholdPct(rp *rocketpool.RocketPool) (float64, error) { return eth.WeiToEth(thresholdWei) * 100.0, nil } +// Defaults used before Saturn 2 deploys. +const DefaultPerformancePeriodEpochs uint64 = 44032 +const defaultProofBuffer = 24 * time.Hour + +// ChallengeParams are the pDAO settings governing performance challenges. +type ChallengeParams struct { + ExitsEnabled bool + PeriodEpochs uint64 + ProofBuffer time.Duration +} + +// GetChallengeParams fetches the pDAO performance-challenge settings, using +// the pre-Saturn-2 defaults when Saturn 2 is not deployed yet. +func GetChallengeParams(rp *rocketpool.RocketPool) (ChallengeParams, error) { + saturn2Deployed, err := state.IsSaturn2Deployed(rp, nil) + if err != nil { + return ChallengeParams{}, fmt.Errorf("error checking if Saturn 2 is deployed: %w", err) + } + if !saturn2Deployed { + return ChallengeParams{ + ExitsEnabled: true, + PeriodEpochs: DefaultPerformancePeriodEpochs, + ProofBuffer: defaultProofBuffer, + }, nil + } + exitsEnabled, err := protocol.GetPerformanceExitsEnabled(rp, nil) + if err != nil { + return ChallengeParams{}, err + } + periodEpochs, err := protocol.GetPerformancePeriod(rp, nil) + if err != nil { + return ChallengeParams{}, err + } + proofBuffer, err := protocol.GetPerformanceProofBuffer(rp, nil) + if err != nil { + return ChallengeParams{}, err + } + return ChallengeParams{ + ExitsEnabled: exitsEnabled, + PeriodEpochs: periodEpochs, + ProofBuffer: proofBuffer, + }, nil +} + +// challengeBeaconClient is the beacon client surface needed to evaluate the +// challengeability of an epoch range. +type challengeBeaconClient interface { + GetEth2Config() (beacon.Eth2Config, error) + GetBeaconHead() (beacon.BeaconHead, error) +} + +// IsRangeChallengeable fetches the pDAO challenge settings and the beacon +// head, then reports whether a performance check over the inclusive range +// [startEpoch, endEpoch] could back an on-chain challenge. See +// IsChallengeable for the rules. +func IsRangeChallengeable(rp *rocketpool.RocketPool, bc challengeBeaconClient, startEpoch, endEpoch uint64) (bool, error) { + params, err := GetChallengeParams(rp) + if err != nil { + return false, err + } + cfg, err := bc.GetEth2Config() + if err != nil { + return false, fmt.Errorf("error getting beacon config: %w", err) + } + head, err := bc.GetBeaconHead() + if err != nil { + return false, fmt.Errorf("error getting beacon head: %w", err) + } + return IsChallengeable(params, cfg, head.Epoch, startEpoch, endEpoch), nil +} + +// ExceedsChallengeThreshold reports whether the validator missed enough +// target votes for a challenge to succeed: the missed share of the checked +// period must be higher than the allowed slack (100% - performance_threshold). +func ExceedsChallengeThreshold(resp *api.VerifyPerformanceResponse) bool { + if resp.TotalEpochs == 0 { + return false + } + missedPct := float64(resp.MissedEpochs) / float64(resp.TotalEpochs) * 100.0 + return missedPct > 100.0-resp.PerformanceThresholdPct +} + +// IsChallengeable reports whether a performance check over the inclusive +// range [startEpoch, endEpoch] could back an on-chain challenge: performance +// exits must be enabled, the range must cover exactly one performance period, +// and it must be recent enough that the proof buffer has not elapsed +// (startEpoch > currentEpoch - period - proofBuffer, with the buffer +// converted to epochs). +func IsChallengeable(params ChallengeParams, cfg beacon.Eth2Config, currentEpoch, startEpoch, endEpoch uint64) bool { + if !params.ExitsEnabled { + return false + } + if endEpoch != startEpoch+params.PeriodEpochs-1 { + return false + } + if cfg.SecondsPerEpoch == 0 { + return false + } + proofBufferEpochs := uint64(params.ProofBuffer.Seconds()) / cfg.SecondsPerEpoch + window := params.PeriodEpochs + proofBufferEpochs + if currentEpoch <= window { + // The whole chain history is still within the challenge window. + return true + } + return startEpoch > currentEpoch-window +} + // farFutureEpoch is the spec's FAR_FUTURE_EPOCH sentinel (2^64-1). const farFutureEpoch = ^uint64(0) diff --git a/shared/services/performance/target-performance_test.go b/shared/services/performance/target-performance_test.go index 43e457b7c..3363b8f92 100644 --- a/shared/services/performance/target-performance_test.go +++ b/shared/services/performance/target-performance_test.go @@ -4,8 +4,187 @@ import ( "math/big" "reflect" "testing" + "time" + + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/types/api" ) +func TestIsChallengeable(t *testing.T) { + // Mainnet timing: 32 slots * 12s = 384s per epoch, so a 24h proof buffer + // spans 86400 / 384 = 225 epochs. + cfg := beacon.Eth2Config{SecondsPerEpoch: 384} + params := ChallengeParams{ + ExitsEnabled: true, + PeriodEpochs: 1000, + ProofBuffer: 24 * time.Hour, + } + // The challenge window is period + proofBufferEpochs = 1225 epochs, so with + // currentEpoch = 10000 the oldest challengeable start epoch is 8776. + + tests := []struct { + name string + params ChallengeParams + cfg beacon.Eth2Config + currentEpoch uint64 + startEpoch uint64 + endEpoch uint64 + want bool + }{ + { + name: "recent full period", + params: params, + cfg: cfg, + currentEpoch: 10000, + startEpoch: 9000, + endEpoch: 9999, + want: true, + }, + { + name: "exits disabled", + params: ChallengeParams{ExitsEnabled: false, PeriodEpochs: 1000, ProofBuffer: 24 * time.Hour}, + cfg: cfg, + currentEpoch: 10000, + startEpoch: 9000, + endEpoch: 9999, + want: false, + }, + { + name: "range one epoch too long", + params: params, + cfg: cfg, + currentEpoch: 10000, + startEpoch: 9000, + endEpoch: 10000, + want: false, + }, + { + name: "range one epoch too short", + params: params, + cfg: cfg, + currentEpoch: 10000, + startEpoch: 9000, + endEpoch: 9998, + want: false, + }, + { + name: "start epoch just inside the window", + params: params, + cfg: cfg, + currentEpoch: 10000, + startEpoch: 8776, + endEpoch: 9775, + want: true, + }, + { + name: "start epoch at the window boundary", + params: params, + cfg: cfg, + currentEpoch: 10000, + startEpoch: 8775, + endEpoch: 9774, + want: false, + }, + { + name: "current epoch smaller than the window", + params: params, + cfg: cfg, + currentEpoch: 1000, + startEpoch: 0, + endEpoch: 999, + want: true, + }, + { + name: "invalid beacon config", + params: params, + cfg: beacon.Eth2Config{}, + currentEpoch: 10000, + startEpoch: 9000, + endEpoch: 9999, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := IsChallengeable(tc.params, tc.cfg, tc.currentEpoch, tc.startEpoch, tc.endEpoch) + if got != tc.want { + t.Errorf("IsChallengeable(%+v, current %d, [%d, %d]) = %v, want %v", + tc.params, tc.currentEpoch, tc.startEpoch, tc.endEpoch, got, tc.want) + } + }) + } +} + +func TestExceedsChallengeThreshold(t *testing.T) { + tests := []struct { + name string + totalEpochs uint64 + missedEpochs uint64 + thresholdPct float64 + want bool + }{ + { + name: "no epochs checked", + totalEpochs: 0, + missedEpochs: 0, + thresholdPct: 94.0, + want: false, + }, + { + name: "no missed epochs", + totalEpochs: 1024, + missedEpochs: 0, + thresholdPct: 94.0, + want: false, + }, + // 16/256 = 6.25% missed; the allowed slack is 100% - threshold. + { + name: "missed share exactly at the allowed slack", + totalEpochs: 256, + missedEpochs: 16, + thresholdPct: 93.75, + want: false, + }, + { + name: "missed share above the allowed slack", + totalEpochs: 256, + missedEpochs: 16, + thresholdPct: 94.0, + want: true, + }, + { + name: "missed share below the allowed slack", + totalEpochs: 256, + missedEpochs: 16, + thresholdPct: 93.0, + want: false, + }, + { + name: "every epoch missed", + totalEpochs: 256, + missedEpochs: 256, + thresholdPct: 94.0, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resp := &api.VerifyPerformanceResponse{ + TotalEpochs: tc.totalEpochs, + MissedEpochs: tc.missedEpochs, + PerformanceThresholdPct: tc.thresholdPct, + } + got := ExceedsChallengeThreshold(resp) + if got != tc.want { + t.Errorf("ExceedsChallengeThreshold(%d missed of %d, threshold %.2f%%) = %v, want %v", + tc.missedEpochs, tc.totalEpochs, tc.thresholdPct, got, tc.want) + } + }) + } +} + func TestEncodeParticipationBitset(t *testing.T) { tests := []struct { name string diff --git a/shared/types/api/minipool.go b/shared/types/api/minipool.go index 19490620d..d296c6b2c 100644 --- a/shared/types/api/minipool.go +++ b/shared/types/api/minipool.go @@ -323,6 +323,11 @@ type VerifyPerformanceResponse struct { // Participation is the challengeMegapool participation calldata (uint256[]): // the epochs [StartEpoch, EndEpoch] as a bitset, 1 = not-timely target vote. Participation []*big.Int `json:"participation"` + // Challengeable reports whether this check could back an on-chain challenge: + // performance exits are enabled, the range is exactly one performance period, + // it is recent enough to be within the proof buffer, and the validator's + // missed share of the period exceeds 100% - performance_threshold. + Challengeable bool `json:"challengeable"` } // VerifyPerformanceResult is one validator's entry in a batch verify-performance diff --git a/shared/utils/cli/verify-performance/verify-performance.go b/shared/utils/cli/verify-performance/verify-performance.go index 51aebd382..df44e4a33 100644 --- a/shared/utils/cli/verify-performance/verify-performance.go +++ b/shared/utils/cli/verify-performance/verify-performance.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + "github.com/rocket-pool/smartnode/shared/services/performance" "github.com/rocket-pool/smartnode/shared/services/rocketpool" "github.com/rocket-pool/smartnode/shared/types/api" "github.com/rocket-pool/smartnode/shared/utils/cli/prompt" @@ -16,7 +17,6 @@ import ( // the user to confirm, because each epoch issues a committees fetch plus a // few-dozen block fetches against the beacon node. const LargeEpochRangeWarning uint64 = 256 -const defaultPerformancePeriodEpochs = 44032 // ResolveEpochRange fills in defaults for the start/length range. If epochs // is 0, it defaults to the on-chain performance_period setting. Returns the @@ -29,7 +29,7 @@ func ResolveEpochRange(rp *rocketpool.Client, startEpoch, epochs uint64) (uint64 } epochs = settings.Performance.Period if epochs == 0 { - epochs = defaultPerformancePeriodEpochs + epochs = performance.DefaultPerformancePeriodEpochs } } endEpoch := startEpoch + epochs - 1 @@ -84,7 +84,11 @@ func PrintResult(resp api.VerifyPerformanceResponse, label string) { } if len(resp.MissedEpochList) > 0 { - fmt.Printf("\nMissed target epochs (challengeable):\n") + if resp.Challengeable { + fmt.Printf("\nMissed target epochs (challengeable):\n") + } else { + fmt.Printf("\nMissed target epochs (not challengeable):\n") + } printEpochList(resp.MissedEpochList) } } From 17e5e99fecc81e41f6807c515bc1e3daf97061c8 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:37:05 -0300 Subject: [PATCH 14/35] Create challenge groups and call challenge --- rocketpool-cli/megapool/verify-performance.go | 75 ++++++ .../api/megapool/challenge-performance.go | 223 ++++++++++++++++++ rocketpool/api/megapool/routes.go | 84 +++++++ .../node/defend-challenge-performance.go | 58 ----- shared/services/rocketpool/megapool.go | 66 ++++++ shared/types/api/node.go | 15 ++ .../verify-performance/verify-performance.go | 38 +++ .../verify-performance_test.go | 114 +++++++++ 8 files changed, 615 insertions(+), 58 deletions(-) create mode 100644 rocketpool/api/megapool/challenge-performance.go create mode 100644 shared/utils/cli/verify-performance/verify-performance_test.go diff --git a/rocketpool-cli/megapool/verify-performance.go b/rocketpool-cli/megapool/verify-performance.go index 244b61987..a4dd1c3ec 100644 --- a/rocketpool-cli/megapool/verify-performance.go +++ b/rocketpool-cli/megapool/verify-performance.go @@ -7,10 +7,14 @@ import ( "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/bindings/utils/eth" + "github.com/rocket-pool/smartnode/shared/services/gas" "github.com/rocket-pool/smartnode/shared/services/rocketpool" "github.com/rocket-pool/smartnode/shared/types/api" cliutils "github.com/rocket-pool/smartnode/shared/utils/cli" + "github.com/rocket-pool/smartnode/shared/utils/cli/prompt" verifyperf "github.com/rocket-pool/smartnode/shared/utils/cli/verify-performance" + "github.com/rocket-pool/smartnode/shared/utils/math" ) // validateMegapoolTargets checks that the verify-performance targets argument @@ -65,5 +69,76 @@ func verifyMegapoolPerformance(megapoolAddress common.Address, targetValidators return fmt.Sprintf("megapool validator %d", r.ValidatorId) }) verifyperf.PrintElapsed(elapsed) + + return challengePerformance(rp, megapoolAddress, resp, yes) +} + +// challengePerformance drives the on-chain challenge flow for the +// challengeable validators of a verify-performance run: it groups validators +// sharing the same missed epochs (one challengeMegapool call covers a whole +// group), then for each group confirms the RPL bond with the user, checks the +// node wallet balance, and submits the challenge after the gas confirmation. +func challengePerformance(rp *rocketpool.Client, megapoolAddress common.Address, resp api.VerifyPerformanceBatchResponse, yes bool) error { + groups := verifyperf.GroupChallengeable(resp.Results) + if len(groups) == 0 { + return nil + } + + settings, err := rp.PDAOGetSettings() + if err != nil { + return fmt.Errorf("error fetching pDAO settings for the challenge bond: %w", err) + } + if !settings.Saturn2Deployed { + fmt.Println("\nPerformance challenges are not available until Saturn 2 is deployed.") + return nil + } + bondRpl := math.RoundDown(eth.WeiToEth(settings.Performance.ChallengeBond), 6) + + for _, group := range groups { + ids := make([]string, len(group.ValidatorIds)) + for i, id := range group.ValidatorIds { + ids[i] = fmt.Sprint(id) + } + fmt.Printf("\nValidator id(s) %s missed the same %d target epoch(s) and can be challenged together.\n", strings.Join(ids, ", "), len(group.MissedEpochs)) + fmt.Printf("Challenging requires a bond of %.6f RPL.\n", bondRpl) + + if prompt.Declined(yes, "Do you want to challenge validator id(s) %s with a bond of %.6f RPL?", strings.Join(ids, ", "), bondRpl) { + fmt.Println("Skipped.") + continue + } + + can, err := rp.CanChallengeMegapoolPerformance(megapoolAddress, group.ValidatorIds, group.StartEpoch, group.Participation) + if err != nil { + return err + } + if can.InsufficientRplBalance { + fmt.Printf("The node wallet holds %.6f RPL but the challenge bond requires %.6f RPL. Skipping.\n", + math.RoundDown(eth.WeiToEth(can.RplBalance), 6), math.RoundDown(eth.WeiToEth(can.ChallengeBond), 6)) + continue + } + if !can.CanChallenge { + fmt.Println("The challenge cannot be submitted. Skipping.") + continue + } + + // Assign max fees + err = gas.AssignMaxFeeAndLimit(can.GasInfo, rp, yes) + if err != nil { + return err + } + + challengeResp, err := rp.ChallengeMegapoolPerformance(megapoolAddress, group.ValidatorIds, group.StartEpoch, group.Participation) + if err != nil { + return err + } + + fmt.Println("Submitting the performance challenge...") + cliutils.PrintTransactionHash(rp, challengeResp.TxHash) + if _, err = rp.WaitForTransaction(challengeResp.TxHash); err != nil { + return err + } + fmt.Printf("Successfully challenged validator id(s) %s.\n", strings.Join(ids, ", ")) + } + return nil } diff --git a/rocketpool/api/megapool/challenge-performance.go b/rocketpool/api/megapool/challenge-performance.go new file mode 100644 index 000000000..0cab3e540 --- /dev/null +++ b/rocketpool/api/megapool/challenge-performance.go @@ -0,0 +1,223 @@ +package megapool + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v3" + + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/node" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/bindings/tokens" + "github.com/rocket-pool/smartnode/rocketpool/api/response" + "github.com/rocket-pool/smartnode/rocketpool/api/snroute" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/wallet" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// canChallengePerformance checks whether the node can challenge the target-vote +// performance of a group of megapool validators: the node wallet must hold the +// performance_challenge_bond in RPL, and the challengeMegapool call must pass +// gas estimation with a fresh slot proof. +func canChallengePerformance( + c *cli.Command, + megapoolAddress common.Address, + validatorIds []uint32, + startEpoch uint64, + participation []*big.Int, +) (*api.CanChallengeMegapoolPerformanceResponse, error) { + if err := services.RequireNodeRegistered(c); err != nil { + return nil, err + } + if err := services.RequireBeaconClientSynced(c); err != nil { + return nil, err + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + response := api.CanChallengeMegapoolPerformanceResponse{} + + nodeAccount, err := w.GetNodeAccount() + if err != nil { + return nil, err + } + megapoolAddress, err = resolveChallengedMegapool(rp, nodeAccount.Address, megapoolAddress) + if err != nil { + return nil, err + } + + // Check the node wallet holds the challenge bond in RPL before doing any + // expensive proof work. + response.ChallengeBond, err = protocol.GetPerformanceChallengeBond(rp, nil) + if err != nil { + return nil, err + } + response.RplBalance, err = tokens.GetRPLBalance(rp, nodeAccount.Address, nil) + if err != nil { + return nil, fmt.Errorf("error getting node RPL balance: %w", err) + } + if response.RplBalance.Cmp(response.ChallengeBond) < 0 { + response.InsufficientRplBalance = true + return &response, nil + } + + slotTimestamp, slotProof, err := getChallengeSlotProof(c, w, bc, rp, megapoolAddress, validatorIds) + if err != nil { + return nil, err + } + + opts, err := w.GetNodeAccountTransactor() + if err != nil { + return nil, err + } + response.GasInfo, err = megapool.EstimateChallengeMegapoolGas(rp, megapoolAddress, validatorIds, startEpoch, participation, slotTimestamp, slotProof, opts) + if err != nil { + return nil, fmt.Errorf("error estimating challengeMegapool gas: %w", err) + } + + response.CanChallenge = true + return &response, nil +} + +// challengePerformance submits the challengeMegapool transaction for a group +// of megapool validators. +func challengePerformance( + c *cli.Command, + megapoolAddress common.Address, + validatorIds []uint32, + startEpoch uint64, + participation []*big.Int, + opts *bind.TransactOpts, +) (*api.ChallengeMegapoolPerformanceResponse, error) { + if err := services.RequireNodeRegistered(c); err != nil { + return nil, err + } + if err := services.RequireBeaconClientSynced(c); err != nil { + return nil, err + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + response := api.ChallengeMegapoolPerformanceResponse{} + + nodeAccount, err := w.GetNodeAccount() + if err != nil { + return nil, err + } + megapoolAddress, err = resolveChallengedMegapool(rp, nodeAccount.Address, megapoolAddress) + if err != nil { + return nil, err + } + + slotTimestamp, slotProof, err := getChallengeSlotProof(c, w, bc, rp, megapoolAddress, validatorIds) + if err != nil { + return nil, err + } + + response.TxHash, err = megapool.ChallengeMegapool(rp, megapoolAddress, validatorIds, startEpoch, participation, slotTimestamp, slotProof, opts) + if err != nil { + return nil, err + } + + return &response, nil +} + +// resolveChallengedMegapool returns the megapool address to challenge, +// resolving the zero address to the node's own megapool. +func resolveChallengedMegapool(rp *rocketpool.RocketPool, nodeAddress common.Address, megapoolAddress common.Address) (common.Address, error) { + if (megapoolAddress != common.Address{}) { + return megapoolAddress, nil + } + megapoolAddress, err := node.GetMegapoolAddress(rp, nodeAddress, nil) + if err != nil { + return common.Address{}, fmt.Errorf("error looking up node's megapool address: %w", err) + } + if (megapoolAddress == common.Address{}) { + return common.Address{}, fmt.Errorf("node has no megapool deployed; pass a megapool address to challenge") + } + return megapoolAddress, nil +} + +// getChallengeSlotProof builds the beacon slot proof and timestamp required by +// challengeMegapool, anchored on the first challenged validator's pubkey (the +// challenge itself only consumes the slot proof, not the validator proof). +func getChallengeSlotProof( + c *cli.Command, + w wallet.Wallet, + bc beacon.Client, + rp *rocketpool.RocketPool, + megapoolAddress common.Address, + validatorIds []uint32, +) (uint64, megapool.SlotProof, error) { + if len(validatorIds) == 0 { + return 0, megapool.SlotProof{}, fmt.Errorf("no validator ids to challenge") + } + mp, err := megapool.NewMegaPoolV1(rp, megapoolAddress, nil) + if err != nil { + return 0, megapool.SlotProof{}, fmt.Errorf("error creating megapool binding for %s: %w", megapoolAddress.Hex(), err) + } + pubkey, err := mp.GetValidatorPubkey(validatorIds[0], nil) + if err != nil { + return 0, megapool.SlotProof{}, fmt.Errorf("error getting megapool validator %d pubkey: %w", validatorIds[0], err) + } + eth2Config, err := bc.GetEth2Config() + if err != nil { + return 0, megapool.SlotProof{}, fmt.Errorf("error getting beacon config: %w", err) + } + _, slotTimestamp, slotProof, err := services.GetValidatorProof(c, 0, w, eth2Config, megapoolAddress, pubkey, nil) + if err != nil { + return 0, megapool.SlotProof{}, fmt.Errorf("error building slot proof: %w", err) + } + return slotTimestamp, slotProof, nil +} + +func canChallengePerformanceHandler(ctx snroute.Context) { + megapoolAddr, validatorIds, startEpoch, participation, err := parseChallengePerformanceParams(ctx.Request) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + resp, err := canChallengePerformance(ctx.Command(), megapoolAddr, validatorIds, startEpoch, participation) + response.WriteResponse(ctx.Writer, resp, err) +} + +func challengePerformanceHandler(ctx snroute.WriteContext) { + megapoolAddr, validatorIds, startEpoch, participation, err := parseChallengePerformanceParams(ctx.Request) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + opts, err := ctx.Transactor() + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + resp, err := challengePerformance(ctx.Command(), megapoolAddr, validatorIds, startEpoch, participation, opts.Opts()) + response.WriteResponse(ctx.Writer, resp, err) +} diff --git a/rocketpool/api/megapool/routes.go b/rocketpool/api/megapool/routes.go index 44e026ed1..3acbc6175 100644 --- a/rocketpool/api/megapool/routes.go +++ b/rocketpool/api/megapool/routes.go @@ -5,6 +5,9 @@ import ( "math/big" "net/http" "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" "github.com/rocket-pool/smartnode/rocketpool/api/response" "github.com/rocket-pool/smartnode/rocketpool/api/snroute" @@ -49,6 +52,8 @@ func RegisterRoutes(router *snroute.Router) { snroute.Read("/api/megapool/latest-block-withdrawals", latestBlockWithdrawalsHandler).RegisterTo(router) snroute.Read("/api/megapool/beacon-withdrawal-queue-estimate", beaconWithdrawalQueueEstimateHandler).RegisterTo(router) snroute.Read("/api/megapool/verify-performance", verifyPerformanceHandler).RegisterTo(router) + snroute.Read("/api/megapool/can-challenge-performance", canChallengePerformanceHandler).RegisterTo(router) + snroute.Write("/api/megapool/challenge-performance", challengePerformanceHandler).RegisterTo(router) } func parseUint64(r *http.Request, name string) (uint64, error) { @@ -83,6 +88,85 @@ func parseBool(r *http.Request, name string) (bool, error) { return v, nil } +// parseChallengePerformanceParams parses the shared parameters of the +// can-challenge-performance and challenge-performance routes. megapoolAddress +// is optional (the zero address means "use the node's own megapool"). +func parseChallengePerformanceParams(r *http.Request) (common.Address, []uint32, uint64, []*big.Int, error) { + var megapoolAddr common.Address + if raw := r.URL.Query().Get("megapoolAddress"); raw != "" { + megapoolAddr = common.HexToAddress(raw) + } else if raw := r.FormValue("megapoolAddress"); raw != "" { + megapoolAddr = common.HexToAddress(raw) + } + validatorIds, err := parseUint32List(r, "validatorIds") + if err != nil { + return common.Address{}, nil, 0, nil, err + } + startEpoch, err := parseUint64(r, "startEpoch") + if err != nil { + return common.Address{}, nil, 0, nil, err + } + participation, err := parseBigIntList(r, "participation") + if err != nil { + return common.Address{}, nil, 0, nil, err + } + return megapoolAddr, validatorIds, startEpoch, participation, nil +} + +func parseUint32List(r *http.Request, name string) ([]uint32, error) { + raw := r.URL.Query().Get(name) + if raw == "" { + raw = r.FormValue(name) + } + if raw == "" { + return nil, &response.BadRequestError{Err: fmt.Errorf("missing required parameter '%s'", name)} + } + parts := strings.Split(raw, ",") + values := make([]uint32, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + v, err := strconv.ParseUint(part, 10, 32) + if err != nil { + return nil, &response.BadRequestError{Err: fmt.Errorf("invalid %s entry: %s", name, part)} + } + values = append(values, uint32(v)) + } + if len(values) == 0 { + return nil, &response.BadRequestError{Err: fmt.Errorf("no valid entries in parameter '%s'", name)} + } + return values, nil +} + +func parseBigIntList(r *http.Request, name string) ([]*big.Int, error) { + raw := r.URL.Query().Get(name) + if raw == "" { + raw = r.FormValue(name) + } + if raw == "" { + return nil, &response.BadRequestError{Err: fmt.Errorf("missing required parameter '%s'", name)} + } + parts := strings.Split(raw, ",") + values := make([]*big.Int, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + v, ok := new(big.Int).SetString(part, 10) + if !ok { + return nil, &response.BadRequestError{Err: fmt.Errorf("invalid %s entry: %s", name, part)} + } + values = append(values, v) + } + if len(values) == 0 { + return nil, &response.BadRequestError{Err: fmt.Errorf("no valid entries in parameter '%s'", name)} + } + return values, nil +} + func parseBigInt(r *http.Request, name string) (*big.Int, error) { raw := r.URL.Query().Get(name) if raw == "" { diff --git a/rocketpool/node/defend-challenge-performance.go b/rocketpool/node/defend-challenge-performance.go index 78e2623a4..1fabb8b63 100644 --- a/rocketpool/node/defend-challenge-performance.go +++ b/rocketpool/node/defend-challenge-performance.go @@ -8,7 +8,6 @@ import ( "github.com/docker/docker/client" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" - coretypes "github.com/ethereum/go-ethereum/core/types" "github.com/urfave/cli/v3" "github.com/rocket-pool/smartnode/bindings/megapool" @@ -23,7 +22,6 @@ import ( "github.com/rocket-pool/smartnode/shared/services/performance" "github.com/rocket-pool/smartnode/shared/services/state" "github.com/rocket-pool/smartnode/shared/services/wallet" - "github.com/rocket-pool/smartnode/shared/utils/api" "github.com/rocket-pool/smartnode/shared/utils/log" ) @@ -259,64 +257,8 @@ func (t *defendChallengePerformance) run(state *state.NetworkState) error { func (t *defendChallengePerformance) defendChallenge(rp *rocketpool.RocketPool, mp megapool.Megapool, validatorId uint32, state *state.NetworkState, validatorPubkey types.ValidatorPubkey, challengeEpoch uint64, callopts *bind.CallOpts) error { - // Get transactor - opts, err := t.w.GetNodeAccountTransactor() - if err != nil { - return err - } - t.log.Printlnf("Creating a validator performance proof that validator id %d participated in the epoch %v.", validatorId, challengeEpoch) - validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(t.c, 0, t.w, state.BeaconConfig, mp.GetAddress(), validatorPubkey, nil) - if err != nil { - t.log.Printlnf("[ERROR] There was an error during the proof creation process: %w", err) - return err - } - - t.log.Printlnf("The validator performance proof has been successfully created.") - var gasInfo rocketpool.GasInfo - - gasInfo, err = megapool.EstimateNotifyExitGas(rp, mp.GetAddress(), validatorId, slotTimestamp, validatorProof, slotProof, opts) - if err != nil { - return err - } - - gas := big.NewInt(int64(gasInfo.SafeGasLimit)) - // Get the max fee - maxFee := t.maxFee - if maxFee == nil || maxFee.Uint64() == 0 { - maxFee, err = rpgas.GetHeadlessMaxFeeWeiWithLatestBlock(t.cfg, t.rp) - if err != nil { - return err - } - } - - // Print the gas info - if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { - return nil - } - - opts.GasFeeCap = maxFee - opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) - opts.GasLimit = gas.Uint64() - - var tx *coretypes.Transaction - - t.log.Printlnf("Notifying that validator %d is exiting.", validatorId) - tx, err = megapool.NotifyExit(rp, mp.GetAddress(), validatorId, slotTimestamp, validatorProof, slotProof, opts) - if err != nil { - return err - } - - // Print TX info and wait for it to be included in a block - err = api.PrintAndWaitForTransaction(t.cfg, tx.Hash(), t.rp.Client, &t.log) - if err != nil { - return err - } - - // Log - t.log.Printlnf("Successfully responded the performance challenge for validator %d.", validatorId) - // Return return nil } diff --git a/shared/services/rocketpool/megapool.go b/shared/services/rocketpool/megapool.go index 45df550c7..dce97e71b 100644 --- a/shared/services/rocketpool/megapool.go +++ b/shared/services/rocketpool/megapool.go @@ -6,6 +6,7 @@ import ( "math/big" "net/url" "strconv" + "strings" "github.com/ethereum/go-ethereum/common" "github.com/goccy/go-json" @@ -43,6 +44,71 @@ func (c *Client) VerifyMegapoolValidatorPerformance(megapoolAddress common.Addre return response, nil } +// challengePerformanceValues builds the shared parameters of the +// can-challenge-performance and challenge-performance calls. validatorIds and +// participation are serialized as comma-separated decimal strings. +func challengePerformanceValues(megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int) url.Values { + ids := make([]string, len(validatorIds)) + for i, id := range validatorIds { + ids[i] = strconv.FormatUint(uint64(id), 10) + } + words := make([]string, len(participation)) + for i, word := range participation { + words[i] = word.String() + } + values := url.Values{ + "validatorIds": {strings.Join(ids, ",")}, + "startEpoch": {strconv.FormatUint(startEpoch, 10)}, + "participation": {strings.Join(words, ",")}, + } + if (megapoolAddress != common.Address{}) { + values.Set("megapoolAddress", megapoolAddress.Hex()) + } + return values +} + +// CanChallengeMegapoolPerformance checks whether the node can challenge the +// target-vote performance of a group of megapool validators, returning the +// challenge bond, the node's RPL balance, and the gas estimate. If +// megapoolAddress is the zero address the daemon resolves the node's own +// megapool. This call has no client-side deadline because the gas estimate +// requires downloading a beacon state to build the slot proof. +func (c *Client) CanChallengeMegapoolPerformance(megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int) (api.CanChallengeMegapoolPerformanceResponse, error) { + values := challengePerformanceValues(megapoolAddress, validatorIds, startEpoch, participation) + responseBytes, err := c.callHTTPAPICtx(context.Background(), "GET", "/api/megapool/can-challenge-performance", values) + if err != nil { + return api.CanChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not get can challenge megapool performance status: %w", err) + } + var response api.CanChallengeMegapoolPerformanceResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return api.CanChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not decode can challenge megapool performance response: %w", err) + } + if response.Error != "" { + return api.CanChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not get can challenge megapool performance status: %s", response.Error) + } + return response, nil +} + +// ChallengeMegapoolPerformance submits a target-vote performance challenge +// against a group of megapool validators. This call has no client-side +// deadline because the transaction requires downloading a beacon state to +// build the slot proof. +func (c *Client) ChallengeMegapoolPerformance(megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int) (api.ChallengeMegapoolPerformanceResponse, error) { + values := challengePerformanceValues(megapoolAddress, validatorIds, startEpoch, participation) + responseBytes, err := c.callHTTPAPICtx(context.Background(), "POST", "/api/megapool/challenge-performance", values) + if err != nil { + return api.ChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not challenge megapool performance: %w", err) + } + var response api.ChallengeMegapoolPerformanceResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return api.ChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not decode challenge megapool performance response: %w", err) + } + if response.Error != "" { + return api.ChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not challenge megapool performance: %s", response.Error) + } + return response, nil +} + // Get megapool status func (c *Client) MegapoolStatus(finalizedState bool) (api.MegapoolStatusResponse, error) { finalizedStr := "false" diff --git a/shared/types/api/node.go b/shared/types/api/node.go index 218accdd0..e9cf580e4 100644 --- a/shared/types/api/node.go +++ b/shared/types/api/node.go @@ -749,6 +749,21 @@ type NotifyValidatorExitResponse struct { TxHash common.Hash `json:"txHash"` } +type CanChallengeMegapoolPerformanceResponse struct { + Status string `json:"status"` + Error string `json:"error"` + CanChallenge bool `json:"canChallenge"` + InsufficientRplBalance bool `json:"insufficientRplBalance"` + ChallengeBond *big.Int `json:"challengeBond"` + RplBalance *big.Int `json:"rplBalance"` + GasInfo rocketpool.GasInfo `json:"gasInfo"` +} +type ChallengeMegapoolPerformanceResponse struct { + Status string `json:"status"` + Error string `json:"error"` + TxHash common.Hash `json:"txHash"` +} + type CanNotifyFinalBalanceResponse struct { APIResponse CanExit bool `json:"canExit"` diff --git a/shared/utils/cli/verify-performance/verify-performance.go b/shared/utils/cli/verify-performance/verify-performance.go index df44e4a33..061ce2586 100644 --- a/shared/utils/cli/verify-performance/verify-performance.go +++ b/shared/utils/cli/verify-performance/verify-performance.go @@ -5,6 +5,7 @@ package verifyperformance import ( "fmt" + "math/big" "time" "github.com/rocket-pool/smartnode/shared/services/performance" @@ -147,6 +148,43 @@ func PrintBatchResults(resp api.VerifyPerformanceBatchResponse, labelFor func(ap } } +// ChallengeGroup is a set of validators sharing an identical missed-epoch +// set, challengeable together in a single challengeMegapool call. +type ChallengeGroup struct { + ValidatorIds []uint32 + StartEpoch uint64 + Participation []*big.Int + MissedEpochs []uint64 +} + +// GroupChallengeable groups the challengeable validators of a batch result by +// identical missed-epoch sets, preserving the order in which they appear in +// the results. Errored, passing, and non-challengeable validators are +// excluded. +func GroupChallengeable(results []api.VerifyPerformanceResult) []ChallengeGroup { + groups := []ChallengeGroup{} + groupIndexByKey := map[string]int{} + for _, result := range results { + perf := result.Performance + if result.Error != "" || perf == nil || !perf.Challengeable || len(perf.MissedEpochList) == 0 { + continue + } + key := fmt.Sprint(perf.StartEpoch, perf.MissedEpochList) + if i, ok := groupIndexByKey[key]; ok { + groups[i].ValidatorIds = append(groups[i].ValidatorIds, result.ValidatorId) + continue + } + groupIndexByKey[key] = len(groups) + groups = append(groups, ChallengeGroup{ + ValidatorIds: []uint32{result.ValidatorId}, + StartEpoch: perf.StartEpoch, + Participation: perf.Participation, + MissedEpochs: perf.MissedEpochList, + }) + } + return groups +} + func printEpochList(epochs []uint64) { const perLine = 8 for i, e := range epochs { diff --git a/shared/utils/cli/verify-performance/verify-performance_test.go b/shared/utils/cli/verify-performance/verify-performance_test.go new file mode 100644 index 000000000..07c26cd3c --- /dev/null +++ b/shared/utils/cli/verify-performance/verify-performance_test.go @@ -0,0 +1,114 @@ +package verifyperformance + +import ( + "math/big" + "reflect" + "testing" + + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// challengeableResult builds a challengeable verify result for the tests. +func challengeableResult(validatorId uint32, startEpoch uint64, missedEpochs []uint64, participation []*big.Int) api.VerifyPerformanceResult { + return api.VerifyPerformanceResult{ + ValidatorId: validatorId, + Performance: &api.VerifyPerformanceResponse{ + StartEpoch: startEpoch, + MissedEpochList: missedEpochs, + Participation: participation, + Challengeable: true, + }, + } +} + +func TestGroupChallengeable(t *testing.T) { + missedA := []uint64{105000, 105003} + missedB := []uint64{105001} + participationA := []*big.Int{big.NewInt(9)} // bits 0 and 3 + participationB := []*big.Int{big.NewInt(2)} // bit 1 + + passing := api.VerifyPerformanceResult{ + ValidatorId: 90, + Performance: &api.VerifyPerformanceResponse{ + StartEpoch: 105000, + MissedEpochList: []uint64{}, + Challengeable: false, + }, + } + notChallengeable := api.VerifyPerformanceResult{ + ValidatorId: 91, + Performance: &api.VerifyPerformanceResponse{ + StartEpoch: 105000, + MissedEpochList: missedA, + Challengeable: false, + }, + } + errored := api.VerifyPerformanceResult{ + ValidatorId: 92, + Error: "validator not found", + } + + tests := []struct { + name string + results []api.VerifyPerformanceResult + want []ChallengeGroup + }{ + { + name: "no results", + results: []api.VerifyPerformanceResult{}, + want: []ChallengeGroup{}, + }, + { + name: "errored, passing and non-challengeable results are excluded", + results: []api.VerifyPerformanceResult{passing, notChallengeable, errored}, + want: []ChallengeGroup{}, + }, + { + name: "identical missed epochs merge into one group", + results: []api.VerifyPerformanceResult{ + challengeableResult(3, 105000, missedA, participationA), + challengeableResult(7, 105000, missedA, participationA), + }, + want: []ChallengeGroup{ + { + ValidatorIds: []uint32{3, 7}, + StartEpoch: 105000, + Participation: participationA, + MissedEpochs: missedA, + }, + }, + }, + { + name: "different missed epochs form separate groups in first-seen order", + results: []api.VerifyPerformanceResult{ + challengeableResult(3, 105000, missedA, participationA), + challengeableResult(5, 105000, missedB, participationB), + errored, + challengeableResult(7, 105000, missedA, participationA), + }, + want: []ChallengeGroup{ + { + ValidatorIds: []uint32{3, 7}, + StartEpoch: 105000, + Participation: participationA, + MissedEpochs: missedA, + }, + { + ValidatorIds: []uint32{5}, + StartEpoch: 105000, + Participation: participationB, + MissedEpochs: missedB, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := GroupChallengeable(tc.results) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("GroupChallengeable() = %+v, want %+v", got, tc.want) + } + }) + } +} From b1b50e48ca0051953b2f842960aa3e7badb73d4f Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:24:08 -0300 Subject: [PATCH 15/35] First pass implementing defendChallenge --- .../node/defend-challenge-performance.go | 61 ++++++- shared/services/megapools.go | 117 ++++++++++++ shared/services/megapools_test.go | 49 +++++ .../types/eth2/fork/electra/state_electra.go | 4 + shared/types/eth2/fork/fulu/state_fulu.go | 61 +++++++ shared/types/eth2/generic/state.go | 17 ++ shared/types/eth2/participation_proof_test.go | 171 ++++++++++++++++++ shared/types/eth2/types.go | 5 + 8 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 shared/types/eth2/participation_proof_test.go diff --git a/rocketpool/node/defend-challenge-performance.go b/rocketpool/node/defend-challenge-performance.go index 1fabb8b63..26bc5bbcf 100644 --- a/rocketpool/node/defend-challenge-performance.go +++ b/rocketpool/node/defend-challenge-performance.go @@ -22,6 +22,7 @@ import ( "github.com/rocket-pool/smartnode/shared/services/performance" "github.com/rocket-pool/smartnode/shared/services/state" "github.com/rocket-pool/smartnode/shared/services/wallet" + "github.com/rocket-pool/smartnode/shared/utils/api" "github.com/rocket-pool/smartnode/shared/utils/log" ) @@ -41,6 +42,7 @@ type defendChallengePerformance struct { } type megapoolPerformanceChallenge struct { + challengeId uint64 megapoolAddress common.Address validatorIds []uint32 startEpoch uint64 @@ -189,6 +191,7 @@ func (t *defendChallengePerformance) run(state *state.NetworkState) error { } // Use a megapool challenge stub for now challenge := megapoolPerformanceChallenge{ + challengeId: 0, megapoolAddress: megapoolAddress, validatorIds: []uint32{0, 1, 2}, participationCallData: participationCallData, @@ -246,7 +249,7 @@ func (t *defendChallengePerformance) run(state *state.NetworkState) error { // Defend the challenge using that validator id and epoch. defender := validatorsByIndex[validatorIndex] t.log.Printlnf("Megapool validator %d made a timely target vote in epoch %d; defending the performance challenge.", defender.validatorId, epoch) - if err := t.defendChallenge(t.rp, mp, defender.validatorId, state, defender.pubkey, epoch, opts); err != nil { + if err := t.defendChallenge(t.rp, challenge, defender, epoch); err != nil { t.log.Printlnf("error defending performance challenge for megapool validator %d: %v", defender.validatorId, err) } @@ -255,9 +258,61 @@ func (t *defendChallengePerformance) run(state *state.NetworkState) error { } -func (t *defendChallengePerformance) defendChallenge(rp *rocketpool.RocketPool, mp megapool.Megapool, validatorId uint32, state *state.NetworkState, validatorPubkey types.ValidatorPubkey, challengeEpoch uint64, callopts *bind.CallOpts) error { +// defendChallenge responds to a performance challenge with a participation +// proof of the defender's timely target vote in challengeEpoch. +func (t *defendChallengePerformance) defendChallenge(rp *rocketpool.RocketPool, challenge megapoolPerformanceChallenge, defender challengedValidator, challengeEpoch uint64) error { - t.log.Printlnf("Creating a validator performance proof that validator id %d participated in the epoch %v.", validatorId, challengeEpoch) + // Get transactor + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + + t.log.Printlnf("Creating a participation proof that validator index %d made a timely target vote in epoch %d.", defender.index, challengeEpoch) + + proofs, err := services.GetParticipationProof(t.c, defender.index, challengeEpoch) + if err != nil { + return fmt.Errorf("error creating the participation proof: %w", err) + } + + gasInfo, err := megapool.EstimateRespondGas(rp, challenge.challengeId, proofs.Offset, proofs.ChallengeLeaf, proofs.ChallengeWitness, proofs.SlotTimestamp, proofs.Participation, proofs.Slot, opts) + if err != nil { + return err + } + + gas := big.NewInt(int64(gasInfo.SafeGasLimit)) + // Get the max fee + maxFee := t.maxFee + if maxFee == nil || maxFee.Uint64() == 0 { + maxFee, err = rpgas.GetHeadlessMaxFeeWeiWithLatestBlock(t.cfg, t.rp) + if err != nil { + return err + } + } + + // Print the gas info + if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + return nil + } + + opts.GasFeeCap = maxFee + opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) + opts.GasLimit = gas.Uint64() + + t.log.Printlnf("Responding to challenge %d with the timely target vote of validator %d in epoch %d.", challenge.challengeId, defender.validatorId, challengeEpoch) + txHash, err := megapool.Respond(rp, challenge.challengeId, proofs.Offset, proofs.ChallengeLeaf, proofs.ChallengeWitness, proofs.SlotTimestamp, proofs.Participation, proofs.Slot, opts) + if err != nil { + return err + } + + // Print TX info and wait for it to be included in a block + err = api.PrintAndWaitForTransaction(t.cfg, txHash, t.rp.Client, &t.log) + if err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully responded to the performance challenge for validator %d.", defender.validatorId) // Return return nil diff --git a/shared/services/megapools.go b/shared/services/megapools.go index b8e639b7a..f0369ec22 100644 --- a/shared/services/megapools.go +++ b/shared/services/megapools.go @@ -30,6 +30,7 @@ import ( "github.com/rocket-pool/smartnode/bindings/utils/multicall" rpstate "github.com/rocket-pool/smartnode/bindings/utils/state" "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/performance" "github.com/rocket-pool/smartnode/shared/services/wallet" "github.com/rocket-pool/smartnode/shared/types/api" cfgtypes "github.com/rocket-pool/smartnode/shared/types/config" @@ -128,6 +129,122 @@ func GetValidatorProof(c *cli.Command, slot uint64, wallet wallet.Wallet, eth2Co return proof, slotTimestamp, slotProof, err } +// PerformanceDefenseProofs bundles everything the Respond call of a megapool +// performance challenge needs from the beacon state. +type PerformanceDefenseProofs struct { + // ChallengeLeaf is the previous_epoch_participation chunk containing the + // validator's flags byte — the merkle leaf of the participation proof — as + // a uint256. + ChallengeLeaf *big.Int + // ChallengeWitness holds the branch hashes from ChallengeLeaf up to the + // beacon block root anchored at SlotTimestamp via EIP-4788. + ChallengeWitness []common.Hash + // Offset is the validator's byte index within ChallengeLeaf (validatorIndex % 32) + Offset uint64 + SlotTimestamp uint64 + // Participation carries the participation metadata + Participation megapool.ParticipationProof + Slot megapool.SlotProof +} + +// participationProofSlotRange returns the inclusive slot range of epoch E+1, +// which is where challenged epoch E's participation flags live in +// previous_epoch_participation and are final by the end of the epoch. +func participationProofSlotRange(challengedEpoch uint64, slotsPerEpoch uint64) (firstSlot uint64, lastSlot uint64) { + firstSlot = (challengedEpoch + 1) * slotsPerEpoch + lastSlot = (challengedEpoch+2)*slotsPerEpoch - 1 + return firstSlot, lastSlot +} + +// GetParticipationProof builds the proofs needed to respond to a performance +// challenge with a validator's timely target vote in challengedEpoch. The +// proof state is the post-state of the last block in epoch challengedEpoch+1: +// attestations only enter the state via blocks, so that state holds the final +// previous_epoch_participation flags for the challenged epoch. +func GetParticipationProof(c *cli.Command, validatorIndex uint64, challengedEpoch uint64) (PerformanceDefenseProofs, error) { + bc, err := GetBeaconClient(c) + if err != nil { + return PerformanceDefenseProofs{}, err + } + eth2Config, err := bc.GetEth2Config() + if err != nil { + return PerformanceDefenseProofs{}, err + } + if eth2Config.SlotsPerEpoch == 0 { + return PerformanceDefenseProofs{}, fmt.Errorf("invalid beacon config: SlotsPerEpoch is 0") + } + + // Walk back from the last slot of epoch E+1 to the most recent slot with a + // block; its post-state holds the final participation flags for epoch E. + firstSlot, lastSlot := participationProofSlotRange(challengedEpoch, eth2Config.SlotsPerEpoch) + proofSlot := uint64(0) + proofSlotFound := false + for slot := lastSlot; slot >= firstSlot; slot-- { + _, exists, err := bc.GetBeaconBlockHeader(strconv.FormatUint(slot, 10)) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error getting beacon block header at slot %d: %w", slot, err) + } + if exists { + proofSlot = slot + proofSlotFound = true + break + } + } + if !proofSlotFound { + return PerformanceDefenseProofs{}, fmt.Errorf("no block found in epoch %d to prove the participation of epoch %d", challengedEpoch+1, challengedEpoch) + } + + stateResponse, err := bc.GetBeaconStateSSZ(proofSlot) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error getting beacon state at slot %d: %w", proofSlot, err) + } + beaconState, err := eth2.NewBeaconState(stateResponse.Data, stateResponse.Fork) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error parsing beacon state at slot %d: %w", proofSlot, err) + } + + participation := beaconState.GetPreviousEpochParticipation() + if validatorIndex >= uint64(len(participation)) { + return PerformanceDefenseProofs{}, fmt.Errorf("validator index %d out of bounds of the previous epoch participation list (%d entries)", validatorIndex, len(participation)) + } + flags := participation[validatorIndex] + if flags&(1<= uint64(len(state.PreviousEpochParticipation)) { + return [32]byte{}, 0, nil, nil, errors.New("validator index out of bounds of the previous epoch participation list") + } + + // Pack the expected leaf chunk locally: 32 participation flag bytes, + // zero-padded at the tail of the list. + chunkIndex := validatorIndex / 32 + chunkOffset := validatorIndex % 32 + var chunk [32]byte + copy(chunk[:], state.PreviousEpochParticipation[chunkIndex*32:]) + + stateTree, err := generic.SSZ.GetTree(state) + if err != nil { + return [32]byte{}, 0, nil, nil, fmt.Errorf("could not get state tree: %w", err) + } + + chunkGid := generic.GetGeneralizedIndexForParticipationChunk(chunkIndex, GetGeneralizedIndexForPreviousEpochParticipation()) + participationStateProof, err := stateTree.Prove(int(chunkGid)) + if err != nil { + return [32]byte{}, 0, nil, nil, fmt.Errorf("could not get proof for participation chunk: %w", err) + } + + // Sanity check that the proof leaf matches the locally packed chunk + if !bytes.Equal(participationStateProof.Leaf, chunk[:]) { + return [32]byte{}, 0, nil, nil, fmt.Errorf("proof leaf does not match expected participation chunk") + } + + slotStateProof, err := stateTree.Prove(int(GetGeneralizedIndexForSlot())) + if err != nil { + return [32]byte{}, 0, nil, nil, fmt.Errorf("could not get proof for slot: %w", err) + } + + // Drop the state tree before doing more work so the GC can reclaim it. + stateTree = nil + + blockHeaderProof, err := state.blockHeaderToStateProof(state.LatestBlockHeader) + if err != nil { + return [32]byte{}, 0, nil, nil, fmt.Errorf("could not get block header proof: %w", err) + } + + participationBranch := make([][]byte, 0, len(participationStateProof.Hashes)+len(blockHeaderProof)) + participationBranch = append(participationBranch, participationStateProof.Hashes...) + participationBranch = append(participationBranch, blockHeaderProof...) + + slotProof := make([][]byte, 0, len(slotStateProof.Hashes)+len(blockHeaderProof)) + slotProof = append(slotProof, slotStateProof.Hashes...) + slotProof = append(slotProof, blockHeaderProof...) + + return chunk, chunkOffset, participationBranch, slotProof, nil +} + // ValidatorAndSlotProof produces both the validator proof and the slot proof // for the state's current slot func (state *BeaconState) ValidatorAndSlotProof(validatorIndex uint64) ([][]byte, [][]byte, error) { diff --git a/shared/types/eth2/generic/state.go b/shared/types/eth2/generic/state.go index 2e9cc82a8..be0734f4c 100644 --- a/shared/types/eth2/generic/state.go +++ b/shared/types/eth2/generic/state.go @@ -23,6 +23,23 @@ const BeaconStateBlockRootsFieldIndex uint64 = 5 const BeaconStateStateRootsMaxLength uint64 = 1 << 13 const BeaconStateStateRootsFieldIndex uint64 = 6 +// BeaconStatePreviousEpochParticipationFieldIndex is the field offset of the +// PreviousEpochParticipation field in the BeaconState struct +const BeaconStatePreviousEpochParticipationFieldIndex uint64 = 15 + +// BeaconStateParticipationMaxChunks is the chunk count of the epoch +// participation byte lists: VALIDATOR_REGISTRY_LIMIT (2^40) one-byte flags +// packed 32 per chunk. +const BeaconStateParticipationMaxChunks uint64 = 1 << 35 + +// GetGeneralizedIndexForParticipationChunk returns the generalized index of +// the 32-byte chunk of a participation byte list, starting from the +// generalized index of the list field itself. +func GetGeneralizedIndexForParticipationChunk(chunkIndex uint64, participationFieldGid uint64) uint64 { + // Lists have a base index of 2 (the length mixin occupies the sibling). + return participationFieldGid*2*BeaconStateParticipationMaxChunks + chunkIndex +} + type PendingDeposit struct { Pubkey []byte `json:"pubkey" ssz-size:"48"` WithdrawalCredentials []byte `json:"withdrawal_credentials" ssz-size:"32"` diff --git a/shared/types/eth2/participation_proof_test.go b/shared/types/eth2/participation_proof_test.go new file mode 100644 index 000000000..1ca013c3e --- /dev/null +++ b/shared/types/eth2/participation_proof_test.go @@ -0,0 +1,171 @@ +package eth2 + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/rocket-pool/smartnode/shared/types/eth2/fork/fulu" + "github.com/rocket-pool/smartnode/shared/types/eth2/generic" +) + +// newTestFuluState builds a minimal but SSZ-valid fulu beacon state with +// numValidators validators and per-validator previous-epoch participation +// flags of (i % 8). +func newTestFuluState(t *testing.T, numValidators int, slot uint64) *fulu.BeaconState { + t.Helper() + + validators := make([]*generic.Validator, numValidators) + balances := make([]uint64, numValidators) + inactivityScores := make([]uint64, numValidators) + previousParticipation := make([]byte, numValidators) + currentParticipation := make([]byte, numValidators) + for i := range validators { + pubkey := make([]byte, 48) + pubkey[0] = byte(i + 1) + validators[i] = &generic.Validator{ + Pubkey: make([]byte, 48), + WithdrawalCredentials: make([]byte, 32), + EffectiveBalance: 32e9, + } + copy(validators[i].Pubkey, pubkey) + balances[i] = 32e9 + previousParticipation[i] = byte(i % 8) + currentParticipation[i] = byte((i + 1) % 8) + } + + randaoMixes := make([][]byte, 65536) + for i := range randaoMixes { + randaoMixes[i] = make([]byte, 32) + } + + syncCommittee := func() *generic.SyncCommittee { + pubkeys := make([][]byte, 512) + for i := range pubkeys { + pubkeys[i] = make([]byte, 48) + } + return &generic.SyncCommittee{PubKeys: pubkeys} + } + + parentRoot := make([]byte, 32) + parentRoot[0] = 0xaa + bodyRoot := make([]byte, 32) + bodyRoot[0] = 0xbb + + return &fulu.BeaconState{ + GenesisValidatorsRoot: make([]byte, 32), + Slot: slot, + Fork: &generic.Fork{ + PreviousVersion: make([]byte, 4), + CurrentVersion: make([]byte, 4), + }, + LatestBlockHeader: &generic.BeaconBlockHeader{ + Slot: slot, + ProposerIndex: 1, + ParentRoot: parentRoot, + StateRoot: make([]byte, 32), + BodyRoot: bodyRoot, + }, + HistoricalRoots: [][]byte{}, + Eth1Data: &generic.Eth1Data{ + DepositRoot: make([]byte, 32), + BlockHash: make([]byte, 32), + }, + Eth1DataVotes: []*generic.Eth1Data{}, + Validators: validators, + Balances: balances, + RandaoMixes: randaoMixes, + Slashings: make([]uint64, 8192), + PreviousEpochParticipation: previousParticipation, + CurrentEpochParticipation: currentParticipation, + PreviousJustifiedCheckpoint: &generic.Checkpoint{Root: make([]byte, 32)}, + CurrentJustifiedCheckpoint: &generic.Checkpoint{Root: make([]byte, 32)}, + FinalizedCheckpoint: &generic.Checkpoint{Root: make([]byte, 32)}, + InactivityScores: inactivityScores, + CurrentSyncCommittee: syncCommittee(), + NextSyncCommittee: syncCommittee(), + LatestExecutionPayloadHeader: &generic.ExecutionPayloadHeader{}, + HistoricalSummaries: []*generic.HistoricalSummary{}, + ProposerLookahead: make([]uint64, 64), + } +} + +// validateFuluStateProof walks a merged state+block-header proof from leaf to +// root and checks it lands on the state's block root. Returns the state root +// and block root. +func validateFuluStateProof(t *testing.T, leaf []byte, proof [][]byte, gid uint64, state *fulu.BeaconState) ([]byte, []byte) { + t.Helper() + + // State proofs are merged with the block-header proof, so the effective + // tree is rooted at the beacon block header. + gid = offsetGidRoot(gid, generic.BeaconBlockHeaderStateRootGeneralizedIndex) + currentHash := leaf + + for i, proofRow := range proof { + // The last neighbor must have a gid of either 2 or 3 + if i == len(proof)-1 { + if gid != 2 && gid != 3 { + t.Fatalf("last node/neighbor gid must be 2 or 3, got: %d", gid) + } + } + neighborIsLeft := gid%2 == 1 + gid /= 2 + currentHash = hash(currentHash, proofRow, neighborIsLeft) + } + + // Compute the expected block root: the latest block header with the state + // root filled in. + stateRoot, err := state.HashTreeRoot() + if err != nil { + t.Fatalf("Failed to get state root: %v", err) + } + header := *state.LatestBlockHeader + header.StateRoot = stateRoot[:] + blockRoot, err := header.HashTreeRoot() + if err != nil { + t.Fatalf("Failed to get block root: %v", err) + } + + if !bytes.Equal(currentHash, blockRoot[:]) { + t.Fatalf("final hash %x does not match block root %x", currentHash, blockRoot) + } + return stateRoot[:], blockRoot[:] +} + +func TestPreviousEpochParticipationAndSlotProof(t *testing.T) { + const numValidators = 100 + const slot = uint64(105002*32 + 31) // last slot of some epoch + state := newTestFuluState(t, numValidators, slot) + + // Validators in different chunks of the participation byte list (32 flag + // bytes per chunk), including the last validator (partially filled chunk). + for _, validatorIndex := range []uint64{0, 31, 32, 70, numValidators - 1} { + chunk, chunkOffset, participationBranch, slotProof, err := state.PreviousEpochParticipationAndSlotProof(validatorIndex) + if err != nil { + t.Fatalf("PreviousEpochParticipationAndSlotProof(%d) failed: %v", validatorIndex, err) + } + + if chunkOffset != validatorIndex%32 { + t.Fatalf("chunkOffset = %d, want %d", chunkOffset, validatorIndex%32) + } + + // The chunk must hold the validator's flags byte at its in-chunk offset. + if chunk[chunkOffset] != state.PreviousEpochParticipation[validatorIndex] { + t.Fatalf("chunk byte %d = %x, want %x", chunkOffset, chunk[chunkOffset], state.PreviousEpochParticipation[validatorIndex]) + } + + // The participation branch must connect the chunk to the block root. + chunkGid := generic.GetGeneralizedIndexForParticipationChunk(validatorIndex/32, fulu.GetGeneralizedIndexForPreviousEpochParticipation()) + validateFuluStateProof(t, chunk[:], participationBranch, chunkGid, state) + + // The slot proof must connect the slot leaf to the same block root. + slotLeaf := make([]byte, 32) + binary.LittleEndian.PutUint64(slotLeaf, state.Slot) + validateFuluStateProof(t, slotLeaf, slotProof, fulu.GetGeneralizedIndexForSlot(), state) + } + + // Out-of-bounds validator index must error. + if _, _, _, _, err := state.PreviousEpochParticipationAndSlotProof(numValidators); err == nil { + t.Fatalf("expected an error for an out-of-bounds validator index") + } +} diff --git a/shared/types/eth2/types.go b/shared/types/eth2/types.go index d3f0076e6..cd895896b 100644 --- a/shared/types/eth2/types.go +++ b/shared/types/eth2/types.go @@ -32,6 +32,11 @@ type BeaconState interface { BlockHeaderProof() ([][]byte, error) GetValidators() []*generic.Validator GetPreviousEpochParticipation() []byte + // PreviousEpochParticipationAndSlotProof proves the previous_epoch_participation + // chunk containing validatorIndex's flags, plus the state slot, both anchored + // at the block-header root. chunk is the 32-byte merkle leaf; chunkOffset is + // the validator's byte index within that chunk (validatorIndex % 32) + PreviousEpochParticipationAndSlotProof(validatorIndex uint64) (chunk [32]byte, chunkOffset uint64, participationProofBytes [][]byte, slotProof [][]byte, err error) } type SignedBeaconBlock interface { From f9d5fd4b70e771a060ca1215b4859d06ccc8dfd2 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:53:06 -0300 Subject: [PATCH 16/35] Call finaliseChallenge on old enough challenges --- .../node/defend-challenge-performance.go | 174 +++++++++++++----- 1 file changed, 126 insertions(+), 48 deletions(-) diff --git a/rocketpool/node/defend-challenge-performance.go b/rocketpool/node/defend-challenge-performance.go index 26bc5bbcf..1e562a213 100644 --- a/rocketpool/node/defend-challenge-performance.go +++ b/rocketpool/node/defend-challenge-performance.go @@ -4,6 +4,7 @@ import ( "fmt" "math/big" "strconv" + "time" "github.com/docker/docker/client" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -12,6 +13,7 @@ import ( "github.com/rocket-pool/smartnode/bindings/megapool" "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/settings/protocol" "github.com/rocket-pool/smartnode/bindings/types" "github.com/rocket-pool/smartnode/bindings/utils/eth" @@ -47,6 +49,7 @@ type megapoolPerformanceChallenge struct { validatorIds []uint32 startEpoch uint64 participationCallData []*big.Int + challengeTimestamp time.Time } // challengedValidator holds a challenged megapool validator's on-chain id @@ -183,6 +186,12 @@ func (t *defendChallengePerformance) run(state *state.NetworkState) error { return err } + // Get the performance challenge period + performanceChallengePeriod, err := protocol.GetPerformanceChallengePeriod(t.rp, opts) + if err != nil { + return err + } + // TODO: Fetch megapool challenges participationCallData := []*big.Int{new(big.Int).Sub( @@ -190,72 +199,141 @@ func (t *defendChallengePerformance) run(state *state.NetworkState) error { big.NewInt(1)), } // Use a megapool challenge stub for now - challenge := megapoolPerformanceChallenge{ - challengeId: 0, - megapoolAddress: megapoolAddress, - validatorIds: []uint32{0, 1, 2}, - participationCallData: participationCallData, - startEpoch: 105000, - } - - challengedEpochs := challenge.getChallengedEpochs() - t.log.Printlnf("Challenged epochs: %v", challengedEpochs) - - // Resolve every challenged validator's pubkey and beacon-chain index up - // front so the per-epoch beacon data can be fetched once and shared across - // all of them when verifying target-vote participation. - validatorsByIndex := make(map[uint64]challengedValidator, len(challenge.validatorIds)) - validatorIndices := make([]uint64, 0, len(challenge.validatorIds)) - for _, validatorId := range challenge.validatorIds { - pubkey, err := mp.GetValidatorPubkey(validatorId, opts) - if err != nil { - t.log.Printlnf("error getting pubkey for megapool validator %d: %v", validatorId, err) - continue - } - beaconStatus, err := t.bc.GetValidatorStatus(pubkey, nil) - if err != nil { - t.log.Printlnf("error getting beacon status for megapool validator %d (%s): %v", validatorId, pubkey.Hex(), err) + challenges := []megapoolPerformanceChallenge{ + { + challengeId: 0, + megapoolAddress: megapoolAddress, + validatorIds: []uint32{0, 1, 2}, + participationCallData: participationCallData, + startEpoch: 105000, + challengeTimestamp: time.Now(), + }, + } + + for _, challenge := range challenges { + + // Old challenges can be finalised + if time.Since(challenge.challengeTimestamp) > performanceChallengePeriod { + t.log.Printlnf("Challenge %d has been open for longer than the performance challenge period; finalising it.", challenge.challengeId) + if err := t.finaliseChallenge(challenge); err != nil { + t.log.Printlnf("error finalising performance challenge %d: %v", challenge.challengeId, err) + } continue } - if !beaconStatus.Exists || beaconStatus.Index == "" { - t.log.Printlnf("Megapool validator %d (%s) is not on the beacon chain yet, skipping.", validatorId, pubkey.Hex()) - continue + + challengedEpochs := challenge.getChallengedEpochs() + t.log.Printlnf("Challenged epochs: %v", challengedEpochs) + + // Resolve every challenged validator's pubkey and beacon-chain index up + // front so the per-epoch beacon data can be fetched once and shared across + // all of them when verifying target-vote participation. + validatorsByIndex := make(map[uint64]challengedValidator, len(challenge.validatorIds)) + validatorIndices := make([]uint64, 0, len(challenge.validatorIds)) + for _, validatorId := range challenge.validatorIds { + pubkey, err := mp.GetValidatorPubkey(validatorId, opts) + if err != nil { + t.log.Printlnf("error getting pubkey for megapool validator %d: %v", validatorId, err) + continue + } + beaconStatus, err := t.bc.GetValidatorStatus(pubkey, nil) + if err != nil { + t.log.Printlnf("error getting beacon status for megapool validator %d (%s): %v", validatorId, pubkey.Hex(), err) + continue + } + if !beaconStatus.Exists || beaconStatus.Index == "" { + t.log.Printlnf("Megapool validator %d (%s) is not on the beacon chain yet, skipping.", validatorId, pubkey.Hex()) + continue + } + validatorIndex, err := strconv.ParseUint(beaconStatus.Index, 10, 64) + if err != nil { + t.log.Printlnf("error parsing beacon index %q for megapool validator %d: %v", beaconStatus.Index, validatorId, err) + continue + } + validatorsByIndex[validatorIndex] = challengedValidator{ + validatorId: validatorId, + pubkey: pubkey, + index: validatorIndex, + } + validatorIndices = append(validatorIndices, validatorIndex) } - validatorIndex, err := strconv.ParseUint(beaconStatus.Index, 10, 64) + + // Find the first challenged validator that made a successful target vote + // within the challenged range. A single (validator, epoch) proof is enough + // to defend the challenge + validatorIndex, epoch, found, err := performance.FindFirstTimelyTargetVote(t.bc, state.BeaconConfig, validatorIndices, challengedEpochs) if err != nil { - t.log.Printlnf("error parsing beacon index %q for megapool validator %d: %v", beaconStatus.Index, validatorId, err) + return fmt.Errorf("error verifying target-vote participation for challenged megapool validators: %w", err) + } + if !found { + t.log.Println("No challenged validator made a successful target vote in the challenged epochs.") continue } - validatorsByIndex[validatorIndex] = challengedValidator{ - validatorId: validatorId, - pubkey: pubkey, - index: validatorIndex, + + // Defend the challenge using that validator id and epoch. + defender := validatorsByIndex[validatorIndex] + t.log.Printlnf("Megapool validator %d made a timely target vote in epoch %d; defending the performance challenge.", defender.validatorId, epoch) + if err := t.defendChallenge(t.rp, challenge, defender, epoch); err != nil { + t.log.Printlnf("error defending performance challenge for megapool validator %d: %v", defender.validatorId, err) } - validatorIndices = append(validatorIndices, validatorIndex) } - // Find the first challenged validator that made a successful target vote - // within the challenged range. A single (validator, epoch) proof is enough - // to defend the challenge - validatorIndex, epoch, found, err := performance.FindFirstTimelyTargetVote(t.bc, state.BeaconConfig, validatorIndices, challengedEpochs) + // Return + return nil + +} + +// finaliseChallenge settles a performance challenge that has been open for +// longer than the performance challenge period. +func (t *defendChallengePerformance) finaliseChallenge(challenge megapoolPerformanceChallenge) error { + + // Get transactor + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + + // Get the gas limit + gasInfo, err := megapool.EstimateFinaliseChallengeGas(t.rp, challenge.challengeId, opts) if err != nil { - return fmt.Errorf("error verifying target-vote participation for challenged megapool validators: %w", err) + return fmt.Errorf("could not estimate the gas required to finalise challenge %d: %w", challenge.challengeId, err) + } + gas := big.NewInt(int64(gasInfo.SafeGasLimit)) + + // Get the max fee + maxFee := t.maxFee + if maxFee == nil || maxFee.Uint64() == 0 { + maxFee, err = rpgas.GetHeadlessMaxFeeWeiWithLatestBlock(t.cfg, t.rp) + if err != nil { + return err + } } - if !found { - t.log.Println("No challenged validator made a successful target vote in the challenged epochs.") + + // Print the gas info + if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { return nil } - // Defend the challenge using that validator id and epoch. - defender := validatorsByIndex[validatorIndex] - t.log.Printlnf("Megapool validator %d made a timely target vote in epoch %d; defending the performance challenge.", defender.validatorId, epoch) - if err := t.defendChallenge(t.rp, challenge, defender, epoch); err != nil { - t.log.Printlnf("error defending performance challenge for megapool validator %d: %v", defender.validatorId, err) + opts.GasFeeCap = maxFee + opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) + opts.GasLimit = gas.Uint64() + + // Finalise the challenge + txHash, err := megapool.FinaliseChallenge(t.rp, challenge.challengeId, opts) + if err != nil { + return err } + // Print TX info and wait for it to be included in a block + err = api.PrintAndWaitForTransaction(t.cfg, txHash, t.rp.Client, &t.log) + if err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully finalised performance challenge %d.", challenge.challengeId) + // Return return nil - } // defendChallenge responds to a performance challenge with a participation From 6ee9d5c911f8cc8c167c0f2ad178644e48d3f13b Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:01:36 -0300 Subject: [PATCH 17/35] Calculate participationBitmapWitness --- bindings/megapool/performance.go | 43 ++++- rocketpool-cli/megapool/verify-performance.go | 75 ++++---- .../api/megapool/challenge-performance.go | 37 ++-- rocketpool/api/megapool/routes.go | 39 +--- .../node/defend-challenge-performance.go | 166 +++++++++++++----- shared/services/megapools.go | 144 ++++++++++++--- shared/services/megapools_test.go | 117 ++++++++++++ shared/services/rocketpool/megapool.go | 24 ++- 8 files changed, 461 insertions(+), 184 deletions(-) diff --git a/bindings/megapool/performance.go b/bindings/megapool/performance.go index 18b291c89..e6d96d7b3 100644 --- a/bindings/megapool/performance.go +++ b/bindings/megapool/performance.go @@ -11,42 +11,67 @@ import ( ) // Estimate the gas to call ChallengeMegapool -func EstimateChallengeMegapoolGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateChallengeMegapoolGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { return rocketpool.GasInfo{}, err } - return rocketNetworkParticipation.GetTransactionGasInfo(opts, "challengeMegapool", megapoolAddress, validatorIds, startEpoch, participation, slotTimestamp, slotProof) + return rocketNetworkParticipation.GetTransactionGasInfo(opts, "challengeMegapool", megapoolAddress, validatorId, startEpoch, participation, slotTimestamp, slotProof) } // Challenge the megapool -func ChallengeMegapool(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { +func ChallengeMegapool(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { return common.Hash{}, err } - tx, err := rocketNetworkParticipation.Transact(opts, "challengeMegapool", megapoolAddress, validatorIds, startEpoch, participation, slotTimestamp, slotProof) + tx, err := rocketNetworkParticipation.Transact(opts, "challengeMegapool", megapoolAddress, validatorId, startEpoch, participation, slotTimestamp, slotProof) if err != nil { return common.Hash{}, fmt.Errorf("error challenging megapool: %w", err) } return tx.Hash(), nil } -// Estimate the gas to call Respond -func EstimateRespondGas(rp *rocketpool.RocketPool, challengeId uint64, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +// Estimate the gas to call RespondWithParticipation +func EstimateRespondWithParticipationGas(rp *rocketpool.RocketPool, challengeId uint64, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, validatorProof ValidatorProof, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { return rocketpool.GasInfo{}, err } - return rocketNetworkParticipation.GetTransactionGasInfo(opts, "respond", challengeId, offset, challengeLeaf, challengeWitness, slotTimestamp, participationProof, slotProof) + return rocketNetworkParticipation.GetTransactionGasInfo(opts, "respondWithParticipation", challengeId, offset, challengeLeaf, challengeWitness, slotTimestamp, validatorProof, participationProof, slotProof) } -func Respond(rp *rocketpool.RocketPool, challengeId uint64, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { +// Respond to a performance challenge with a proof that the validator did +// participate in one of the challenged epochs +func RespondWithParticipation(rp *rocketpool.RocketPool, challengeId uint64, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, validatorProof ValidatorProof, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { return common.Hash{}, err } - tx, err := rocketNetworkParticipation.Transact(opts, "respond", challengeId, offset, challengeLeaf, challengeWitness, slotTimestamp, participationProof, slotProof) + tx, err := rocketNetworkParticipation.Transact(opts, "respondWithParticipation", challengeId, offset, challengeLeaf, challengeWitness, slotTimestamp, validatorProof, participationProof, slotProof) + if err != nil { + return common.Hash{}, fmt.Errorf("error responding to challenge: %w", err) + } + return tx.Hash(), nil +} + +// Estimate the gas to call RespondWithValidator +func EstimateRespondWithValidatorGas(rp *rocketpool.RocketPool, challengeId uint64, slotTimestamp uint64, validatorProof ValidatorProof, slotProof SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return rocketpool.GasInfo{}, err + } + return rocketNetworkParticipation.GetTransactionGasInfo(opts, "respondWithValidator", challengeId, slotTimestamp, validatorProof, slotProof) +} + +// Respond to a performance challenge with a proof that the validator was not +// staking for the entire challenge period +func RespondWithValidator(rp *rocketpool.RocketPool, challengeId uint64, slotTimestamp uint64, validatorProof ValidatorProof, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkParticipation.Transact(opts, "respondWithValidator", challengeId, slotTimestamp, validatorProof, slotProof) if err != nil { return common.Hash{}, fmt.Errorf("error responding to challenge: %w", err) } diff --git a/rocketpool-cli/megapool/verify-performance.go b/rocketpool-cli/megapool/verify-performance.go index a4dd1c3ec..74e04ac53 100644 --- a/rocketpool-cli/megapool/verify-performance.go +++ b/rocketpool-cli/megapool/verify-performance.go @@ -75,9 +75,10 @@ func verifyMegapoolPerformance(megapoolAddress common.Address, targetValidators // challengePerformance drives the on-chain challenge flow for the // challengeable validators of a verify-performance run: it groups validators -// sharing the same missed epochs (one challengeMegapool call covers a whole -// group), then for each group confirms the RPL bond with the user, checks the -// node wallet balance, and submits the challenge after the gas confirmation. +// sharing the same missed epochs so they can be confirmed together, then +// submits one challengeMegapool call per validator (challenges are +// per-validator on-chain, each requiring its own RPL bond) after the gas +// confirmation. func challengePerformance(rp *rocketpool.Client, megapoolAddress common.Address, resp api.VerifyPerformanceBatchResponse, yes bool) error { groups := verifyperf.GroupChallengeable(resp.Results) if len(groups) == 0 { @@ -99,45 +100,47 @@ func challengePerformance(rp *rocketpool.Client, megapoolAddress common.Address, for i, id := range group.ValidatorIds { ids[i] = fmt.Sprint(id) } - fmt.Printf("\nValidator id(s) %s missed the same %d target epoch(s) and can be challenged together.\n", strings.Join(ids, ", "), len(group.MissedEpochs)) - fmt.Printf("Challenging requires a bond of %.6f RPL.\n", bondRpl) + fmt.Printf("\nValidator id(s) %s missed the same %d target epoch(s).\n", strings.Join(ids, ", "), len(group.MissedEpochs)) + fmt.Printf("Each validator is challenged individually and requires a bond of %.6f RPL.\n", bondRpl) - if prompt.Declined(yes, "Do you want to challenge validator id(s) %s with a bond of %.6f RPL?", strings.Join(ids, ", "), bondRpl) { + if prompt.Declined(yes, "Do you want to challenge validator id(s) %s with a bond of %.6f RPL each?", strings.Join(ids, ", "), bondRpl) { fmt.Println("Skipped.") continue } - can, err := rp.CanChallengeMegapoolPerformance(megapoolAddress, group.ValidatorIds, group.StartEpoch, group.Participation) - if err != nil { - return err - } - if can.InsufficientRplBalance { - fmt.Printf("The node wallet holds %.6f RPL but the challenge bond requires %.6f RPL. Skipping.\n", - math.RoundDown(eth.WeiToEth(can.RplBalance), 6), math.RoundDown(eth.WeiToEth(can.ChallengeBond), 6)) - continue - } - if !can.CanChallenge { - fmt.Println("The challenge cannot be submitted. Skipping.") - continue - } - - // Assign max fees - err = gas.AssignMaxFeeAndLimit(can.GasInfo, rp, yes) - if err != nil { - return err - } - - challengeResp, err := rp.ChallengeMegapoolPerformance(megapoolAddress, group.ValidatorIds, group.StartEpoch, group.Participation) - if err != nil { - return err - } - - fmt.Println("Submitting the performance challenge...") - cliutils.PrintTransactionHash(rp, challengeResp.TxHash) - if _, err = rp.WaitForTransaction(challengeResp.TxHash); err != nil { - return err + for _, validatorId := range group.ValidatorIds { + can, err := rp.CanChallengeMegapoolPerformance(megapoolAddress, validatorId, group.StartEpoch, group.Participation) + if err != nil { + return err + } + if can.InsufficientRplBalance { + fmt.Printf("The node wallet holds %.6f RPL but the challenge bond requires %.6f RPL. Skipping validator %d.\n", + math.RoundDown(eth.WeiToEth(can.RplBalance), 6), math.RoundDown(eth.WeiToEth(can.ChallengeBond), 6), validatorId) + continue + } + if !can.CanChallenge { + fmt.Printf("The challenge for validator %d cannot be submitted. Skipping.\n", validatorId) + continue + } + + // Assign max fees + err = gas.AssignMaxFeeAndLimit(can.GasInfo, rp, yes) + if err != nil { + return err + } + + challengeResp, err := rp.ChallengeMegapoolPerformance(megapoolAddress, validatorId, group.StartEpoch, group.Participation) + if err != nil { + return err + } + + fmt.Printf("Submitting the performance challenge for validator %d...\n", validatorId) + cliutils.PrintTransactionHash(rp, challengeResp.TxHash) + if _, err = rp.WaitForTransaction(challengeResp.TxHash); err != nil { + return err + } + fmt.Printf("Successfully challenged validator %d.\n", validatorId) } - fmt.Printf("Successfully challenged validator id(s) %s.\n", strings.Join(ids, ", ")) } return nil diff --git a/rocketpool/api/megapool/challenge-performance.go b/rocketpool/api/megapool/challenge-performance.go index 0cab3e540..ec6109afe 100644 --- a/rocketpool/api/megapool/challenge-performance.go +++ b/rocketpool/api/megapool/challenge-performance.go @@ -22,13 +22,13 @@ import ( ) // canChallengePerformance checks whether the node can challenge the target-vote -// performance of a group of megapool validators: the node wallet must hold the +// performance of a megapool validator: the node wallet must hold the // performance_challenge_bond in RPL, and the challengeMegapool call must pass // gas estimation with a fresh slot proof. func canChallengePerformance( c *cli.Command, megapoolAddress common.Address, - validatorIds []uint32, + validatorId uint32, startEpoch uint64, participation []*big.Int, ) (*api.CanChallengeMegapoolPerformanceResponse, error) { @@ -77,7 +77,7 @@ func canChallengePerformance( return &response, nil } - slotTimestamp, slotProof, err := getChallengeSlotProof(c, w, bc, rp, megapoolAddress, validatorIds) + slotTimestamp, slotProof, err := getChallengeSlotProof(c, w, bc, rp, megapoolAddress, validatorId) if err != nil { return nil, err } @@ -86,7 +86,7 @@ func canChallengePerformance( if err != nil { return nil, err } - response.GasInfo, err = megapool.EstimateChallengeMegapoolGas(rp, megapoolAddress, validatorIds, startEpoch, participation, slotTimestamp, slotProof, opts) + response.GasInfo, err = megapool.EstimateChallengeMegapoolGas(rp, megapoolAddress, validatorId, startEpoch, participation, slotTimestamp, slotProof, opts) if err != nil { return nil, fmt.Errorf("error estimating challengeMegapool gas: %w", err) } @@ -95,12 +95,12 @@ func canChallengePerformance( return &response, nil } -// challengePerformance submits the challengeMegapool transaction for a group -// of megapool validators. +// challengePerformance submits the challengeMegapool transaction for a +// megapool validator. func challengePerformance( c *cli.Command, megapoolAddress common.Address, - validatorIds []uint32, + validatorId uint32, startEpoch uint64, participation []*big.Int, opts *bind.TransactOpts, @@ -135,12 +135,12 @@ func challengePerformance( return nil, err } - slotTimestamp, slotProof, err := getChallengeSlotProof(c, w, bc, rp, megapoolAddress, validatorIds) + slotTimestamp, slotProof, err := getChallengeSlotProof(c, w, bc, rp, megapoolAddress, validatorId) if err != nil { return nil, err } - response.TxHash, err = megapool.ChallengeMegapool(rp, megapoolAddress, validatorIds, startEpoch, participation, slotTimestamp, slotProof, opts) + response.TxHash, err = megapool.ChallengeMegapool(rp, megapoolAddress, validatorId, startEpoch, participation, slotTimestamp, slotProof, opts) if err != nil { return nil, err } @@ -165,7 +165,7 @@ func resolveChallengedMegapool(rp *rocketpool.RocketPool, nodeAddress common.Add } // getChallengeSlotProof builds the beacon slot proof and timestamp required by -// challengeMegapool, anchored on the first challenged validator's pubkey (the +// challengeMegapool, anchored on the challenged validator's pubkey (the // challenge itself only consumes the slot proof, not the validator proof). func getChallengeSlotProof( c *cli.Command, @@ -173,18 +173,15 @@ func getChallengeSlotProof( bc beacon.Client, rp *rocketpool.RocketPool, megapoolAddress common.Address, - validatorIds []uint32, + validatorId uint32, ) (uint64, megapool.SlotProof, error) { - if len(validatorIds) == 0 { - return 0, megapool.SlotProof{}, fmt.Errorf("no validator ids to challenge") - } mp, err := megapool.NewMegaPoolV1(rp, megapoolAddress, nil) if err != nil { return 0, megapool.SlotProof{}, fmt.Errorf("error creating megapool binding for %s: %w", megapoolAddress.Hex(), err) } - pubkey, err := mp.GetValidatorPubkey(validatorIds[0], nil) + pubkey, err := mp.GetValidatorPubkey(validatorId, nil) if err != nil { - return 0, megapool.SlotProof{}, fmt.Errorf("error getting megapool validator %d pubkey: %w", validatorIds[0], err) + return 0, megapool.SlotProof{}, fmt.Errorf("error getting megapool validator %d pubkey: %w", validatorId, err) } eth2Config, err := bc.GetEth2Config() if err != nil { @@ -198,17 +195,17 @@ func getChallengeSlotProof( } func canChallengePerformanceHandler(ctx snroute.Context) { - megapoolAddr, validatorIds, startEpoch, participation, err := parseChallengePerformanceParams(ctx.Request) + megapoolAddr, validatorId, startEpoch, participation, err := parseChallengePerformanceParams(ctx.Request) if err != nil { response.WriteErrorResponse(ctx.Writer, err) return } - resp, err := canChallengePerformance(ctx.Command(), megapoolAddr, validatorIds, startEpoch, participation) + resp, err := canChallengePerformance(ctx.Command(), megapoolAddr, validatorId, startEpoch, participation) response.WriteResponse(ctx.Writer, resp, err) } func challengePerformanceHandler(ctx snroute.WriteContext) { - megapoolAddr, validatorIds, startEpoch, participation, err := parseChallengePerformanceParams(ctx.Request) + megapoolAddr, validatorId, startEpoch, participation, err := parseChallengePerformanceParams(ctx.Request) if err != nil { response.WriteErrorResponse(ctx.Writer, err) return @@ -218,6 +215,6 @@ func challengePerformanceHandler(ctx snroute.WriteContext) { response.WriteErrorResponse(ctx.Writer, err) return } - resp, err := challengePerformance(ctx.Command(), megapoolAddr, validatorIds, startEpoch, participation, opts.Opts()) + resp, err := challengePerformance(ctx.Command(), megapoolAddr, validatorId, startEpoch, participation, opts.Opts()) response.WriteResponse(ctx.Writer, resp, err) } diff --git a/rocketpool/api/megapool/routes.go b/rocketpool/api/megapool/routes.go index 3acbc6175..129af9d50 100644 --- a/rocketpool/api/megapool/routes.go +++ b/rocketpool/api/megapool/routes.go @@ -91,53 +91,26 @@ func parseBool(r *http.Request, name string) (bool, error) { // parseChallengePerformanceParams parses the shared parameters of the // can-challenge-performance and challenge-performance routes. megapoolAddress // is optional (the zero address means "use the node's own megapool"). -func parseChallengePerformanceParams(r *http.Request) (common.Address, []uint32, uint64, []*big.Int, error) { +func parseChallengePerformanceParams(r *http.Request) (common.Address, uint32, uint64, []*big.Int, error) { var megapoolAddr common.Address if raw := r.URL.Query().Get("megapoolAddress"); raw != "" { megapoolAddr = common.HexToAddress(raw) } else if raw := r.FormValue("megapoolAddress"); raw != "" { megapoolAddr = common.HexToAddress(raw) } - validatorIds, err := parseUint32List(r, "validatorIds") + validatorId, err := parseUint32(r, "validatorId") if err != nil { - return common.Address{}, nil, 0, nil, err + return common.Address{}, 0, 0, nil, err } startEpoch, err := parseUint64(r, "startEpoch") if err != nil { - return common.Address{}, nil, 0, nil, err + return common.Address{}, 0, 0, nil, err } participation, err := parseBigIntList(r, "participation") if err != nil { - return common.Address{}, nil, 0, nil, err + return common.Address{}, 0, 0, nil, err } - return megapoolAddr, validatorIds, startEpoch, participation, nil -} - -func parseUint32List(r *http.Request, name string) ([]uint32, error) { - raw := r.URL.Query().Get(name) - if raw == "" { - raw = r.FormValue(name) - } - if raw == "" { - return nil, &response.BadRequestError{Err: fmt.Errorf("missing required parameter '%s'", name)} - } - parts := strings.Split(raw, ",") - values := make([]uint32, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - v, err := strconv.ParseUint(part, 10, 32) - if err != nil { - return nil, &response.BadRequestError{Err: fmt.Errorf("invalid %s entry: %s", name, part)} - } - values = append(values, uint32(v)) - } - if len(values) == 0 { - return nil, &response.BadRequestError{Err: fmt.Errorf("no valid entries in parameter '%s'", name)} - } - return values, nil + return megapoolAddr, validatorId, startEpoch, participation, nil } func parseBigIntList(r *http.Request, name string) ([]*big.Int, error) { diff --git a/rocketpool/node/defend-challenge-performance.go b/rocketpool/node/defend-challenge-performance.go index 1e562a213..5d8c30261 100644 --- a/rocketpool/node/defend-challenge-performance.go +++ b/rocketpool/node/defend-challenge-performance.go @@ -46,7 +46,7 @@ type defendChallengePerformance struct { type megapoolPerformanceChallenge struct { challengeId uint64 megapoolAddress common.Address - validatorIds []uint32 + validatorId uint32 startEpoch uint64 participationCallData []*big.Int challengeTimestamp time.Time @@ -192,6 +192,12 @@ func (t *defendChallengePerformance) run(state *state.NetworkState) error { return err } + // Get the performance measurement period (in epochs) + performancePeriod, err := protocol.GetPerformancePeriod(t.rp, opts) + if err != nil { + return err + } + // TODO: Fetch megapool challenges participationCallData := []*big.Int{new(big.Int).Sub( @@ -203,7 +209,7 @@ func (t *defendChallengePerformance) run(state *state.NetworkState) error { { challengeId: 0, megapoolAddress: megapoolAddress, - validatorIds: []uint32{0, 1, 2}, + validatorId: 0, participationCallData: participationCallData, startEpoch: 105000, challengeTimestamp: time.Now(), @@ -224,57 +230,59 @@ func (t *defendChallengePerformance) run(state *state.NetworkState) error { challengedEpochs := challenge.getChallengedEpochs() t.log.Printlnf("Challenged epochs: %v", challengedEpochs) - // Resolve every challenged validator's pubkey and beacon-chain index up - // front so the per-epoch beacon data can be fetched once and shared across - // all of them when verifying target-vote participation. - validatorsByIndex := make(map[uint64]challengedValidator, len(challenge.validatorIds)) - validatorIndices := make([]uint64, 0, len(challenge.validatorIds)) - for _, validatorId := range challenge.validatorIds { - pubkey, err := mp.GetValidatorPubkey(validatorId, opts) - if err != nil { - t.log.Printlnf("error getting pubkey for megapool validator %d: %v", validatorId, err) - continue - } - beaconStatus, err := t.bc.GetValidatorStatus(pubkey, nil) - if err != nil { - t.log.Printlnf("error getting beacon status for megapool validator %d (%s): %v", validatorId, pubkey.Hex(), err) - continue - } - if !beaconStatus.Exists || beaconStatus.Index == "" { - t.log.Printlnf("Megapool validator %d (%s) is not on the beacon chain yet, skipping.", validatorId, pubkey.Hex()) - continue - } - validatorIndex, err := strconv.ParseUint(beaconStatus.Index, 10, 64) - if err != nil { - t.log.Printlnf("error parsing beacon index %q for megapool validator %d: %v", beaconStatus.Index, validatorId, err) - continue - } - validatorsByIndex[validatorIndex] = challengedValidator{ - validatorId: validatorId, - pubkey: pubkey, - index: validatorIndex, - } - validatorIndices = append(validatorIndices, validatorIndex) + // Resolve the challenged validator's pubkey and beacon-chain index + pubkey, err := mp.GetValidatorPubkey(challenge.validatorId, opts) + if err != nil { + t.log.Printlnf("error getting pubkey for megapool validator %d: %v", challenge.validatorId, err) + continue + } + beaconStatus, err := t.bc.GetValidatorStatus(pubkey, nil) + if err != nil { + t.log.Printlnf("error getting beacon status for megapool validator %d (%s): %v", challenge.validatorId, pubkey.Hex(), err) + continue + } + if !beaconStatus.Exists || beaconStatus.Index == "" { + t.log.Printlnf("Megapool validator %d (%s) is not on the beacon chain yet, skipping.", challenge.validatorId, pubkey.Hex()) + continue + } + validatorIndex, err := strconv.ParseUint(beaconStatus.Index, 10, 64) + if err != nil { + t.log.Printlnf("error parsing beacon index %q for megapool validator %d: %v", beaconStatus.Index, challenge.validatorId, err) + continue + } + defender := challengedValidator{ + validatorId: challenge.validatorId, + pubkey: pubkey, + index: validatorIndex, } - // Find the first challenged validator that made a successful target vote - // within the challenged range. A single (validator, epoch) proof is enough - // to defend the challenge - validatorIndex, epoch, found, err := performance.FindFirstTimelyTargetVote(t.bc, state.BeaconConfig, validatorIndices, challengedEpochs) + // Find an epoch in the challenged range where the validator made a + // timely target vote. A single (validator, epoch) proof is enough to + // defend the challenge + _, epoch, found, err := performance.FindFirstTimelyTargetVote(t.bc, state.BeaconConfig, []uint64{validatorIndex}, challengedEpochs) if err != nil { - return fmt.Errorf("error verifying target-vote participation for challenged megapool validators: %w", err) + return fmt.Errorf("error verifying target-vote participation for challenged megapool validator %d: %w", challenge.validatorId, err) } - if !found { - t.log.Println("No challenged validator made a successful target vote in the challenged epochs.") + if found { + t.log.Printlnf("Megapool validator %d made a timely target vote in epoch %d; defending the performance challenge.", defender.validatorId, epoch) + if err := t.defendChallenge(t.rp, challenge, defender, epoch); err != nil { + t.log.Printlnf("error defending performance challenge for megapool validator %d: %v", defender.validatorId, err) + } continue } - // Defend the challenge using that validator id and epoch. - defender := validatorsByIndex[validatorIndex] - t.log.Printlnf("Megapool validator %d made a timely target vote in epoch %d; defending the performance challenge.", defender.validatorId, epoch) - if err := t.defendChallenge(t.rp, challenge, defender, epoch); err != nil { - t.log.Printlnf("error defending performance challenge for megapool validator %d: %v", defender.validatorId, err) + // No timely target vote found. If the validator was not staking for + // the entire challenge window, the challenge can still be defeated + // with a validator proof + if beaconStatus.ActivationEpoch > challenge.startEpoch || beaconStatus.WithdrawableEpoch <= challenge.startEpoch+performancePeriod { + t.log.Printlnf("Megapool validator %d was not staking during the challenge window; responding with a validator proof.", defender.validatorId) + if err := t.respondWithValidator(challenge, defender, state); err != nil { + t.log.Printlnf("error responding to performance challenge %d with a validator proof: %v", challenge.challengeId, err) + } + continue } + + t.log.Printlnf("No defense available for performance challenge %d: validator %d made no timely target vote in the challenged epochs and was staking during the challenge window.", challenge.challengeId, defender.validatorId) } // Return @@ -336,6 +344,68 @@ func (t *defendChallengePerformance) finaliseChallenge(challenge megapoolPerform return nil } +// respondWithValidator responds to a performance challenge with a validator +// proof showing the defender was not staking during the challenge window. +func (t *defendChallengePerformance) respondWithValidator(challenge megapoolPerformanceChallenge, defender challengedValidator, state *state.NetworkState) error { + + // Get transactor + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + + t.log.Printlnf("Creating a validator proof for megapool validator %d.", defender.validatorId) + + // Build a fresh validator proof against the head state to satisfy the + // contract's slot recency requirement + validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(t.c, 0, t.w, state.BeaconConfig, challenge.megapoolAddress, defender.pubkey, nil) + if err != nil { + return fmt.Errorf("error creating the validator proof: %w", err) + } + + gasInfo, err := megapool.EstimateRespondWithValidatorGas(t.rp, challenge.challengeId, slotTimestamp, validatorProof, slotProof, opts) + if err != nil { + return err + } + + gas := big.NewInt(int64(gasInfo.SafeGasLimit)) + // Get the max fee + maxFee := t.maxFee + if maxFee == nil || maxFee.Uint64() == 0 { + maxFee, err = rpgas.GetHeadlessMaxFeeWeiWithLatestBlock(t.cfg, t.rp) + if err != nil { + return err + } + } + + // Print the gas info + if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + return nil + } + + opts.GasFeeCap = maxFee + opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) + opts.GasLimit = gas.Uint64() + + t.log.Printlnf("Responding to challenge %d with a validator proof for validator %d.", challenge.challengeId, defender.validatorId) + txHash, err := megapool.RespondWithValidator(t.rp, challenge.challengeId, slotTimestamp, validatorProof, slotProof, opts) + if err != nil { + return err + } + + // Print TX info and wait for it to be included in a block + err = api.PrintAndWaitForTransaction(t.cfg, txHash, t.rp.Client, &t.log) + if err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully responded to the performance challenge for validator %d.", defender.validatorId) + + // Return + return nil +} + // defendChallenge responds to a performance challenge with a participation // proof of the defender's timely target vote in challengeEpoch. func (t *defendChallengePerformance) defendChallenge(rp *rocketpool.RocketPool, challenge megapoolPerformanceChallenge, defender challengedValidator, challengeEpoch uint64) error { @@ -348,12 +418,12 @@ func (t *defendChallengePerformance) defendChallenge(rp *rocketpool.RocketPool, t.log.Printlnf("Creating a participation proof that validator index %d made a timely target vote in epoch %d.", defender.index, challengeEpoch) - proofs, err := services.GetParticipationProof(t.c, defender.index, challengeEpoch) + proofs, err := services.GetParticipationProof(t.c, defender.index, defender.pubkey, challengeEpoch, challenge.startEpoch, challenge.participationCallData) if err != nil { return fmt.Errorf("error creating the participation proof: %w", err) } - gasInfo, err := megapool.EstimateRespondGas(rp, challenge.challengeId, proofs.Offset, proofs.ChallengeLeaf, proofs.ChallengeWitness, proofs.SlotTimestamp, proofs.Participation, proofs.Slot, opts) + gasInfo, err := megapool.EstimateRespondWithParticipationGas(rp, challenge.challengeId, proofs.Offset, proofs.ChallengeLeaf, proofs.ChallengeWitness, proofs.SlotTimestamp, proofs.Validator, proofs.Participation, proofs.Slot, opts) if err != nil { return err } @@ -378,7 +448,7 @@ func (t *defendChallengePerformance) defendChallenge(rp *rocketpool.RocketPool, opts.GasLimit = gas.Uint64() t.log.Printlnf("Responding to challenge %d with the timely target vote of validator %d in epoch %d.", challenge.challengeId, defender.validatorId, challengeEpoch) - txHash, err := megapool.Respond(rp, challenge.challengeId, proofs.Offset, proofs.ChallengeLeaf, proofs.ChallengeWitness, proofs.SlotTimestamp, proofs.Participation, proofs.Slot, opts) + txHash, err := megapool.RespondWithParticipation(rp, challenge.challengeId, proofs.Offset, proofs.ChallengeLeaf, proofs.ChallengeWitness, proofs.SlotTimestamp, proofs.Validator, proofs.Participation, proofs.Slot, opts) if err != nil { return err } diff --git a/shared/services/megapools.go b/shared/services/megapools.go index f0369ec22..eb4026fb8 100644 --- a/shared/services/megapools.go +++ b/shared/services/megapools.go @@ -2,6 +2,7 @@ package services import ( "context" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -129,24 +130,76 @@ func GetValidatorProof(c *cli.Command, slot uint64, wallet wallet.Wallet, eth2Co return proof, slotTimestamp, slotProof, err } -// PerformanceDefenseProofs bundles everything the Respond call of a megapool -// performance challenge needs from the beacon state. +// PerformanceDefenseProofs bundles everything the RespondWithParticipation +// call of a megapool performance challenge needs. type PerformanceDefenseProofs struct { - // ChallengeLeaf is the previous_epoch_participation chunk containing the - // validator's flags byte — the merkle leaf of the participation proof — as - // a uint256. + // Offset is the epoch offset of the disproven epoch from the challenge's + // start epoch (challengedEpoch = startEpoch + Offset) + Offset uint64 + // ChallengeLeaf is the word of the challenge participation bitmap that + // contains the Offset bit (LSB-first: bit Offset % 256 must be set, + // meaning the epoch was challenged as missed) ChallengeLeaf *big.Int - // ChallengeWitness holds the branch hashes from ChallengeLeaf up to the - // beacon block root anchored at SlotTimestamp via EIP-4788. + // ChallengeWitness is the sha256 merkle branch proving ChallengeLeaf is + // part of the challenge bitmap root stored on-chain ChallengeWitness []common.Hash - // Offset is the validator's byte index within ChallengeLeaf (validatorIndex % 32) - Offset uint64 - SlotTimestamp uint64 + SlotTimestamp uint64 + // Validator ties the beacon validator index to the challenged validator's + // pubkey, anchored to the same slot as the participation proof + Validator megapool.ValidatorProof // Participation carries the participation metadata Participation megapool.ParticipationProof Slot megapool.SlotProof } +// Number of epochs encoded per challenge participation bitmap word (a +// Solidity uint256) +const bitsPerParticipationWord = 256 + +// participationBitmapWitness builds the merkle branch proving that the word +// at leafIndex is part of the challenge participation bitmap tree. It mirrors +// RocketNetworkParticipation.hashTree/restoreMerkleRoot: the leaves are the +// raw uint256 bitmap words, zero-padded to the next power of two, hashed +// pairwise with sha256. +func participationBitmapWitness(participation []*big.Int, leafIndex uint64) ([]common.Hash, error) { + leafCount := uint64(len(participation)) + if leafCount == 0 { + return nil, fmt.Errorf("the participation bitmap is empty") + } + if leafIndex >= leafCount { + return nil, fmt.Errorf("leaf index %d out of bounds of the participation bitmap (%d words)", leafIndex, leafCount) + } + + width := uint64(1) + for width < leafCount { + width *= 2 + } + + level := make([][32]byte, width) + for i, word := range participation { + if word.Sign() < 0 || word.BitLen() > bitsPerParticipationWord { + return nil, fmt.Errorf("participation bitmap word %d is not a uint256", i) + } + word.FillBytes(level[i][:]) + } + + witness := []common.Hash{} + index := leafIndex + for len(level) > 1 { + witness = append(witness, common.Hash(level[index^1])) + next := make([][32]byte, len(level)/2) + for i := range next { + var pair [64]byte + copy(pair[:32], level[2*i][:]) + copy(pair[32:], level[2*i+1][:]) + next[i] = sha256.Sum256(pair[:]) + } + level = next + index /= 2 + } + return witness, nil +} + // participationProofSlotRange returns the inclusive slot range of epoch E+1, // which is where challenged epoch E's participation flags live in // previous_epoch_participation and are final by the end of the epoch. @@ -158,10 +211,30 @@ func participationProofSlotRange(challengedEpoch uint64, slotsPerEpoch uint64) ( // GetParticipationProof builds the proofs needed to respond to a performance // challenge with a validator's timely target vote in challengedEpoch. The -// proof state is the post-state of the last block in epoch challengedEpoch+1: +// challenge is identified by its start epoch and participation bitmap +// (challengedEpoch must be marked as missed in the bitmap). The proof state +// is the post-state of the last block in epoch challengedEpoch+1: // attestations only enter the state via blocks, so that state holds the final // previous_epoch_participation flags for the challenged epoch. -func GetParticipationProof(c *cli.Command, validatorIndex uint64, challengedEpoch uint64) (PerformanceDefenseProofs, error) { +func GetParticipationProof(c *cli.Command, validatorIndex uint64, validatorPubkey types.ValidatorPubkey, challengedEpoch uint64, startEpoch uint64, participation []*big.Int) (PerformanceDefenseProofs, error) { + // Locate the challenged epoch within the challenge participation bitmap + if challengedEpoch < startEpoch { + return PerformanceDefenseProofs{}, fmt.Errorf("challenged epoch %d is before the challenge start epoch %d", challengedEpoch, startEpoch) + } + offset := challengedEpoch - startEpoch + leafIndex := offset / bitsPerParticipationWord + if leafIndex >= uint64(len(participation)) { + return PerformanceDefenseProofs{}, fmt.Errorf("epoch %d (offset %d) is out of bounds of the challenge participation bitmap (%d words)", challengedEpoch, offset, len(participation)) + } + challengeLeaf := participation[leafIndex] + if challengeLeaf.Bit(int(offset%bitsPerParticipationWord)) != 1 { + return PerformanceDefenseProofs{}, fmt.Errorf("epoch %d (offset %d) was not challenged as missed in the participation bitmap", challengedEpoch, offset) + } + challengeWitness, err := participationBitmapWitness(participation, leafIndex) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error building the challenge bitmap witness: %w", err) + } + bc, err := GetBeaconClient(c) if err != nil { return PerformanceDefenseProofs{}, err @@ -203,35 +276,58 @@ func GetParticipationProof(c *cli.Command, validatorIndex uint64, challengedEpoc return PerformanceDefenseProofs{}, fmt.Errorf("error parsing beacon state at slot %d: %w", proofSlot, err) } - participation := beaconState.GetPreviousEpochParticipation() - if validatorIndex >= uint64(len(participation)) { - return PerformanceDefenseProofs{}, fmt.Errorf("validator index %d out of bounds of the previous epoch participation list (%d entries)", validatorIndex, len(participation)) + epochParticipation := beaconState.GetPreviousEpochParticipation() + if validatorIndex >= uint64(len(epochParticipation)) { + return PerformanceDefenseProofs{}, fmt.Errorf("validator index %d out of bounds of the previous epoch participation list (%d entries)", validatorIndex, len(epochParticipation)) } - flags := participation[validatorIndex] + flags := epochParticipation[validatorIndex] if flags&(1<= uint64(len(validators)) { + return PerformanceDefenseProofs{}, fmt.Errorf("validator index %d out of bounds of the validator set (%d entries)", validatorIndex, len(validators)) } + validator := validators[validatorIndex] + var withdrawalCredentialsFixed [32]byte + copy(withdrawalCredentialsFixed[:], validator.WithdrawalCredentials) - challengeWitness := make([]common.Hash, len(participationProofBytes)) - for i, h := range participationProofBytes { - challengeWitness[i] = common.BytesToHash(h) + slotTimestamp, err := GetChildBlockTimestampForSlot(c, beaconState.GetSlot()) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error getting the slot timestamp: %w", err) } return PerformanceDefenseProofs{ - ChallengeLeaf: new(big.Int).SetBytes(chunk[:]), + Offset: offset, + ChallengeLeaf: challengeLeaf, ChallengeWitness: challengeWitness, - Offset: chunkOffset, SlotTimestamp: slotTimestamp, + Validator: megapool.ValidatorProof{ + ValidatorIndex: new(big.Int).SetUint64(validatorIndex), + Validator: megapool.ProvedValidator{ + Pubkey: validatorPubkey[:], + WithdrawalCredentials: withdrawalCredentialsFixed, + EffectiveBalance: validator.EffectiveBalance, + Slashed: validator.Slashed, + ActivationEligibilityEpoch: validator.ActivationEligibilityEpoch, + ActivationEpoch: validator.ActivationEpoch, + ExitEpoch: validator.ExitEpoch, + WithdrawableEpoch: validator.WithdrawableEpoch, + }, + Witnesses: ConvertToFixedSize(validatorProofBytes), + }, Participation: megapool.ParticipationProof{ ParticipationSlot: beaconState.GetSlot(), ValidatorIndex: validatorIndex, diff --git a/shared/services/megapools_test.go b/shared/services/megapools_test.go index c2e4fa4f3..354f6991e 100644 --- a/shared/services/megapools_test.go +++ b/shared/services/megapools_test.go @@ -1,6 +1,7 @@ package services import ( + "crypto/sha256" "math/big" "testing" @@ -264,3 +265,119 @@ func TestParticipationProofSlotRange(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// participationBitmapWitness +// --------------------------------------------------------------------------- + +// testHashTree mirrors RocketNetworkParticipation.hashTree: raw uint256 words +// as leaves, zero-padded to the next power of two, hashed pairwise with +// sha256. +func testHashTree(t *testing.T, leaves []*big.Int) [32]byte { + t.Helper() + width := 1 + for width < len(leaves) { + width *= 2 + } + tree := make([][32]byte, width) + for i, leaf := range leaves { + leaf.FillBytes(tree[i][:]) + } + for width > 1 { + for i := 0; i < width; i += 2 { + var pair [64]byte + copy(pair[:32], tree[i][:]) + copy(pair[32:], tree[i+1][:]) + tree[i/2] = sha256.Sum256(pair[:]) + } + width /= 2 + } + return tree[0] +} + +// testRestoreMerkleRoot mirrors RocketNetworkParticipation.restoreMerkleRoot: +// walk the generalized index from the leaf to the root, hashing with the +// witnesses. +func testRestoreMerkleRoot(t *testing.T, leaf [32]byte, gindex uint64, witnesses []common.Hash) [32]byte { + t.Helper() + if 1<<(len(witnesses)+1) <= gindex { + t.Fatalf("invalid witness length %d for gindex %d", len(witnesses), gindex) + } + value := leaf + i := 0 + for gindex != 1 { + var pair [64]byte + if gindex%2 == 1 { + copy(pair[:32], witnesses[i][:]) + copy(pair[32:], value[:]) + } else { + copy(pair[:32], value[:]) + copy(pair[32:], witnesses[i][:]) + } + value = sha256.Sum256(pair[:]) + gindex /= 2 + i++ + } + return value +} + +func TestParticipationBitmapWitness(t *testing.T) { + for _, wordCount := range []int{1, 2, 3, 5, 8} { + // Deterministic, distinct bitmap words + participation := make([]*big.Int, wordCount) + for i := range participation { + word := new(big.Int).Lsh(big.NewInt(int64(i)+1), 13) + word.Add(word, big.NewInt(int64(i)*7+1)) + participation[i] = word + } + + root := testHashTree(t, participation) + width := uint64(1) + for width < uint64(wordCount) { + width *= 2 + } + + for leafIndex := uint64(0); leafIndex < uint64(wordCount); leafIndex++ { + witness, err := participationBitmapWitness(participation, leafIndex) + if err != nil { + t.Fatalf("wordCount %d leafIndex %d: unexpected error: %v", wordCount, leafIndex, err) + } + + var leaf [32]byte + participation[leafIndex].FillBytes(leaf[:]) + + // The contract computes the generalized index as + // nextPowerOfTwo(leafCount) + leafIndex + gindex := width + leafIndex + restored := testRestoreMerkleRoot(t, leaf, gindex, witness) + if restored != root { + t.Fatalf("wordCount %d leafIndex %d: restored root %x does not match tree root %x", wordCount, leafIndex, restored, root) + } + } + } +} + +func TestParticipationBitmapWitnessSingleWord(t *testing.T) { + participation := []*big.Int{big.NewInt(0b1011)} + witness, err := participationBitmapWitness(participation, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // A single-word bitmap has an empty branch: the root is the word itself + if len(witness) != 0 { + t.Fatalf("expected empty witness for a single-word bitmap, got %d hashes", len(witness)) + } +} + +func TestParticipationBitmapWitnessErrors(t *testing.T) { + if _, err := participationBitmapWitness([]*big.Int{}, 0); err == nil { + t.Fatal("expected an error for an empty bitmap") + } + if _, err := participationBitmapWitness([]*big.Int{big.NewInt(1)}, 1); err == nil { + t.Fatal("expected an error for an out-of-bounds leaf index") + } + tooBig := new(big.Int).Lsh(big.NewInt(1), 256) + if _, err := participationBitmapWitness([]*big.Int{tooBig}, 0); err == nil { + t.Fatal("expected an error for a word larger than uint256") + } +} diff --git a/shared/services/rocketpool/megapool.go b/shared/services/rocketpool/megapool.go index dce97e71b..2ef1769f2 100644 --- a/shared/services/rocketpool/megapool.go +++ b/shared/services/rocketpool/megapool.go @@ -45,19 +45,15 @@ func (c *Client) VerifyMegapoolValidatorPerformance(megapoolAddress common.Addre } // challengePerformanceValues builds the shared parameters of the -// can-challenge-performance and challenge-performance calls. validatorIds and -// participation are serialized as comma-separated decimal strings. -func challengePerformanceValues(megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int) url.Values { - ids := make([]string, len(validatorIds)) - for i, id := range validatorIds { - ids[i] = strconv.FormatUint(uint64(id), 10) - } +// can-challenge-performance and challenge-performance calls. participation is +// serialized as comma-separated decimal strings. +func challengePerformanceValues(megapoolAddress common.Address, validatorId uint32, startEpoch uint64, participation []*big.Int) url.Values { words := make([]string, len(participation)) for i, word := range participation { words[i] = word.String() } values := url.Values{ - "validatorIds": {strings.Join(ids, ",")}, + "validatorId": {strconv.FormatUint(uint64(validatorId), 10)}, "startEpoch": {strconv.FormatUint(startEpoch, 10)}, "participation": {strings.Join(words, ",")}, } @@ -68,13 +64,13 @@ func challengePerformanceValues(megapoolAddress common.Address, validatorIds []u } // CanChallengeMegapoolPerformance checks whether the node can challenge the -// target-vote performance of a group of megapool validators, returning the +// target-vote performance of a megapool validator, returning the // challenge bond, the node's RPL balance, and the gas estimate. If // megapoolAddress is the zero address the daemon resolves the node's own // megapool. This call has no client-side deadline because the gas estimate // requires downloading a beacon state to build the slot proof. -func (c *Client) CanChallengeMegapoolPerformance(megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int) (api.CanChallengeMegapoolPerformanceResponse, error) { - values := challengePerformanceValues(megapoolAddress, validatorIds, startEpoch, participation) +func (c *Client) CanChallengeMegapoolPerformance(megapoolAddress common.Address, validatorId uint32, startEpoch uint64, participation []*big.Int) (api.CanChallengeMegapoolPerformanceResponse, error) { + values := challengePerformanceValues(megapoolAddress, validatorId, startEpoch, participation) responseBytes, err := c.callHTTPAPICtx(context.Background(), "GET", "/api/megapool/can-challenge-performance", values) if err != nil { return api.CanChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not get can challenge megapool performance status: %w", err) @@ -90,11 +86,11 @@ func (c *Client) CanChallengeMegapoolPerformance(megapoolAddress common.Address, } // ChallengeMegapoolPerformance submits a target-vote performance challenge -// against a group of megapool validators. This call has no client-side +// against a megapool validator. This call has no client-side // deadline because the transaction requires downloading a beacon state to // build the slot proof. -func (c *Client) ChallengeMegapoolPerformance(megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int) (api.ChallengeMegapoolPerformanceResponse, error) { - values := challengePerformanceValues(megapoolAddress, validatorIds, startEpoch, participation) +func (c *Client) ChallengeMegapoolPerformance(megapoolAddress common.Address, validatorId uint32, startEpoch uint64, participation []*big.Int) (api.ChallengeMegapoolPerformanceResponse, error) { + values := challengePerformanceValues(megapoolAddress, validatorId, startEpoch, participation) responseBytes, err := c.callHTTPAPICtx(context.Background(), "POST", "/api/megapool/challenge-performance", values) if err != nil { return api.ChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not challenge megapool performance: %w", err) From 9817ca6dd1353b082309bf87e4ac5139c271ee0c Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:33:56 -0300 Subject: [PATCH 18/35] participationFlags from uint8 to bytes32 and historical participation proof --- bindings/megapool/megapool-contract.go | 9 +- shared/services/megapools.go | 168 +++++++++-- shared/services/megapools_test.go | 271 ++++++++++++++++++ .../types/eth2/fork/electra/state_electra.go | 73 ++++- shared/types/eth2/fork/fulu/state_fulu.go | 114 +++++--- shared/types/eth2/generic/state.go | 3 + shared/types/eth2/participation_proof_test.go | 185 +++++++++--- shared/types/eth2/types.go | 12 +- 8 files changed, 727 insertions(+), 108 deletions(-) diff --git a/bindings/megapool/megapool-contract.go b/bindings/megapool/megapool-contract.go index f984656dd..4936a126a 100644 --- a/bindings/megapool/megapool-contract.go +++ b/bindings/megapool/megapool-contract.go @@ -18,10 +18,11 @@ import ( ) type ParticipationProof struct { - ParticipationSlot uint64 - ValidatorIndex uint64 - ParticipationFlags uint8 - Witnesses [][32]byte + ParticipationSlot uint64 + ValidatorIndex *big.Int + // ParticipationFlagsChunk is the 32-byte merkle chunk of previous_epoch_participation + ParticipationFlagsChunk [32]byte + Witnesses [][32]byte } type SlotProof struct { diff --git a/shared/services/megapools.go b/shared/services/megapools.go index eb4026fb8..7915a73a3 100644 --- a/shared/services/megapools.go +++ b/shared/services/megapools.go @@ -209,13 +209,85 @@ func participationProofSlotRange(challengedEpoch uint64, slotsPerEpoch uint64) ( return firstSlot, lastSlot } +// buildRecentParticipationWitnesses assembles the witness chain for a +// participation slot still covered by the anchor state's state_roots vector: +// [participation chunk -> participation state root] ++ +// [state_roots[n] -> anchor state root] ++ [anchor block header] +func buildRecentParticipationWitnesses(anchorState eth2.BeaconState, participationSlot uint64, chunkProof [][]byte) ([][]byte, error) { + stateRootProof, err := anchorState.StateRootProof(participationSlot) + if err != nil { + return nil, fmt.Errorf("error building the state root proof: %w", err) + } + blockHeaderProof, err := anchorState.BlockHeaderProof() + if err != nil { + return nil, fmt.Errorf("error building the anchor block header proof: %w", err) + } + witnesses := make([][]byte, 0, len(chunkProof)+len(stateRootProof)+len(blockHeaderProof)) + witnesses = append(witnesses, chunkProof...) + witnesses = append(witnesses, stateRootProof...) + witnesses = append(witnesses, blockHeaderProof...) + return witnesses, nil +} + +// buildHistoricalParticipationWitnesses assembles the witness chain for a +// participation slot older than the anchor state's state_roots vector, +// routing through historical_summaries[n].state_summary_root: the era +// boundary state's state_roots vector proves the participation state root +// within the summary, and the anchor state proves the summary itself (the +// historical summary proof includes the anchor block header cap): +// [participation chunk -> participation state root] ++ +// [state_roots[n] -> state_summary_root -> HistoricalSummary root] ++ +// [historical_summaries[n] -> anchor state root -> anchor block header] +func buildHistoricalParticipationWitnesses(anchorState eth2.BeaconState, eraState eth2.BeaconState, participationSlot uint64, capellaOffset uint64, chunkProof [][]byte) ([][]byte, error) { + summaryStateRootProof, err := eraState.HistoricalSummaryStateRootProof(int(participationSlot)) + if err != nil { + return nil, fmt.Errorf("error building the historical summary state root proof: %w", err) + } + historicalSummaryProof, err := anchorState.HistoricalSummaryProof(participationSlot, capellaOffset) + if err != nil { + return nil, fmt.Errorf("error building the historical summary proof: %w", err) + } + witnesses := make([][]byte, 0, len(chunkProof)+len(summaryStateRootProof)+len(historicalSummaryProof)) + witnesses = append(witnesses, chunkProof...) + witnesses = append(witnesses, summaryStateRootProof...) + witnesses = append(witnesses, historicalSummaryProof...) + return witnesses, nil +} + +// verifyParticipationStateLink sanity checks that linkState's state_roots +// vector commits to the participation state's hash tree root before anything +// is submitted on chain. The check is skipped for fork combinations that +// can't be inspected (non-fulu states) +func verifyParticipationStateLink(linkState eth2.BeaconState, participationState eth2.BeaconState, participationSlot uint64) error { + linkFulu, ok := linkState.(*fulu.BeaconState) + if !ok { + return nil + } + participationFulu, ok := participationState.(*fulu.BeaconState) + if !ok { + return nil + } + participationRoot, err := participationFulu.HashTreeRoot() + if err != nil { + return fmt.Errorf("error hashing the participation state: %w", err) + } + if linkFulu.StateRoots[participationSlot%generic.SlotsPerHistoricalRoot] != participationRoot { + return fmt.Errorf("the state at slot %d does not commit to the root of the participation state at slot %d", linkFulu.Slot, participationSlot) + } + return nil +} + // GetParticipationProof builds the proofs needed to respond to a performance // challenge with a validator's timely target vote in challengedEpoch. The // challenge is identified by its start epoch and participation bitmap -// (challengedEpoch must be marked as missed in the bitmap). The proof state -// is the post-state of the last block in epoch challengedEpoch+1: +// (challengedEpoch must be marked as missed in the bitmap). The participation +// state is the post-state of the last block in epoch challengedEpoch+1: // attestations only enter the state via blocks, so that state holds the final -// previous_epoch_participation flags for the challenged epoch. +// previous_epoch_participation flags for the challenged epoch. All proofs are +// anchored at a recent finalized state: the participation state's root is +// proven through the anchor's state_roots vector when it is at most 8192 +// slots old, or through historical_summaries otherwise, mirroring the +// on-chain BeaconStateVerifier path construction. func GetParticipationProof(c *cli.Command, validatorIndex uint64, validatorPubkey types.ValidatorPubkey, challengedEpoch uint64, startEpoch uint64, participation []*big.Int) (PerformanceDefenseProofs, error) { // Locate the challenged epoch within the challenge participation bitmap if challengedEpoch < startEpoch { @@ -250,33 +322,33 @@ func GetParticipationProof(c *cli.Command, validatorIndex uint64, validatorPubke // Walk back from the last slot of epoch E+1 to the most recent slot with a // block; its post-state holds the final participation flags for epoch E. firstSlot, lastSlot := participationProofSlotRange(challengedEpoch, eth2Config.SlotsPerEpoch) - proofSlot := uint64(0) - proofSlotFound := false + participationSlot := uint64(0) + participationSlotFound := false for slot := lastSlot; slot >= firstSlot; slot-- { _, exists, err := bc.GetBeaconBlockHeader(strconv.FormatUint(slot, 10)) if err != nil { return PerformanceDefenseProofs{}, fmt.Errorf("error getting beacon block header at slot %d: %w", slot, err) } if exists { - proofSlot = slot - proofSlotFound = true + participationSlot = slot + participationSlotFound = true break } } - if !proofSlotFound { + if !participationSlotFound { return PerformanceDefenseProofs{}, fmt.Errorf("no block found in epoch %d to prove the participation of epoch %d", challengedEpoch+1, challengedEpoch) } - stateResponse, err := bc.GetBeaconStateSSZ(proofSlot) + stateResponse, err := bc.GetBeaconStateSSZ(participationSlot) if err != nil { - return PerformanceDefenseProofs{}, fmt.Errorf("error getting beacon state at slot %d: %w", proofSlot, err) + return PerformanceDefenseProofs{}, fmt.Errorf("error getting beacon state at slot %d (an archive Beacon Node may be required for old epochs): %w", participationSlot, err) } - beaconState, err := eth2.NewBeaconState(stateResponse.Data, stateResponse.Fork) + participationState, err := eth2.NewBeaconState(stateResponse.Data, stateResponse.Fork) if err != nil { - return PerformanceDefenseProofs{}, fmt.Errorf("error parsing beacon state at slot %d: %w", proofSlot, err) + return PerformanceDefenseProofs{}, fmt.Errorf("error parsing beacon state at slot %d: %w", participationSlot, err) } - epochParticipation := beaconState.GetPreviousEpochParticipation() + epochParticipation := participationState.GetPreviousEpochParticipation() if validatorIndex >= uint64(len(epochParticipation)) { return PerformanceDefenseProofs{}, fmt.Errorf("validator index %d out of bounds of the previous epoch participation list (%d entries)", validatorIndex, len(epochParticipation)) } @@ -285,18 +357,30 @@ func GetParticipationProof(c *cli.Command, validatorIndex uint64, validatorPubke return PerformanceDefenseProofs{}, fmt.Errorf("validator %d does not have the timely target flag set for epoch %d", validatorIndex, challengedEpoch) } - _, _, participationProofBytes, slotProofBytes, err := beaconState.PreviousEpochParticipationAndSlotProof(validatorIndex) + chunk, chunkProofBytes, err := participationState.PreviousEpochParticipationChunkProof(validatorIndex) if err != nil { return PerformanceDefenseProofs{}, fmt.Errorf("error building participation proof: %w", err) } - // Build the validator proof from the same state so verifyValidator and - // verifyParticipation are anchored to the same slot - validatorProofBytes, _, err := beaconState.ValidatorAndSlotProof(validatorIndex) + // Anchor all proofs at a recent finalized state. The contract retrieves + // the anchor's block root via EIP-4788 and verifies the validator, slot + // and participation proofs against it + anchorState, err := GetBeaconState(bc) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error getting the anchor beacon state: %w", err) + } + anchorSlot := anchorState.GetSlot() + if anchorSlot <= participationSlot { + return PerformanceDefenseProofs{}, fmt.Errorf("the participation state at slot %d is not yet finalized (finalized slot %d), try again later", participationSlot, anchorSlot) + } + + // Build the validator proof from the anchor state so verifyValidator and + // verifySlot are anchored to the same slot as the participation proof + validatorProofBytes, slotProofBytes, err := anchorState.ValidatorAndSlotProof(validatorIndex) if err != nil { return PerformanceDefenseProofs{}, fmt.Errorf("error building validator proof: %w", err) } - validators := beaconState.GetValidators() + validators := anchorState.GetValidators() if validatorIndex >= uint64(len(validators)) { return PerformanceDefenseProofs{}, fmt.Errorf("validator index %d out of bounds of the validator set (%d entries)", validatorIndex, len(validators)) } @@ -304,7 +388,43 @@ func GetParticipationProof(c *cli.Command, validatorIndex uint64, validatorPubke var withdrawalCredentialsFixed [32]byte copy(withdrawalCredentialsFixed[:], validator.WithdrawalCredentials) - slotTimestamp, err := GetChildBlockTimestampForSlot(c, beaconState.GetSlot()) + // Extend the participation chunk proof up to the anchor block root, via + // the anchor's state_roots vector when the participation slot is recent + // or via historical_summaries otherwise (matching the on-chain + // _pathBeaconStateToPastStateRoot branch) + var participationWitnesses [][]byte + if participationSlot+generic.SlotsPerHistoricalRoot >= anchorSlot { + if err := verifyParticipationStateLink(anchorState, participationState, participationSlot); err != nil { + return PerformanceDefenseProofs{}, err + } + participationWitnesses, err = buildRecentParticipationWitnesses(anchorState, participationSlot, chunkProofBytes) + if err != nil { + return PerformanceDefenseProofs{}, err + } + } else { + // Fetch the state at the end of the 8192 slot era containing the + // participation slot; its state_roots vector is the one summarised + // by historical_summaries[era] + eraBoundarySlot := (participationSlot/generic.SlotsPerHistoricalRoot + 1) * generic.SlotsPerHistoricalRoot + eraStateResponse, err := bc.GetBeaconStateSSZ(eraBoundarySlot) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error getting the era boundary state at slot %d (an archive Beacon Node may be required): %w", eraBoundarySlot, err) + } + eraState, err := eth2.NewBeaconState(eraStateResponse.Data, eraStateResponse.Fork) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error parsing the era boundary state at slot %d: %w", eraBoundarySlot, err) + } + if err := verifyParticipationStateLink(eraState, participationState, participationSlot); err != nil { + return PerformanceDefenseProofs{}, err + } + capellaOffset := eth2Config.CapellaForkEpoch * eth2Config.SlotsPerEpoch / generic.SlotsPerHistoricalRoot + participationWitnesses, err = buildHistoricalParticipationWitnesses(anchorState, eraState, participationSlot, capellaOffset, chunkProofBytes) + if err != nil { + return PerformanceDefenseProofs{}, err + } + } + + slotTimestamp, err := GetChildBlockTimestampForSlot(c, anchorSlot) if err != nil { return PerformanceDefenseProofs{}, fmt.Errorf("error getting the slot timestamp: %w", err) } @@ -329,13 +449,13 @@ func GetParticipationProof(c *cli.Command, validatorIndex uint64, validatorPubke Witnesses: ConvertToFixedSize(validatorProofBytes), }, Participation: megapool.ParticipationProof{ - ParticipationSlot: beaconState.GetSlot(), - ValidatorIndex: validatorIndex, - ParticipationFlags: flags, - Witnesses: ConvertToFixedSize(participationProofBytes), + ParticipationSlot: participationSlot, + ValidatorIndex: new(big.Int).SetUint64(validatorIndex), + ParticipationFlagsChunk: chunk, + Witnesses: ConvertToFixedSize(participationWitnesses), }, Slot: megapool.SlotProof{ - Slot: beaconState.GetSlot(), + Slot: anchorSlot, Witnesses: ConvertToFixedSize(slotProofBytes), }, }, nil diff --git a/shared/services/megapools_test.go b/shared/services/megapools_test.go index 354f6991e..835c8c132 100644 --- a/shared/services/megapools_test.go +++ b/shared/services/megapools_test.go @@ -1,14 +1,18 @@ package services import ( + "bytes" "crypto/sha256" "math/big" + "math/bits" "testing" "github.com/ethereum/go-ethereum/common" "github.com/rocket-pool/smartnode/bindings/rocketpool" "github.com/rocket-pool/smartnode/shared/types/api" + "github.com/rocket-pool/smartnode/shared/types/eth2/fork/fulu" + "github.com/rocket-pool/smartnode/shared/types/eth2/generic" ) // --------------------------------------------------------------------------- @@ -381,3 +385,270 @@ func TestParticipationBitmapWitnessErrors(t *testing.T) { t.Fatal("expected an error for a word larger than uint256") } } + +// --------------------------------------------------------------------------- +// Participation witness chain assembly +// --------------------------------------------------------------------------- + +// newProofTestFuluState builds a minimal but SSZ-valid fulu beacon state with +// numValidators validators and per-validator previous-epoch participation +// flags of (i % 8). +func newProofTestFuluState(t *testing.T, numValidators int, slot uint64) *fulu.BeaconState { + t.Helper() + + validators := make([]*generic.Validator, numValidators) + balances := make([]uint64, numValidators) + inactivityScores := make([]uint64, numValidators) + previousParticipation := make([]byte, numValidators) + currentParticipation := make([]byte, numValidators) + for i := range validators { + validators[i] = &generic.Validator{ + Pubkey: make([]byte, 48), + WithdrawalCredentials: make([]byte, 32), + EffectiveBalance: 32e9, + } + validators[i].Pubkey[0] = byte(i + 1) + balances[i] = 32e9 + previousParticipation[i] = byte(i % 8) + currentParticipation[i] = byte((i + 1) % 8) + } + + randaoMixes := make([][]byte, 65536) + for i := range randaoMixes { + randaoMixes[i] = make([]byte, 32) + } + + syncCommittee := func() *generic.SyncCommittee { + pubkeys := make([][]byte, 512) + for i := range pubkeys { + pubkeys[i] = make([]byte, 48) + } + return &generic.SyncCommittee{PubKeys: pubkeys} + } + + parentRoot := make([]byte, 32) + parentRoot[0] = 0xaa + bodyRoot := make([]byte, 32) + bodyRoot[0] = 0xbb + + return &fulu.BeaconState{ + GenesisValidatorsRoot: make([]byte, 32), + Slot: slot, + Fork: &generic.Fork{ + PreviousVersion: make([]byte, 4), + CurrentVersion: make([]byte, 4), + }, + LatestBlockHeader: &generic.BeaconBlockHeader{ + Slot: slot, + ProposerIndex: 1, + ParentRoot: parentRoot, + StateRoot: make([]byte, 32), + BodyRoot: bodyRoot, + }, + HistoricalRoots: [][]byte{}, + Eth1Data: &generic.Eth1Data{ + DepositRoot: make([]byte, 32), + BlockHash: make([]byte, 32), + }, + Eth1DataVotes: []*generic.Eth1Data{}, + Validators: validators, + Balances: balances, + RandaoMixes: randaoMixes, + Slashings: make([]uint64, 8192), + PreviousEpochParticipation: previousParticipation, + CurrentEpochParticipation: currentParticipation, + PreviousJustifiedCheckpoint: &generic.Checkpoint{Root: make([]byte, 32)}, + CurrentJustifiedCheckpoint: &generic.Checkpoint{Root: make([]byte, 32)}, + FinalizedCheckpoint: &generic.Checkpoint{Root: make([]byte, 32)}, + InactivityScores: inactivityScores, + CurrentSyncCommittee: syncCommittee(), + NextSyncCommittee: syncCommittee(), + LatestExecutionPayloadHeader: &generic.ExecutionPayloadHeader{}, + HistoricalSummaries: []*generic.HistoricalSummary{}, + ProposerLookahead: make([]uint64, 64), + } +} + +// concatGid appends a child generalized index (rooted at 1 in its own +// subtree) onto a parent generalized index, mirroring the on-chain SSZ.concat +// path construction. The combined index exceeds 64 bits for full +// participation witness chains, hence big.Int. +func concatGid(parent *big.Int, child uint64) *big.Int { + depth := bits.Len64(child) - 1 + out := new(big.Int).Lsh(parent, uint(depth)) + return out.Or(out, new(big.Int).SetUint64(child-1< state_root ++ state -> state_roots[n] ++ state -> chunk + stateRootsGid := (uint64(1)*64+generic.BeaconStateStateRootsFieldIndex)*generic.BeaconStateBlockRootsMaxLength + participationSlot%generic.SlotsPerHistoricalRoot + chunkGid := generic.GetGeneralizedIndexForParticipationChunk(validatorIndex/32, fulu.GetGeneralizedIndexForPreviousEpochParticipation()) + gid := big.NewInt(int64(generic.BeaconBlockHeaderStateRootGeneralizedIndex)) + gid = concatGid(gid, stateRootsGid) + gid = concatGid(gid, chunkGid) + + root := walkWitnessChain(t, chunk[:], gid, witnesses) + if !bytes.Equal(root, anchorBlockRoot(t, anchorState)) { + t.Fatalf("restored root %x does not match the anchor block root", root) + } +} + +func TestBuildHistoricalParticipationWitnesses(t *testing.T) { + const numValidators = 100 + const validatorIndex = uint64(33) + const capellaOffset = uint64(0) + // Participation slot in era 1, anchored more than 8192 slots later + const participationSlot = uint64(generic.SlotsPerHistoricalRoot + 5000) + const eraBoundarySlot = (participationSlot/generic.SlotsPerHistoricalRoot + 1) * generic.SlotsPerHistoricalRoot + const anchorSlot = uint64(5*generic.SlotsPerHistoricalRoot + 77) + const entry = participationSlot/generic.SlotsPerHistoricalRoot - capellaOffset + + participationState := newProofTestFuluState(t, numValidators, participationSlot) + eraState := newProofTestFuluState(t, numValidators, eraBoundarySlot) + anchorState := newProofTestFuluState(t, numValidators, anchorSlot) + + // Wire the era boundary state's state_roots vector to commit to the + // participation state + participationRoot, err := participationState.HashTreeRoot() + if err != nil { + t.Fatalf("failed to hash participation state: %v", err) + } + eraState.StateRoots[participationSlot%generic.SlotsPerHistoricalRoot] = participationRoot + + if err := verifyParticipationStateLink(eraState, participationState, participationSlot); err != nil { + t.Fatalf("state link check failed: %v", err) + } + + // Wire the anchor's historical_summaries to commit to the era boundary + // state's roots vectors + hsls := generic.HistoricalSummaryLists{ + BlockRoots: eraState.BlockRoots, + StateRoots: eraState.StateRoots, + } + hslsTree, err := hsls.GetTree() + if err != nil { + t.Fatalf("failed to get historical summary lists tree: %v", err) + } + blockSummaryNode, err := hslsTree.Get(2) + if err != nil { + t.Fatalf("failed to get block summary node: %v", err) + } + stateSummaryNode, err := hslsTree.Get(3) + if err != nil { + t.Fatalf("failed to get state summary node: %v", err) + } + summaries := make([]*generic.HistoricalSummary, entry+1) + for i := range summaries { + summaries[i] = &generic.HistoricalSummary{} + summaries[i].BlockSummaryRoot[0] = byte(i + 1) + } + copy(summaries[entry].BlockSummaryRoot[:], blockSummaryNode.Hash()) + copy(summaries[entry].StateSummaryRoot[:], stateSummaryNode.Hash()) + anchorState.HistoricalSummaries = summaries + + chunk, chunkProof, err := participationState.PreviousEpochParticipationChunkProof(validatorIndex) + if err != nil { + t.Fatalf("failed to build the chunk proof: %v", err) + } + witnesses, err := buildHistoricalParticipationWitnesses(anchorState, eraState, participationSlot, capellaOffset, chunkProof) + if err != nil { + t.Fatalf("failed to build the historical witnesses: %v", err) + } + if len(witnesses) != 90 { + t.Fatalf("historical witness count = %d, want 90", len(witnesses)) + } + + // Combined gindex, mirroring BeaconStateVerifier.verifyParticipation: + // header -> state_root ++ state -> historical_summaries[n] ++ + // HistoricalSummary -> state_summary_root ++ state_roots -> [n] ++ + // state -> chunk + summaryElementGid := (uint64(1)*64+generic.BeaconStateHistoricalSummariesFieldIndex)*2*generic.BeaconStateHistoricalSummariesMaxLength + entry + stateRootsVectorGid := generic.SlotsPerHistoricalRoot + participationSlot%generic.SlotsPerHistoricalRoot + chunkGid := generic.GetGeneralizedIndexForParticipationChunk(validatorIndex/32, fulu.GetGeneralizedIndexForPreviousEpochParticipation()) + gid := big.NewInt(int64(generic.BeaconBlockHeaderStateRootGeneralizedIndex)) + gid = concatGid(gid, summaryElementGid) + gid = concatGid(gid, 3) // HistoricalSummary -> state_summary_root + gid = concatGid(gid, stateRootsVectorGid) + gid = concatGid(gid, chunkGid) + + root := walkWitnessChain(t, chunk[:], gid, witnesses) + if !bytes.Equal(root, anchorBlockRoot(t, anchorState)) { + t.Fatalf("restored root %x does not match the anchor block root", root) + } +} diff --git a/shared/types/eth2/fork/electra/state_electra.go b/shared/types/eth2/fork/electra/state_electra.go index 1458d46a2..16cdafa4f 100644 --- a/shared/types/eth2/fork/electra/state_electra.go +++ b/shared/types/eth2/fork/electra/state_electra.go @@ -288,6 +288,75 @@ func (state *BeaconState) BlockHeaderProof() ([][]byte, error) { return nil, nil } -func (state *BeaconState) PreviousEpochParticipationAndSlotProof(validatorIndex uint64) ([32]byte, uint64, [][]byte, [][]byte, error) { - return [32]byte{}, 0, nil, nil, fmt.Errorf("participation proofs are not supported for electra states") +func (state *BeaconState) PreviousEpochParticipationChunkProof(validatorIndex uint64) ([32]byte, [][]byte, error) { + return [32]byte{}, nil, fmt.Errorf("participation proofs are not supported for electra states") +} + +// HistoricalSummaryStateRootProof proves that the state root of the given +// slot is part of the HistoricalSummary covering its era, using this state's +// state_roots vector. The state must be aligned at the end of the 8192 slot +// era containing slot, so its state_roots vector is the one summarised by +// historical_summaries[slot / 8192]. +func (state *BeaconState) HistoricalSummaryStateRootProof(slot int) ([][]byte, error) { + // If the state isn't aligned at the end of an 8192 slot era, throw an error + if state.Slot%generic.SlotsPerHistoricalRoot != 0 { + return nil, fmt.Errorf("state is not aligned at the end of an 8192 slot era") + } + + hsls := generic.HistoricalSummaryLists{ + BlockRoots: state.BlockRoots, + StateRoots: state.StateRoots, + } + + idx := slot % int(generic.SlotsPerHistoricalRoot) + tree, err := hsls.GetTree() + if err != nil { + return nil, fmt.Errorf("could not get historical summary lists tree: %w", err) + } + + gid := uint64(1) + gid = gid*2 + 1 // Now at state_roots + gid = gid * generic.SlotsPerHistoricalRoot // Now at the first state_root + gid = gid + uint64(idx) // Now at the correct state_root + + proof, err := tree.Prove(int(gid)) + if err != nil { + return nil, fmt.Errorf("could not get proof for historical summary: %w", err) + } + + return proof.Hashes, nil +} + +// StateRootProof proves the state root of a recent past slot from this +// state's state_roots vector, up to this state's root (no block-header cap) +func (state *BeaconState) StateRootProof(slot uint64) ([][]byte, error) { + if slot >= state.Slot { + return nil, fmt.Errorf("slot %d is not in the past of the state at slot %d", slot, state.Slot) + } + // Note: a distance of exactly SlotsPerHistoricalRoot is still recent - + // state_roots[slot % 8192] holds the root of slot (state.Slot - 8192) + if slot+generic.SlotsPerHistoricalRoot < state.Slot { + return nil, fmt.Errorf("slot %d is more than %d slots in the past from the state at slot %d, you must build a proof from the historical_summaries instead", slot, generic.SlotsPerHistoricalRoot, state.Slot) + } + + tree, err := state.GetTree() + if err != nil { + return nil, fmt.Errorf("could not get state tree: %w", err) + } + + gid := uint64(1) + + // Navigate to the state_roots + gid = gid*beaconStateChunkCeil + generic.BeaconStateStateRootsFieldIndex + + // We're now at the state_roots vector, which is the root of a slotsPerHistoricalRoot slots vector. + // The index we care about is given by slot % slotsPerHistoricalRoot. + gid = gid*generic.BeaconStateBlockRootsMaxLength + (slot % generic.SlotsPerHistoricalRoot) + + proof, err := tree.Prove(int(gid)) + if err != nil { + return nil, fmt.Errorf("could not get proof for state root: %w", err) + } + + return proof.Hashes, nil } diff --git a/shared/types/eth2/fork/fulu/state_fulu.go b/shared/types/eth2/fork/fulu/state_fulu.go index 0528683aa..63510f5fa 100644 --- a/shared/types/eth2/fork/fulu/state_fulu.go +++ b/shared/types/eth2/fork/fulu/state_fulu.go @@ -92,61 +92,40 @@ func GetGeneralizedIndexForPreviousEpochParticipation() uint64 { return generic.ContainerFieldGindex(getStateChunkSize(), generic.BeaconStatePreviousEpochParticipationFieldIndex) } -// PreviousEpochParticipationAndSlotProof proves the previous_epoch_participation -// chunk containing validatorIndex's participation flags, plus the state slot, -// both anchored at the block-header root. chunk is the 32-byte merkle leaf -// holding the flags of validators [chunkIndex*32, chunkIndex*32+31]; -// chunkOffset is validatorIndex % 32 (the Respond offset into that leaf). -func (state *BeaconState) PreviousEpochParticipationAndSlotProof(validatorIndex uint64) ([32]byte, uint64, [][]byte, [][]byte, error) { +// PreviousEpochParticipationChunkProof proves the previous_epoch_participation +// chunk containing validatorIndex's participation flags up to the state root +// (no block-header cap; the proof is anchored via a separate state root proof +// from a more recent state). chunk is the 32-byte merkle leaf holding the +// flags of validators [chunkIndex*32, chunkIndex*32+31], with the validator's +// own flags at byte validatorIndex % 32. +func (state *BeaconState) PreviousEpochParticipationChunkProof(validatorIndex uint64) ([32]byte, [][]byte, error) { if validatorIndex >= uint64(len(state.PreviousEpochParticipation)) { - return [32]byte{}, 0, nil, nil, errors.New("validator index out of bounds of the previous epoch participation list") + return [32]byte{}, nil, errors.New("validator index out of bounds of the previous epoch participation list") } // Pack the expected leaf chunk locally: 32 participation flag bytes, // zero-padded at the tail of the list. chunkIndex := validatorIndex / 32 - chunkOffset := validatorIndex % 32 var chunk [32]byte copy(chunk[:], state.PreviousEpochParticipation[chunkIndex*32:]) stateTree, err := generic.SSZ.GetTree(state) if err != nil { - return [32]byte{}, 0, nil, nil, fmt.Errorf("could not get state tree: %w", err) + return [32]byte{}, nil, fmt.Errorf("could not get state tree: %w", err) } chunkGid := generic.GetGeneralizedIndexForParticipationChunk(chunkIndex, GetGeneralizedIndexForPreviousEpochParticipation()) participationStateProof, err := stateTree.Prove(int(chunkGid)) if err != nil { - return [32]byte{}, 0, nil, nil, fmt.Errorf("could not get proof for participation chunk: %w", err) + return [32]byte{}, nil, fmt.Errorf("could not get proof for participation chunk: %w", err) } // Sanity check that the proof leaf matches the locally packed chunk if !bytes.Equal(participationStateProof.Leaf, chunk[:]) { - return [32]byte{}, 0, nil, nil, fmt.Errorf("proof leaf does not match expected participation chunk") + return [32]byte{}, nil, fmt.Errorf("proof leaf does not match expected participation chunk") } - slotStateProof, err := stateTree.Prove(int(GetGeneralizedIndexForSlot())) - if err != nil { - return [32]byte{}, 0, nil, nil, fmt.Errorf("could not get proof for slot: %w", err) - } - - // Drop the state tree before doing more work so the GC can reclaim it. - stateTree = nil - - blockHeaderProof, err := state.blockHeaderToStateProof(state.LatestBlockHeader) - if err != nil { - return [32]byte{}, 0, nil, nil, fmt.Errorf("could not get block header proof: %w", err) - } - - participationBranch := make([][]byte, 0, len(participationStateProof.Hashes)+len(blockHeaderProof)) - participationBranch = append(participationBranch, participationStateProof.Hashes...) - participationBranch = append(participationBranch, blockHeaderProof...) - - slotProof := make([][]byte, 0, len(slotStateProof.Hashes)+len(blockHeaderProof)) - slotProof = append(slotProof, slotStateProof.Hashes...) - slotProof = append(slotProof, blockHeaderProof...) - - return chunk, chunkOffset, participationBranch, slotProof, nil + return chunk, participationStateProof.Hashes, nil } // ValidatorAndSlotProof produces both the validator proof and the slot proof @@ -286,6 +265,75 @@ func (state *BeaconState) HistoricalSummaryBlockRootProof(slot int) ([][]byte, e return proof.Hashes, nil } +// HistoricalSummaryStateRootProof proves that the state root of the given +// slot is part of the HistoricalSummary covering its era, using this state's +// state_roots vector. The state must be aligned at the end of the 8192 slot +// era containing slot, so its state_roots vector is the one summarised by +// historical_summaries[slot / 8192]. +func (state *BeaconState) HistoricalSummaryStateRootProof(slot int) ([][]byte, error) { + // If the state isn't aligned at the end of an 8192 slot era, throw an error + if state.Slot%generic.SlotsPerHistoricalRoot != 0 { + return nil, fmt.Errorf("state is not aligned at the end of an 8192 slot era") + } + + hsls := generic.HistoricalSummaryLists{ + BlockRoots: state.BlockRoots, + StateRoots: state.StateRoots, + } + + idx := slot % int(generic.SlotsPerHistoricalRoot) + tree, err := hsls.GetTree() + if err != nil { + return nil, fmt.Errorf("could not get historical summary lists tree: %w", err) + } + + gid := uint64(1) + gid = gid*2 + 1 // Now at state_roots + gid = gid * generic.SlotsPerHistoricalRoot // Now at the first state_root + gid = gid + uint64(idx) // Now at the correct state_root + + proof, err := tree.Prove(int(gid)) + if err != nil { + return nil, fmt.Errorf("could not get proof for historical summary: %w", err) + } + + return proof.Hashes, nil +} + +// StateRootProof proves the state root of a recent past slot from this +// state's state_roots vector, up to this state's root (no block-header cap). +func (state *BeaconState) StateRootProof(slot uint64) ([][]byte, error) { + if slot >= state.Slot { + return nil, fmt.Errorf("slot %d is not in the past of the state at slot %d", slot, state.Slot) + } + // Note: a distance of exactly SlotsPerHistoricalRoot is still recent - + // state_roots[slot % 8192] holds the root of slot (state.Slot - 8192) + if slot+generic.SlotsPerHistoricalRoot < state.Slot { + return nil, fmt.Errorf("slot %d is more than %d slots in the past from the state at slot %d, you must build a proof from the historical_summaries instead", slot, generic.SlotsPerHistoricalRoot, state.Slot) + } + + tree, err := state.GetTree() + if err != nil { + return nil, fmt.Errorf("could not get state tree: %w", err) + } + + gid := uint64(1) + + // Navigate to the state_roots + gid = gid*beaconStateChunkCeil + generic.BeaconStateStateRootsFieldIndex + + // We're now at the state_roots vector, which is the root of a slotsPerHistoricalRoot slots vector. + // The index we care about is given by slot % slotsPerHistoricalRoot. + gid = gid*generic.BeaconStateBlockRootsMaxLength + (slot % generic.SlotsPerHistoricalRoot) + + proof, err := tree.Prove(int(gid)) + if err != nil { + return nil, fmt.Errorf("could not get proof for state root: %w", err) + } + + return proof.Hashes, nil +} + func (state *BeaconState) BlockRootProof(slot uint64) ([][]byte, error) { if generic.IsHistoricalProof(state.Slot, slot) { return nil, fmt.Errorf("slot %d is more than %d slots in the past from the state at slot %d, you must build a proof from the historical_summaries instead", slot, generic.SlotsPerHistoricalRoot, state.Slot) diff --git a/shared/types/eth2/generic/state.go b/shared/types/eth2/generic/state.go index be0734f4c..0d02d4bb3 100644 --- a/shared/types/eth2/generic/state.go +++ b/shared/types/eth2/generic/state.go @@ -23,6 +23,9 @@ const BeaconStateBlockRootsFieldIndex uint64 = 5 const BeaconStateStateRootsMaxLength uint64 = 1 << 13 const BeaconStateStateRootsFieldIndex uint64 = 6 +// BeaconStateStateRootsFieldIndex is the field offset of the StateRoots field in the BeaconState struct +const BeaconStateStateRootsFieldIndex uint64 = 6 + // BeaconStatePreviousEpochParticipationFieldIndex is the field offset of the // PreviousEpochParticipation field in the BeaconState struct const BeaconStatePreviousEpochParticipationFieldIndex uint64 = 15 diff --git a/shared/types/eth2/participation_proof_test.go b/shared/types/eth2/participation_proof_test.go index 1ca013c3e..d864b3481 100644 --- a/shared/types/eth2/participation_proof_test.go +++ b/shared/types/eth2/participation_proof_test.go @@ -3,10 +3,13 @@ package eth2 import ( "bytes" "encoding/binary" + "encoding/hex" + "encoding/json" "testing" "github.com/rocket-pool/smartnode/shared/types/eth2/fork/fulu" "github.com/rocket-pool/smartnode/shared/types/eth2/generic" + hexutils "github.com/rocket-pool/smartnode/shared/utils/hex" ) // newTestFuluState builds a minimal but SSZ-valid fulu beacon state with @@ -90,49 +93,46 @@ func newTestFuluState(t *testing.T, numValidators int, slot uint64) *fulu.Beacon } } -// validateFuluStateProof walks a merged state+block-header proof from leaf to -// root and checks it lands on the state's block root. Returns the state root -// and block root. -func validateFuluStateProof(t *testing.T, leaf []byte, proof [][]byte, gid uint64, state *fulu.BeaconState) ([]byte, []byte) { +// walkProof walks a merkle branch from leaf to root, consuming the witnesses +// leaf-first, and returns the reconstructed root. Fails if the witness count +// doesn't match the gid depth. +func walkProof(t *testing.T, leaf []byte, proof [][]byte, gid uint64) []byte { t.Helper() - // State proofs are merged with the block-header proof, so the effective - // tree is rooted at the beacon block header. - gid = offsetGidRoot(gid, generic.BeaconBlockHeaderStateRootGeneralizedIndex) currentHash := leaf - - for i, proofRow := range proof { - // The last neighbor must have a gid of either 2 or 3 - if i == len(proof)-1 { - if gid != 2 && gid != 3 { - t.Fatalf("last node/neighbor gid must be 2 or 3, got: %d", gid) - } + for _, proofRow := range proof { + if gid == 1 { + t.Fatalf("too many witnesses for gid depth") } neighborIsLeft := gid%2 == 1 gid /= 2 currentHash = hash(currentHash, proofRow, neighborIsLeft) } + if gid != 1 { + t.Fatalf("too few witnesses for gid depth, remaining gid: %d", gid) + } + return currentHash +} + +// validateFuluStateProofToStateRoot walks a state-internal proof from leaf to +// root and checks it lands on the state's hash tree root. Returns the state +// root. +func validateFuluStateProofToStateRoot(t *testing.T, leaf []byte, proof [][]byte, gid uint64, state *fulu.BeaconState) []byte { + t.Helper() + + currentHash := walkProof(t, leaf, proof, gid) - // Compute the expected block root: the latest block header with the state - // root filled in. stateRoot, err := state.HashTreeRoot() if err != nil { t.Fatalf("Failed to get state root: %v", err) } - header := *state.LatestBlockHeader - header.StateRoot = stateRoot[:] - blockRoot, err := header.HashTreeRoot() - if err != nil { - t.Fatalf("Failed to get block root: %v", err) - } - - if !bytes.Equal(currentHash, blockRoot[:]) { - t.Fatalf("final hash %x does not match block root %x", currentHash, blockRoot) + if !bytes.Equal(currentHash, stateRoot[:]) { + t.Fatalf("final hash %x does not match state root %x", currentHash, stateRoot) } - return stateRoot[:], blockRoot[:] + return stateRoot[:] } -func TestPreviousEpochParticipationAndSlotProof(t *testing.T) { +func TestPreviousEpochParticipationChunkProof(t *testing.T) { const numValidators = 100 const slot = uint64(105002*32 + 31) // last slot of some epoch state := newTestFuluState(t, numValidators, slot) @@ -140,32 +140,137 @@ func TestPreviousEpochParticipationAndSlotProof(t *testing.T) { // Validators in different chunks of the participation byte list (32 flag // bytes per chunk), including the last validator (partially filled chunk). for _, validatorIndex := range []uint64{0, 31, 32, 70, numValidators - 1} { - chunk, chunkOffset, participationBranch, slotProof, err := state.PreviousEpochParticipationAndSlotProof(validatorIndex) + chunk, participationBranch, err := state.PreviousEpochParticipationChunkProof(validatorIndex) if err != nil { - t.Fatalf("PreviousEpochParticipationAndSlotProof(%d) failed: %v", validatorIndex, err) - } - - if chunkOffset != validatorIndex%32 { - t.Fatalf("chunkOffset = %d, want %d", chunkOffset, validatorIndex%32) + t.Fatalf("PreviousEpochParticipationChunkProof(%d) failed: %v", validatorIndex, err) } // The chunk must hold the validator's flags byte at its in-chunk offset. + chunkOffset := validatorIndex % 32 if chunk[chunkOffset] != state.PreviousEpochParticipation[validatorIndex] { t.Fatalf("chunk byte %d = %x, want %x", chunkOffset, chunk[chunkOffset], state.PreviousEpochParticipation[validatorIndex]) } - // The participation branch must connect the chunk to the block root. + // The participation branch must connect the chunk to the state root. chunkGid := generic.GetGeneralizedIndexForParticipationChunk(validatorIndex/32, fulu.GetGeneralizedIndexForPreviousEpochParticipation()) - validateFuluStateProof(t, chunk[:], participationBranch, chunkGid, state) + validateFuluStateProofToStateRoot(t, chunk[:], participationBranch, chunkGid, state) - // The slot proof must connect the slot leaf to the same block root. - slotLeaf := make([]byte, 32) - binary.LittleEndian.PutUint64(slotLeaf, state.Slot) - validateFuluStateProof(t, slotLeaf, slotProof, fulu.GetGeneralizedIndexForSlot(), state) + // The witness count must match the on-chain path length: + // 6 (state fields) + 1 (list length mixin) + 35 (chunk index) = 42 + if len(participationBranch) != 42 { + t.Fatalf("participation branch length = %d, want 42", len(participationBranch)) + } } // Out-of-bounds validator index must error. - if _, _, _, _, err := state.PreviousEpochParticipationAndSlotProof(numValidators); err == nil { + if _, _, err := state.PreviousEpochParticipationChunkProof(numValidators); err == nil { t.Fatalf("expected an error for an out-of-bounds validator index") } } + +func TestStateRootProof(t *testing.T) { + const numValidators = 10 + const slot = uint64(105002*32 + 31) + state := newTestFuluState(t, numValidators, slot) + + // Fill state_roots with distinct values + for i := range state.StateRoots { + binary.LittleEndian.PutUint64(state.StateRoots[i][:], uint64(i)+1) + } + + for _, targetSlot := range []uint64{slot - 1, slot - 5000, slot - generic.SlotsPerHistoricalRoot} { + proof, err := state.StateRootProof(targetSlot) + if err != nil { + t.Fatalf("StateRootProof(%d) failed: %v", targetSlot, err) + } + + // The witness count must match the on-chain path length: + // 6 (state fields) + 13 (vector index) = 19 + if len(proof) != 19 { + t.Fatalf("state root proof length = %d, want 19", len(proof)) + } + + idx := targetSlot % generic.SlotsPerHistoricalRoot + gid := (uint64(1)*64+generic.BeaconStateStateRootsFieldIndex)*generic.BeaconStateBlockRootsMaxLength + idx + validateFuluStateProofToStateRoot(t, state.StateRoots[idx][:], proof, gid, state) + } + + // A slot not in the past must error + if _, err := state.StateRootProof(slot); err == nil { + t.Fatalf("expected an error for a slot not in the past") + } + // A slot more than 8192 slots in the past must error + if _, err := state.StateRootProof(slot - generic.SlotsPerHistoricalRoot - 1); err == nil { + t.Fatalf("expected an error for a historical slot") + } +} + +func TestHistoricalSummaryStateRootProof(t *testing.T) { + // Reuse the mainnet fixture for the 8192 slot era that ended at slot + // 11567103; the era boundary state is at slot 11567104 + var roots testRoots + err := json.Unmarshal(testRootsJSON, &roots) + if err != nil { + t.Fatalf("Failed to unmarshal test roots: %v", err) + } + + const eraBoundarySlot = uint64(11567104) + if eraBoundarySlot%generic.SlotsPerHistoricalRoot != 0 { + t.Fatalf("era boundary slot %d is not aligned", eraBoundarySlot) + } + state := newTestFuluState(t, 10, eraBoundarySlot) + for i, blockRoot := range roots.BlockRoots { + blockRootBytes, err := hex.DecodeString(hexutils.RemovePrefix(blockRoot)) + if err != nil { + t.Fatalf("Failed to decode block root: %v", err) + } + copy(state.BlockRoots[i][:], blockRootBytes) + } + for i, stateRoot := range roots.StateRoots { + stateRootBytes, err := hex.DecodeString(hexutils.RemovePrefix(stateRoot)) + if err != nil { + t.Fatalf("Failed to decode state root: %v", err) + } + copy(state.StateRoots[i][:], stateRootBytes) + } + + // A misaligned state must error + misaligned := newTestFuluState(t, 10, eraBoundarySlot+1) + if _, err := misaligned.HistoricalSummaryStateRootProof(int(eraBoundarySlot - 100)); err == nil { + t.Fatalf("expected an error for a misaligned state") + } + + // Prove a state root within the era [11558912, 11567103] + const targetSlot = uint64(11560000) + proof, err := state.HistoricalSummaryStateRootProof(int(targetSlot)) + if err != nil { + t.Fatalf("HistoricalSummaryStateRootProof(%d) failed: %v", targetSlot, err) + } + + // The witness count must match the on-chain path length: + // 13 (vector index) + 1 (state_summary_root vs block_summary_root) = 14 + if len(proof) != 14 { + t.Fatalf("historical summary state root proof length = %d, want 14", len(proof)) + } + + // Walk from state_roots[idx] up to the HistoricalSummary container root + idx := targetSlot % generic.SlotsPerHistoricalRoot + gid := (uint64(1)*2+1)*generic.SlotsPerHistoricalRoot + idx + summaryRoot := walkProof(t, state.StateRoots[idx][:], proof, gid) + + // The HistoricalSummary root equals sha256(block_summary_root ++ + // state_summary_root); the fixture era's summary roots are known mainnet + // values (see TestBlockRootProof) + expectedBlockSummaryRoot, err := hex.DecodeString("9d73b29c6e80e8300cedfa9e53aff89523affb98f3bd3f6752ecc159a2058858") + if err != nil { + t.Fatalf("Failed to decode expected block summary root: %v", err) + } + expectedStateSummaryRoot, err := hex.DecodeString("8a38d8b000dc65641eff8f7ff2e0b6f3b129957410cb9ecf183537484630b289") + if err != nil { + t.Fatalf("Failed to decode expected state summary root: %v", err) + } + expectedSummaryRoot := hash(expectedBlockSummaryRoot, expectedStateSummaryRoot, false) + if !bytes.Equal(summaryRoot, expectedSummaryRoot) { + t.Fatalf("summary root %x does not match expected %x", summaryRoot, expectedSummaryRoot) + } +} diff --git a/shared/types/eth2/types.go b/shared/types/eth2/types.go index cd895896b..e566e84d5 100644 --- a/shared/types/eth2/types.go +++ b/shared/types/eth2/types.go @@ -28,15 +28,17 @@ type BeaconState interface { ValidatorAndSlotProof(validatorIndex uint64) (validatorProof [][]byte, slotProof [][]byte, err error) HistoricalSummaryProof(slot uint64, capellaOffset uint64) ([][]byte, error) HistoricalSummaryBlockRootProof(slot int) ([][]byte, error) + HistoricalSummaryStateRootProof(slot int) ([][]byte, error) BlockRootProof(slot uint64) ([][]byte, error) + StateRootProof(slot uint64) ([][]byte, error) BlockHeaderProof() ([][]byte, error) GetValidators() []*generic.Validator GetPreviousEpochParticipation() []byte - // PreviousEpochParticipationAndSlotProof proves the previous_epoch_participation - // chunk containing validatorIndex's flags, plus the state slot, both anchored - // at the block-header root. chunk is the 32-byte merkle leaf; chunkOffset is - // the validator's byte index within that chunk (validatorIndex % 32) - PreviousEpochParticipationAndSlotProof(validatorIndex uint64) (chunk [32]byte, chunkOffset uint64, participationProofBytes [][]byte, slotProof [][]byte, err error) + // PreviousEpochParticipationChunkProof proves the previous_epoch_participation + // chunk containing validatorIndex's flags up to the state root (no + // block-header cap). chunk is the 32-byte merkle leaf; the validator's flags + // are at byte validatorIndex % 32 within it + PreviousEpochParticipationChunkProof(validatorIndex uint64) (chunk [32]byte, participationProofBytes [][]byte, err error) } type SignedBeaconBlock interface { From 2d32d4cc36a6cf03752b484df29285aa3f1b3576 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:48:50 -0300 Subject: [PATCH 19/35] Adapt to utils refactor --- bindings/megapool/performance.go | 17 ++++++----- bindings/settings/protocol/exit.go | 7 +++-- bindings/settings/protocol/performance.go | 13 ++++---- bindings/settings/security/performance.go | 3 +- .../verify-performance/verify-performance.go | 2 +- .../verify-performance_test.go | 0 rocketpool-cli/megapool/verify-performance.go | 15 +++++----- rocketpool-cli/minipool/verify-performance.go | 4 +-- rocketpool-cli/pdao/get-settings.go | 6 ++-- .../api/megapool/challenge-performance.go | 2 +- rocketpool/api/megapool/verify-performance.go | 2 +- rocketpool/api/minipool/verify-performance.go | 2 +- rocketpool/api/pdao/propose-settings.go | 18 +++++------ rocketpool/api/security/propose-settings.go | 2 +- .../node/defend-challenge-performance.go | 30 +++++++++---------- shared/services/megapools.go | 6 ++-- shared/services/megapools_test.go | 10 +++---- .../performance/target-performance.go | 4 +-- shared/types/api/node.go | 14 ++++----- .../types/eth2/fork/electra/state_electra.go | 4 +-- shared/types/eth2/fork/fulu/state_fulu.go | 4 +-- shared/types/eth2/fork/gloas/state_gloas.go | 4 +++ shared/types/eth2/generic/state.go | 1 - shared/types/eth2/participation_proof_test.go | 4 +-- 24 files changed, 90 insertions(+), 84 deletions(-) rename {shared/utils => rocketpool-cli}/cli/verify-performance/verify-performance.go (99%) rename {shared/utils => rocketpool-cli}/cli/verify-performance/verify-performance_test.go (100%) diff --git a/bindings/megapool/performance.go b/bindings/megapool/performance.go index e6d96d7b3..209abf7cb 100644 --- a/bindings/megapool/performance.go +++ b/bindings/megapool/performance.go @@ -8,13 +8,14 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" ) // Estimate the gas to call ChallengeMegapool -func EstimateChallengeMegapoolGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateChallengeMegapoolGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { - return rocketpool.GasInfo{}, err + return gaslimit.Limits{}, err } return rocketNetworkParticipation.GetTransactionGasInfo(opts, "challengeMegapool", megapoolAddress, validatorId, startEpoch, participation, slotTimestamp, slotProof) } @@ -33,10 +34,10 @@ func ChallengeMegapool(rp *rocketpool.RocketPool, megapoolAddress common.Address } // Estimate the gas to call RespondWithParticipation -func EstimateRespondWithParticipationGas(rp *rocketpool.RocketPool, challengeId uint64, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, validatorProof ValidatorProof, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateRespondWithParticipationGas(rp *rocketpool.RocketPool, challengeId uint64, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, validatorProof ValidatorProof, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { - return rocketpool.GasInfo{}, err + return gaslimit.Limits{}, err } return rocketNetworkParticipation.GetTransactionGasInfo(opts, "respondWithParticipation", challengeId, offset, challengeLeaf, challengeWitness, slotTimestamp, validatorProof, participationProof, slotProof) } @@ -56,10 +57,10 @@ func RespondWithParticipation(rp *rocketpool.RocketPool, challengeId uint64, off } // Estimate the gas to call RespondWithValidator -func EstimateRespondWithValidatorGas(rp *rocketpool.RocketPool, challengeId uint64, slotTimestamp uint64, validatorProof ValidatorProof, slotProof SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateRespondWithValidatorGas(rp *rocketpool.RocketPool, challengeId uint64, slotTimestamp uint64, validatorProof ValidatorProof, slotProof SlotProof, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { - return rocketpool.GasInfo{}, err + return gaslimit.Limits{}, err } return rocketNetworkParticipation.GetTransactionGasInfo(opts, "respondWithValidator", challengeId, slotTimestamp, validatorProof, slotProof) } @@ -79,10 +80,10 @@ func RespondWithValidator(rp *rocketpool.RocketPool, challengeId uint64, slotTim } // Estimate the gas to call FinaliseChallenge -func EstimateFinaliseChallengeGas(rp *rocketpool.RocketPool, challengeId uint64, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateFinaliseChallengeGas(rp *rocketpool.RocketPool, challengeId uint64, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { - return rocketpool.GasInfo{}, err + return gaslimit.Limits{}, err } return rocketNetworkParticipation.GetTransactionGasInfo(opts, "finaliseChallenge", challengeId) } diff --git a/bindings/settings/protocol/exit.go b/bindings/settings/protocol/exit.go index f08d4c43c..b546e4f2d 100644 --- a/bindings/settings/protocol/exit.go +++ b/bindings/settings/protocol/exit.go @@ -11,6 +11,7 @@ import ( "github.com/rocket-pool/smartnode/bindings/dao/protocol" "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" "github.com/rocket-pool/smartnode/bindings/types" ) @@ -37,7 +38,7 @@ func GetCooperativeExitPhase(rp *rocketpool.RocketPool, opts *bind.CallOpts) (ti func ProposeCooperativeExitPhase(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", CooperativeExitPhaseSettingPath), ExitSettingsContractName, CooperativeExitPhaseSettingPath, value, blockNumber, treeNodes, opts) } -func EstimateProposeCooperativeExitPhaseGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateProposeCooperativeExitPhaseGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", CooperativeExitPhaseSettingPath), ExitSettingsContractName, CooperativeExitPhaseSettingPath, value, blockNumber, treeNodes, opts) } @@ -56,7 +57,7 @@ func GetDidNotExitPenalty(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big. func ProposeDidNotExitPenalty(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", DidNotExitPenaltySettingPath), ExitSettingsContractName, DidNotExitPenaltySettingPath, value, blockNumber, treeNodes, opts) } -func EstimateProposeDidNotExitPenaltyGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateProposeDidNotExitPenaltyGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", DidNotExitPenaltySettingPath), ExitSettingsContractName, DidNotExitPenaltySettingPath, value, blockNumber, treeNodes, opts) } @@ -75,7 +76,7 @@ func GetDidNotExitCooldown(rp *rocketpool.RocketPool, opts *bind.CallOpts) (time func ProposeDidNotExitCooldown(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", DidNotExitCooldownSettingPath), ExitSettingsContractName, DidNotExitCooldownSettingPath, value, blockNumber, treeNodes, opts) } -func EstimateProposeDidNotExitCooldownGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateProposeDidNotExitCooldownGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", DidNotExitCooldownSettingPath), ExitSettingsContractName, DidNotExitCooldownSettingPath, value, blockNumber, treeNodes, opts) } diff --git a/bindings/settings/protocol/performance.go b/bindings/settings/protocol/performance.go index 57be7c9f1..ad9d36f60 100644 --- a/bindings/settings/protocol/performance.go +++ b/bindings/settings/protocol/performance.go @@ -11,6 +11,7 @@ import ( "github.com/rocket-pool/smartnode/bindings/dao/protocol" "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" "github.com/rocket-pool/smartnode/bindings/types" ) @@ -40,7 +41,7 @@ func GetPerformanceExitsEnabled(rp *rocketpool.RocketPool, opts *bind.CallOpts) func ProposePerformanceExitsEnabled(rp *rocketpool.RocketPool, value bool, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { return protocol.ProposeSetBool(rp, fmt.Sprintf("set %s", PerformanceExitsEnabledSettingPath), PerformanceSettingsContractName, PerformanceExitsEnabledSettingPath, value, blockNumber, treeNodes, opts) } -func EstimateProposePerformanceExitsEnabledGas(rp *rocketpool.RocketPool, value bool, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateProposePerformanceExitsEnabledGas(rp *rocketpool.RocketPool, value bool, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { return protocol.EstimateProposeSetBoolGas(rp, fmt.Sprintf("set %s", PerformanceExitsEnabledSettingPath), PerformanceSettingsContractName, PerformanceExitsEnabledSettingPath, value, blockNumber, treeNodes, opts) } @@ -59,7 +60,7 @@ func GetPerformancePeriod(rp *rocketpool.RocketPool, opts *bind.CallOpts) (uint6 func ProposePerformancePeriod(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformancePeriodSettingPath), PerformanceSettingsContractName, PerformancePeriodSettingPath, value, blockNumber, treeNodes, opts) } -func EstimateProposePerformancePeriodGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateProposePerformancePeriodGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformancePeriodSettingPath), PerformanceSettingsContractName, PerformancePeriodSettingPath, value, blockNumber, treeNodes, opts) } @@ -78,7 +79,7 @@ func GetPerformanceProofBuffer(rp *rocketpool.RocketPool, opts *bind.CallOpts) ( func ProposePerformanceProofBuffer(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformanceProofBufferSettingPath), PerformanceSettingsContractName, PerformanceProofBufferSettingPath, value, blockNumber, treeNodes, opts) } -func EstimateProposePerformanceProofBufferGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateProposePerformanceProofBufferGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformanceProofBufferSettingPath), PerformanceSettingsContractName, PerformanceProofBufferSettingPath, value, blockNumber, treeNodes, opts) } @@ -97,7 +98,7 @@ func GetPerformanceThreshold(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*b func ProposePerformanceThreshold(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformanceThresholdSettingPath), PerformanceSettingsContractName, PerformanceThresholdSettingPath, value, blockNumber, treeNodes, opts) } -func EstimateProposePerformanceThresholdGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateProposePerformanceThresholdGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformanceThresholdSettingPath), PerformanceSettingsContractName, PerformanceThresholdSettingPath, value, blockNumber, treeNodes, opts) } @@ -116,7 +117,7 @@ func GetPerformanceChallengePeriod(rp *rocketpool.RocketPool, opts *bind.CallOpt func ProposePerformanceChallengePeriod(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformanceChallengePeriodSettingPath), PerformanceSettingsContractName, PerformanceChallengePeriodSettingPath, value, blockNumber, treeNodes, opts) } -func EstimateProposePerformanceChallengePeriodGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateProposePerformanceChallengePeriodGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformanceChallengePeriodSettingPath), PerformanceSettingsContractName, PerformanceChallengePeriodSettingPath, value, blockNumber, treeNodes, opts) } @@ -135,7 +136,7 @@ func GetPerformanceChallengeBond(rp *rocketpool.RocketPool, opts *bind.CallOpts) func ProposePerformanceChallengeBond(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformanceChallengeBondSettingPath), PerformanceSettingsContractName, PerformanceChallengeBondSettingPath, value, blockNumber, treeNodes, opts) } -func EstimateProposePerformanceChallengeBondGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateProposePerformanceChallengeBondGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformanceChallengeBondSettingPath), PerformanceSettingsContractName, PerformanceChallengeBondSettingPath, value, blockNumber, treeNodes, opts) } diff --git a/bindings/settings/security/performance.go b/bindings/settings/security/performance.go index 08c107167..deb59add8 100644 --- a/bindings/settings/security/performance.go +++ b/bindings/settings/security/performance.go @@ -9,6 +9,7 @@ import ( "github.com/rocket-pool/smartnode/bindings/dao/security" "github.com/rocket-pool/smartnode/bindings/rocketpool" psettings "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" ) const ( @@ -19,6 +20,6 @@ const ( func ProposePerformanceExitsEnabled(rp *rocketpool.RocketPool, value bool, opts *bind.TransactOpts) (uint64, common.Hash, error) { return security.ProposeSetBool(rp, fmt.Sprintf("set %s", psettings.PerformanceExitsEnabledSettingPath), performanceNamespace, psettings.PerformanceExitsEnabledSettingPath, value, opts) } -func EstimateProposePerformanceExitsEnabledGas(rp *rocketpool.RocketPool, value bool, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateProposePerformanceExitsEnabledGas(rp *rocketpool.RocketPool, value bool, opts *bind.TransactOpts) (gaslimit.Limits, error) { return security.EstimateProposeSetBoolGas(rp, fmt.Sprintf("set %s", psettings.PerformanceExitsEnabledSettingPath), performanceNamespace, psettings.PerformanceExitsEnabledSettingPath, value, opts) } diff --git a/shared/utils/cli/verify-performance/verify-performance.go b/rocketpool-cli/cli/verify-performance/verify-performance.go similarity index 99% rename from shared/utils/cli/verify-performance/verify-performance.go rename to rocketpool-cli/cli/verify-performance/verify-performance.go index 061ce2586..04bac067b 100644 --- a/shared/utils/cli/verify-performance/verify-performance.go +++ b/rocketpool-cli/cli/verify-performance/verify-performance.go @@ -8,10 +8,10 @@ import ( "math/big" "time" + "github.com/rocket-pool/smartnode/rocketpool-cli/cli/prompt" "github.com/rocket-pool/smartnode/shared/services/performance" "github.com/rocket-pool/smartnode/shared/services/rocketpool" "github.com/rocket-pool/smartnode/shared/types/api" - "github.com/rocket-pool/smartnode/shared/utils/cli/prompt" ) // LargeEpochRangeWarning is the number of epochs above which the CLI prompts diff --git a/shared/utils/cli/verify-performance/verify-performance_test.go b/rocketpool-cli/cli/verify-performance/verify-performance_test.go similarity index 100% rename from shared/utils/cli/verify-performance/verify-performance_test.go rename to rocketpool-cli/cli/verify-performance/verify-performance_test.go diff --git a/rocketpool-cli/megapool/verify-performance.go b/rocketpool-cli/megapool/verify-performance.go index 74e04ac53..2283dcdde 100644 --- a/rocketpool-cli/megapool/verify-performance.go +++ b/rocketpool-cli/megapool/verify-performance.go @@ -7,14 +7,13 @@ import ( "github.com/ethereum/go-ethereum/common" - "github.com/rocket-pool/smartnode/bindings/utils/eth" + cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" + "github.com/rocket-pool/smartnode/rocketpool-cli/cli/prompt" + verifyperf "github.com/rocket-pool/smartnode/rocketpool-cli/cli/verify-performance" + "github.com/rocket-pool/smartnode/shared/math" "github.com/rocket-pool/smartnode/shared/services/gas" "github.com/rocket-pool/smartnode/shared/services/rocketpool" "github.com/rocket-pool/smartnode/shared/types/api" - cliutils "github.com/rocket-pool/smartnode/shared/utils/cli" - "github.com/rocket-pool/smartnode/shared/utils/cli/prompt" - verifyperf "github.com/rocket-pool/smartnode/shared/utils/cli/verify-performance" - "github.com/rocket-pool/smartnode/shared/utils/math" ) // validateMegapoolTargets checks that the verify-performance targets argument @@ -93,7 +92,7 @@ func challengePerformance(rp *rocketpool.Client, megapoolAddress common.Address, fmt.Println("\nPerformance challenges are not available until Saturn 2 is deployed.") return nil } - bondRpl := math.RoundDown(eth.WeiToEth(settings.Performance.ChallengeBond), 6) + bondRpl := math.RoundDown(math.WeiToEth(settings.Performance.ChallengeBond), 6) for _, group := range groups { ids := make([]string, len(group.ValidatorIds)) @@ -115,7 +114,7 @@ func challengePerformance(rp *rocketpool.Client, megapoolAddress common.Address, } if can.InsufficientRplBalance { fmt.Printf("The node wallet holds %.6f RPL but the challenge bond requires %.6f RPL. Skipping validator %d.\n", - math.RoundDown(eth.WeiToEth(can.RplBalance), 6), math.RoundDown(eth.WeiToEth(can.ChallengeBond), 6), validatorId) + math.RoundDown(math.WeiToEth(can.RplBalance), 6), math.RoundDown(math.WeiToEth(can.ChallengeBond), 6), validatorId) continue } if !can.CanChallenge { @@ -124,7 +123,7 @@ func challengePerformance(rp *rocketpool.Client, megapoolAddress common.Address, } // Assign max fees - err = gas.AssignMaxFeeAndLimit(can.GasInfo, rp, yes) + err = gas.AssignMaxFeeAndLimit(can.GasLimits, rp, yes) if err != nil { return err } diff --git a/rocketpool-cli/minipool/verify-performance.go b/rocketpool-cli/minipool/verify-performance.go index 6ec7c2aaf..13b0d9bc6 100644 --- a/rocketpool-cli/minipool/verify-performance.go +++ b/rocketpool-cli/minipool/verify-performance.go @@ -5,10 +5,10 @@ import ( "strings" "time" + cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" + verifyperf "github.com/rocket-pool/smartnode/rocketpool-cli/cli/verify-performance" "github.com/rocket-pool/smartnode/shared/services/rocketpool" "github.com/rocket-pool/smartnode/shared/types/api" - cliutils "github.com/rocket-pool/smartnode/shared/utils/cli" - verifyperf "github.com/rocket-pool/smartnode/shared/utils/cli/verify-performance" ) // validateMinipoolTargets checks that the verify-performance targets argument diff --git a/rocketpool-cli/pdao/get-settings.go b/rocketpool-cli/pdao/get-settings.go index 11e8da57e..c70b60a3b 100644 --- a/rocketpool-cli/pdao/get-settings.go +++ b/rocketpool-cli/pdao/get-settings.go @@ -138,15 +138,15 @@ func getSettings() error { fmt.Printf("\tPerformance Exits Enabled: %t\n", response.Performance.ExitsEnabled) fmt.Printf("\tPerformance Period: %d Epochs\n", response.Performance.Period) fmt.Printf("\tProof Buffer: %s\n", response.Performance.ProofBuffer) - fmt.Printf("\tPerformance Threshold: %.2f%%\n", eth.WeiToEth(response.Performance.Threshold)*100) + fmt.Printf("\tPerformance Threshold: %.2f%%\n", math.WeiToEth(response.Performance.Threshold)*100) fmt.Printf("\tChallenge Period: %s\n", response.Performance.ChallengePeriod) - fmt.Printf("\tChallenge Bond: %.6f RPL\n", eth.WeiToEth(response.Performance.ChallengeBond)) + fmt.Printf("\tChallenge Bond: %.6f RPL\n", math.WeiToEth(response.Performance.ChallengeBond)) fmt.Println() // Exit fmt.Println("== Exit Settings (RPIP-80) ==") fmt.Printf("\tCooperative Exit Phase: %.0f Hours\n", response.Exit.CooperativeExitPhase.Hours()) - fmt.Printf("\tDid Not Exit Penalty: %.6f ETH\n", eth.WeiToEth(response.Exit.DidNotExitPenalty)) + fmt.Printf("\tDid Not Exit Penalty: %.6f ETH\n", math.WeiToEth(response.Exit.DidNotExitPenalty)) fmt.Printf("\tDid Not Exit Cooldown: %s\n", response.Exit.DidNotExitCooldown) fmt.Println() } diff --git a/rocketpool/api/megapool/challenge-performance.go b/rocketpool/api/megapool/challenge-performance.go index ec6109afe..a9df7eaba 100644 --- a/rocketpool/api/megapool/challenge-performance.go +++ b/rocketpool/api/megapool/challenge-performance.go @@ -86,7 +86,7 @@ func canChallengePerformance( if err != nil { return nil, err } - response.GasInfo, err = megapool.EstimateChallengeMegapoolGas(rp, megapoolAddress, validatorId, startEpoch, participation, slotTimestamp, slotProof, opts) + response.GasLimits, err = megapool.EstimateChallengeMegapoolGas(rp, megapoolAddress, validatorId, startEpoch, participation, slotTimestamp, slotProof, opts) if err != nil { return nil, fmt.Errorf("error estimating challengeMegapool gas: %w", err) } diff --git a/rocketpool/api/megapool/verify-performance.go b/rocketpool/api/megapool/verify-performance.go index 6e91d2b75..d64a8bea8 100644 --- a/rocketpool/api/megapool/verify-performance.go +++ b/rocketpool/api/megapool/verify-performance.go @@ -11,12 +11,12 @@ import ( "github.com/rocket-pool/smartnode/bindings/node" rptypes "github.com/rocket-pool/smartnode/bindings/types" + cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" "github.com/rocket-pool/smartnode/rocketpool/api/response" "github.com/rocket-pool/smartnode/rocketpool/api/snroute" "github.com/rocket-pool/smartnode/shared/services" "github.com/rocket-pool/smartnode/shared/services/performance" "github.com/rocket-pool/smartnode/shared/types/api" - cliutils "github.com/rocket-pool/smartnode/shared/utils/cli" ) // verifyPerformance computes the RPIP-73 target-vote performance over the diff --git a/rocketpool/api/minipool/verify-performance.go b/rocketpool/api/minipool/verify-performance.go index 678bf26d3..797e85b05 100644 --- a/rocketpool/api/minipool/verify-performance.go +++ b/rocketpool/api/minipool/verify-performance.go @@ -11,12 +11,12 @@ import ( "github.com/rocket-pool/smartnode/bindings/rocketpool" rptypes "github.com/rocket-pool/smartnode/bindings/types" + cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" "github.com/rocket-pool/smartnode/rocketpool/api/response" "github.com/rocket-pool/smartnode/rocketpool/api/snroute" "github.com/rocket-pool/smartnode/shared/services" "github.com/rocket-pool/smartnode/shared/services/performance" "github.com/rocket-pool/smartnode/shared/types/api" - cliutils "github.com/rocket-pool/smartnode/shared/utils/cli" ) // verifyPerformance computes the RPIP-73 target-vote performance over the diff --git a/rocketpool/api/pdao/propose-settings.go b/rocketpool/api/pdao/propose-settings.go index a57ec98ce..5fce0ad5f 100644 --- a/rocketpool/api/pdao/propose-settings.go +++ b/rocketpool/api/pdao/propose-settings.go @@ -969,7 +969,7 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, if err != nil { return nil, err } - response.GasInfo, err = protocol.EstimateProposePerformanceExitsEnabledGas(rp, newValue, blockNumber, pollard, opts) + response.GasLimits, err = protocol.EstimateProposePerformanceExitsEnabledGas(rp, newValue, blockNumber, pollard, opts) if err != nil { return nil, fmt.Errorf("error estimating gas for proposing PerformanceExitsEnabled: %w", err) } @@ -980,7 +980,7 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, if err != nil { return nil, err } - response.GasInfo, err = protocol.EstimateProposePerformancePeriodGas(rp, newValue, blockNumber, pollard, opts) + response.GasLimits, err = protocol.EstimateProposePerformancePeriodGas(rp, newValue, blockNumber, pollard, opts) if err != nil { return nil, fmt.Errorf("error estimating gas for proposing PerformancePeriod: %w", err) } @@ -991,7 +991,7 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, if err != nil { return nil, err } - response.GasInfo, err = protocol.EstimateProposePerformanceProofBufferGas(rp, newValue, blockNumber, pollard, opts) + response.GasLimits, err = protocol.EstimateProposePerformanceProofBufferGas(rp, newValue, blockNumber, pollard, opts) if err != nil { return nil, fmt.Errorf("error estimating gas for proposing PerformanceProofBuffer: %w", err) } @@ -1002,7 +1002,7 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, if err != nil { return nil, err } - response.GasInfo, err = protocol.EstimateProposePerformanceThresholdGas(rp, newValue, blockNumber, pollard, opts) + response.GasLimits, err = protocol.EstimateProposePerformanceThresholdGas(rp, newValue, blockNumber, pollard, opts) if err != nil { return nil, fmt.Errorf("error estimating gas for proposing PerformanceThreshold: %w", err) } @@ -1013,7 +1013,7 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, if err != nil { return nil, err } - response.GasInfo, err = protocol.EstimateProposePerformanceChallengePeriodGas(rp, newValue, blockNumber, pollard, opts) + response.GasLimits, err = protocol.EstimateProposePerformanceChallengePeriodGas(rp, newValue, blockNumber, pollard, opts) if err != nil { return nil, fmt.Errorf("error estimating gas for proposing PerformanceChallengePeriod: %w", err) } @@ -1024,7 +1024,7 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, if err != nil { return nil, err } - response.GasInfo, err = protocol.EstimateProposePerformanceChallengeBondGas(rp, newValue, blockNumber, pollard, opts) + response.GasLimits, err = protocol.EstimateProposePerformanceChallengeBondGas(rp, newValue, blockNumber, pollard, opts) if err != nil { return nil, fmt.Errorf("error estimating gas for proposing PerformanceChallengeBond: %w", err) } @@ -1038,7 +1038,7 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, if err != nil { return nil, err } - response.GasInfo, err = protocol.EstimateProposeCooperativeExitPhaseGas(rp, newValue, blockNumber, pollard, opts) + response.GasLimits, err = protocol.EstimateProposeCooperativeExitPhaseGas(rp, newValue, blockNumber, pollard, opts) if err != nil { return nil, fmt.Errorf("error estimating gas for proposing CooperativeExitPhase: %w", err) } @@ -1049,7 +1049,7 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, if err != nil { return nil, err } - response.GasInfo, err = protocol.EstimateProposeDidNotExitPenaltyGas(rp, newValue, blockNumber, pollard, opts) + response.GasLimits, err = protocol.EstimateProposeDidNotExitPenaltyGas(rp, newValue, blockNumber, pollard, opts) if err != nil { return nil, fmt.Errorf("error estimating gas for proposing DidNotExitPenalty: %w", err) } @@ -1060,7 +1060,7 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, if err != nil { return nil, err } - response.GasInfo, err = protocol.EstimateProposeDidNotExitCooldownGas(rp, newValue, blockNumber, pollard, opts) + response.GasLimits, err = protocol.EstimateProposeDidNotExitCooldownGas(rp, newValue, blockNumber, pollard, opts) if err != nil { return nil, fmt.Errorf("error estimating gas for proposing DidNotExitCooldown: %w", err) } diff --git a/rocketpool/api/security/propose-settings.go b/rocketpool/api/security/propose-settings.go index 060897839..7f9c9c7b0 100644 --- a/rocketpool/api/security/propose-settings.go +++ b/rocketpool/api/security/propose-settings.go @@ -211,7 +211,7 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, if err != nil { return nil, err } - response.GasInfo, err = security.EstimateProposePerformanceExitsEnabledGas(rp, newValue, opts) + response.GasLimits, err = security.EstimateProposePerformanceExitsEnabledGas(rp, newValue, opts) if err != nil { return nil, fmt.Errorf("error estimating gas for proposing PerformanceExitsEnabled: %w", err) } diff --git a/rocketpool/node/defend-challenge-performance.go b/rocketpool/node/defend-challenge-performance.go index 5d8c30261..0704fd07e 100644 --- a/rocketpool/node/defend-challenge-performance.go +++ b/rocketpool/node/defend-challenge-performance.go @@ -14,9 +14,11 @@ import ( "github.com/rocket-pool/smartnode/bindings/megapool" "github.com/rocket-pool/smartnode/bindings/rocketpool" "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/bindings/transactions" "github.com/rocket-pool/smartnode/bindings/types" - "github.com/rocket-pool/smartnode/bindings/utils/eth" + log "github.com/rocket-pool/smartnode/shared/logger" + "github.com/rocket-pool/smartnode/shared/math" "github.com/rocket-pool/smartnode/shared/services" "github.com/rocket-pool/smartnode/shared/services/beacon" "github.com/rocket-pool/smartnode/shared/services/config" @@ -24,8 +26,6 @@ import ( "github.com/rocket-pool/smartnode/shared/services/performance" "github.com/rocket-pool/smartnode/shared/services/state" "github.com/rocket-pool/smartnode/shared/services/wallet" - "github.com/rocket-pool/smartnode/shared/utils/api" - "github.com/rocket-pool/smartnode/shared/utils/log" ) // Stake megapool validator task @@ -113,7 +113,7 @@ func newDefendChallengePerformance(c *cli.Command, logger log.ColorLogger) (*def if maxFeeGwei == 0 { maxFee = nil } else { - maxFee = eth.GweiToWei(maxFeeGwei) + maxFee = math.GweiToWei(maxFeeGwei) } // Get the user-requested max fee @@ -121,9 +121,9 @@ func newDefendChallengePerformance(c *cli.Command, logger log.ColorLogger) (*def var priorityFee *big.Int if priorityFeeGwei == 0 { logger.Printlnf("WARNING: priority fee was missing or 0, setting a default of %.2f.", rpgas.DefaultPriorityFeeGwei) - priorityFee = eth.GweiToWei(rpgas.DefaultPriorityFeeGwei) + priorityFee = math.GweiToWei(rpgas.DefaultPriorityFeeGwei) } else { - priorityFee = eth.GweiToWei(priorityFeeGwei) + priorityFee = math.GweiToWei(priorityFeeGwei) } // Return task @@ -305,7 +305,7 @@ func (t *defendChallengePerformance) finaliseChallenge(challenge megapoolPerform if err != nil { return fmt.Errorf("could not estimate the gas required to finalise challenge %d: %w", challenge.challengeId, err) } - gas := big.NewInt(int64(gasInfo.SafeGasLimit)) + gas := big.NewInt(int64(gasInfo.Safe)) // Get the max fee maxFee := t.maxFee @@ -317,7 +317,7 @@ func (t *defendChallengePerformance) finaliseChallenge(challenge megapoolPerform } // Print the gas info - if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + if !gasInfo.PrintAndCheck(true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { return nil } @@ -332,7 +332,7 @@ func (t *defendChallengePerformance) finaliseChallenge(challenge megapoolPerform } // Print TX info and wait for it to be included in a block - err = api.PrintAndWaitForTransaction(t.cfg, txHash, t.rp.Client, &t.log) + err = transactions.PrintAndWaitForTransaction(t.cfg, txHash, t.rp.Client, &t.log) if err != nil { return err } @@ -368,7 +368,7 @@ func (t *defendChallengePerformance) respondWithValidator(challenge megapoolPerf return err } - gas := big.NewInt(int64(gasInfo.SafeGasLimit)) + gas := big.NewInt(int64(gasInfo.Safe)) // Get the max fee maxFee := t.maxFee if maxFee == nil || maxFee.Uint64() == 0 { @@ -379,7 +379,7 @@ func (t *defendChallengePerformance) respondWithValidator(challenge megapoolPerf } // Print the gas info - if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + if !gasInfo.PrintAndCheck(true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { return nil } @@ -394,7 +394,7 @@ func (t *defendChallengePerformance) respondWithValidator(challenge megapoolPerf } // Print TX info and wait for it to be included in a block - err = api.PrintAndWaitForTransaction(t.cfg, txHash, t.rp.Client, &t.log) + err = transactions.PrintAndWaitForTransaction(t.cfg, txHash, t.rp.Client, &t.log) if err != nil { return err } @@ -428,7 +428,7 @@ func (t *defendChallengePerformance) defendChallenge(rp *rocketpool.RocketPool, return err } - gas := big.NewInt(int64(gasInfo.SafeGasLimit)) + gas := big.NewInt(int64(gasInfo.Safe)) // Get the max fee maxFee := t.maxFee if maxFee == nil || maxFee.Uint64() == 0 { @@ -439,7 +439,7 @@ func (t *defendChallengePerformance) defendChallenge(rp *rocketpool.RocketPool, } // Print the gas info - if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + if !gasInfo.PrintAndCheck(true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { return nil } @@ -454,7 +454,7 @@ func (t *defendChallengePerformance) defendChallenge(rp *rocketpool.RocketPool, } // Print TX info and wait for it to be included in a block - err = api.PrintAndWaitForTransaction(t.cfg, txHash, t.rp.Client, &t.log) + err = transactions.PrintAndWaitForTransaction(t.cfg, txHash, t.rp.Client, &t.log) if err != nil { return err } diff --git a/shared/services/megapools.go b/shared/services/megapools.go index 7915a73a3..bab5a4fb4 100644 --- a/shared/services/megapools.go +++ b/shared/services/megapools.go @@ -267,7 +267,7 @@ func verifyParticipationStateLink(linkState eth2.BeaconState, participationState if !ok { return nil } - participationRoot, err := participationFulu.HashTreeRoot() + participationRoot, err := generic.SSZ.HashTreeRoot(participationFulu) if err != nil { return fmt.Errorf("error hashing the participation state: %w", err) } @@ -343,7 +343,7 @@ func GetParticipationProof(c *cli.Command, validatorIndex uint64, validatorPubke if err != nil { return PerformanceDefenseProofs{}, fmt.Errorf("error getting beacon state at slot %d (an archive Beacon Node may be required for old epochs): %w", participationSlot, err) } - participationState, err := eth2.NewBeaconState(stateResponse.Data, stateResponse.Fork) + participationState, err := eth2.NewBeaconState(stateResponse.Data, stateResponse.Size, stateResponse.Fork) if err != nil { return PerformanceDefenseProofs{}, fmt.Errorf("error parsing beacon state at slot %d: %w", participationSlot, err) } @@ -410,7 +410,7 @@ func GetParticipationProof(c *cli.Command, validatorIndex uint64, validatorPubke if err != nil { return PerformanceDefenseProofs{}, fmt.Errorf("error getting the era boundary state at slot %d (an archive Beacon Node may be required): %w", eraBoundarySlot, err) } - eraState, err := eth2.NewBeaconState(eraStateResponse.Data, eraStateResponse.Fork) + eraState, err := eth2.NewBeaconState(eraStateResponse.Data, eraStateResponse.Size, eraStateResponse.Fork) if err != nil { return PerformanceDefenseProofs{}, fmt.Errorf("error parsing the era boundary state at slot %d: %w", eraBoundarySlot, err) } diff --git a/shared/services/megapools_test.go b/shared/services/megapools_test.go index 835c8c132..570d1f6d5 100644 --- a/shared/services/megapools_test.go +++ b/shared/services/megapools_test.go @@ -510,13 +510,13 @@ func walkWitnessChain(t *testing.T, leaf []byte, gid *big.Int, witnesses [][]byt // filled in. func anchorBlockRoot(t *testing.T, state *fulu.BeaconState) []byte { t.Helper() - stateRoot, err := state.HashTreeRoot() + stateRoot, err := generic.SSZ.HashTreeRoot(state) if err != nil { t.Fatalf("failed to hash anchor state: %v", err) } header := *state.LatestBlockHeader header.StateRoot = stateRoot[:] - blockRoot, err := header.HashTreeRoot() + blockRoot, err := generic.SSZ.HashTreeRoot(&header) if err != nil { t.Fatalf("failed to hash anchor block header: %v", err) } @@ -533,7 +533,7 @@ func TestBuildRecentParticipationWitnesses(t *testing.T) { anchorState := newProofTestFuluState(t, numValidators, anchorSlot) // Wire the anchor's state_roots vector to commit to the participation state - participationRoot, err := participationState.HashTreeRoot() + participationRoot, err := generic.SSZ.HashTreeRoot(participationState) if err != nil { t.Fatalf("failed to hash participation state: %v", err) } @@ -585,7 +585,7 @@ func TestBuildHistoricalParticipationWitnesses(t *testing.T) { // Wire the era boundary state's state_roots vector to commit to the // participation state - participationRoot, err := participationState.HashTreeRoot() + participationRoot, err := generic.SSZ.HashTreeRoot(participationState) if err != nil { t.Fatalf("failed to hash participation state: %v", err) } @@ -601,7 +601,7 @@ func TestBuildHistoricalParticipationWitnesses(t *testing.T) { BlockRoots: eraState.BlockRoots, StateRoots: eraState.StateRoots, } - hslsTree, err := hsls.GetTree() + hslsTree, err := generic.SSZ.GetTree(&hsls) if err != nil { t.Fatalf("failed to get historical summary lists tree: %v", err) } diff --git a/shared/services/performance/target-performance.go b/shared/services/performance/target-performance.go index 1e8b760ee..c6722f451 100644 --- a/shared/services/performance/target-performance.go +++ b/shared/services/performance/target-performance.go @@ -22,7 +22,7 @@ import ( "github.com/rocket-pool/smartnode/bindings/rocketpool" "github.com/rocket-pool/smartnode/bindings/settings/protocol" rptypes "github.com/rocket-pool/smartnode/bindings/types" - "github.com/rocket-pool/smartnode/bindings/utils/eth" + "github.com/rocket-pool/smartnode/shared/math" "github.com/rocket-pool/smartnode/shared/services/beacon" "github.com/rocket-pool/smartnode/shared/services/state" @@ -53,7 +53,7 @@ func GetPerformanceThresholdPct(rp *rocketpool.RocketPool) (float64, error) { if err != nil { return 0, fmt.Errorf("error getting performance threshold: %w", err) } - return eth.WeiToEth(thresholdWei) * 100.0, nil + return math.WeiToEth(thresholdWei) * 100.0, nil } // Defaults used before Saturn 2 deploys. diff --git a/shared/types/api/node.go b/shared/types/api/node.go index e9cf580e4..74b36ed08 100644 --- a/shared/types/api/node.go +++ b/shared/types/api/node.go @@ -750,13 +750,13 @@ type NotifyValidatorExitResponse struct { } type CanChallengeMegapoolPerformanceResponse struct { - Status string `json:"status"` - Error string `json:"error"` - CanChallenge bool `json:"canChallenge"` - InsufficientRplBalance bool `json:"insufficientRplBalance"` - ChallengeBond *big.Int `json:"challengeBond"` - RplBalance *big.Int `json:"rplBalance"` - GasInfo rocketpool.GasInfo `json:"gasInfo"` + Status string `json:"status"` + Error string `json:"error"` + CanChallenge bool `json:"canChallenge"` + InsufficientRplBalance bool `json:"insufficientRplBalance"` + ChallengeBond *big.Int `json:"challengeBond"` + RplBalance *big.Int `json:"rplBalance"` + GasLimits gaslimit.Limits `json:"gasLimits"` } type ChallengeMegapoolPerformanceResponse struct { Status string `json:"status"` diff --git a/shared/types/eth2/fork/electra/state_electra.go b/shared/types/eth2/fork/electra/state_electra.go index 16cdafa4f..08ad0c458 100644 --- a/shared/types/eth2/fork/electra/state_electra.go +++ b/shared/types/eth2/fork/electra/state_electra.go @@ -309,7 +309,7 @@ func (state *BeaconState) HistoricalSummaryStateRootProof(slot int) ([][]byte, e } idx := slot % int(generic.SlotsPerHistoricalRoot) - tree, err := hsls.GetTree() + tree, err := generic.SSZ.GetTree(&hsls) if err != nil { return nil, fmt.Errorf("could not get historical summary lists tree: %w", err) } @@ -339,7 +339,7 @@ func (state *BeaconState) StateRootProof(slot uint64) ([][]byte, error) { return nil, fmt.Errorf("slot %d is more than %d slots in the past from the state at slot %d, you must build a proof from the historical_summaries instead", slot, generic.SlotsPerHistoricalRoot, state.Slot) } - tree, err := state.GetTree() + tree, err := generic.SSZ.GetTree(state) if err != nil { return nil, fmt.Errorf("could not get state tree: %w", err) } diff --git a/shared/types/eth2/fork/fulu/state_fulu.go b/shared/types/eth2/fork/fulu/state_fulu.go index 63510f5fa..e8b0a70e0 100644 --- a/shared/types/eth2/fork/fulu/state_fulu.go +++ b/shared/types/eth2/fork/fulu/state_fulu.go @@ -282,7 +282,7 @@ func (state *BeaconState) HistoricalSummaryStateRootProof(slot int) ([][]byte, e } idx := slot % int(generic.SlotsPerHistoricalRoot) - tree, err := hsls.GetTree() + tree, err := generic.SSZ.GetTree(&hsls) if err != nil { return nil, fmt.Errorf("could not get historical summary lists tree: %w", err) } @@ -312,7 +312,7 @@ func (state *BeaconState) StateRootProof(slot uint64) ([][]byte, error) { return nil, fmt.Errorf("slot %d is more than %d slots in the past from the state at slot %d, you must build a proof from the historical_summaries instead", slot, generic.SlotsPerHistoricalRoot, state.Slot) } - tree, err := state.GetTree() + tree, err := generic.SSZ.GetTree(state) if err != nil { return nil, fmt.Errorf("could not get state tree: %w", err) } diff --git a/shared/types/eth2/fork/gloas/state_gloas.go b/shared/types/eth2/fork/gloas/state_gloas.go index 621af530d..c0d269c7a 100644 --- a/shared/types/eth2/fork/gloas/state_gloas.go +++ b/shared/types/eth2/fork/gloas/state_gloas.go @@ -533,3 +533,7 @@ func (state *BeaconState) GetSlot() uint64 { func (state *BeaconState) GetPreviousEpochParticipation() []byte { return state.PreviousEpochParticipation } + +func (state *BeaconState) PreviousEpochParticipationChunkProof(validatorIndex uint64) ([32]byte, [][]byte, error) { + return [32]byte{}, nil, fmt.Errorf("participation proofs are not supported for gloas states yet") +} diff --git a/shared/types/eth2/generic/state.go b/shared/types/eth2/generic/state.go index 0d02d4bb3..062989e47 100644 --- a/shared/types/eth2/generic/state.go +++ b/shared/types/eth2/generic/state.go @@ -21,7 +21,6 @@ const BeaconStateHistoricalSummariesMaxLength uint64 = 1 << 24 const BeaconStateBlockRootsMaxLength uint64 = 1 << 13 const BeaconStateBlockRootsFieldIndex uint64 = 5 const BeaconStateStateRootsMaxLength uint64 = 1 << 13 -const BeaconStateStateRootsFieldIndex uint64 = 6 // BeaconStateStateRootsFieldIndex is the field offset of the StateRoots field in the BeaconState struct const BeaconStateStateRootsFieldIndex uint64 = 6 diff --git a/shared/types/eth2/participation_proof_test.go b/shared/types/eth2/participation_proof_test.go index d864b3481..74caa9ab2 100644 --- a/shared/types/eth2/participation_proof_test.go +++ b/shared/types/eth2/participation_proof_test.go @@ -7,9 +7,9 @@ import ( "encoding/json" "testing" + hexutils "github.com/rocket-pool/smartnode/shared/hex" "github.com/rocket-pool/smartnode/shared/types/eth2/fork/fulu" "github.com/rocket-pool/smartnode/shared/types/eth2/generic" - hexutils "github.com/rocket-pool/smartnode/shared/utils/hex" ) // newTestFuluState builds a minimal but SSZ-valid fulu beacon state with @@ -122,7 +122,7 @@ func validateFuluStateProofToStateRoot(t *testing.T, leaf []byte, proof [][]byte currentHash := walkProof(t, leaf, proof, gid) - stateRoot, err := state.HashTreeRoot() + stateRoot, err := generic.SSZ.HashTreeRoot(state) if err != nil { t.Fatalf("Failed to get state root: %v", err) } From c3fd6e7fc4de0e4a8fa2867607288c77a9c566e1 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:14:55 -0300 Subject: [PATCH 20/35] Add PreviousEpochParticipationChunkProof for Gloas --- shared/services/megapools.go | 31 +++-- .../eth2/fork/gloas/gloas_proofs_test.go | 129 ++++++++++++++++++ shared/types/eth2/fork/gloas/state_gloas.go | 54 +++++++- 3 files changed, 200 insertions(+), 14 deletions(-) diff --git a/shared/services/megapools.go b/shared/services/megapools.go index bab5a4fb4..47423f259 100644 --- a/shared/services/megapools.go +++ b/shared/services/megapools.go @@ -256,23 +256,30 @@ func buildHistoricalParticipationWitnesses(anchorState eth2.BeaconState, eraStat // verifyParticipationStateLink sanity checks that linkState's state_roots // vector commits to the participation state's hash tree root before anything -// is submitted on chain. The check is skipped for fork combinations that -// can't be inspected (non-fulu states) +// is submitted on chain. The link and participation states are inspected +// independently (they can be different forks across a fork boundary); the +// check is skipped for forks it can't inspect. func verifyParticipationStateLink(linkState eth2.BeaconState, participationState eth2.BeaconState, participationSlot uint64) error { - linkFulu, ok := linkState.(*fulu.BeaconState) - if !ok { + var stateRoots *[8192][32]byte + switch s := linkState.(type) { + case *fulu.BeaconState: + stateRoots = &s.StateRoots + case *gloas.BeaconState: + stateRoots = &s.StateRoots + default: return nil } - participationFulu, ok := participationState.(*fulu.BeaconState) - if !ok { + switch participationState.(type) { + case *fulu.BeaconState, *gloas.BeaconState: + default: return nil } - participationRoot, err := generic.SSZ.HashTreeRoot(participationFulu) + participationRoot, err := generic.SSZ.HashTreeRoot(participationState) if err != nil { return fmt.Errorf("error hashing the participation state: %w", err) } - if linkFulu.StateRoots[participationSlot%generic.SlotsPerHistoricalRoot] != participationRoot { - return fmt.Errorf("the state at slot %d does not commit to the root of the participation state at slot %d", linkFulu.Slot, participationSlot) + if stateRoots[participationSlot%generic.SlotsPerHistoricalRoot] != participationRoot { + return fmt.Errorf("the state at slot %d does not commit to the root of the participation state at slot %d", linkState.GetSlot(), participationSlot) } return nil } @@ -286,8 +293,7 @@ func verifyParticipationStateLink(linkState eth2.BeaconState, participationState // previous_epoch_participation flags for the challenged epoch. All proofs are // anchored at a recent finalized state: the participation state's root is // proven through the anchor's state_roots vector when it is at most 8192 -// slots old, or through historical_summaries otherwise, mirroring the -// on-chain BeaconStateVerifier path construction. +// slots old, or through historical_summaries otherwise. func GetParticipationProof(c *cli.Command, validatorIndex uint64, validatorPubkey types.ValidatorPubkey, challengedEpoch uint64, startEpoch uint64, participation []*big.Int) (PerformanceDefenseProofs, error) { // Locate the challenged epoch within the challenge participation bitmap if challengedEpoch < startEpoch { @@ -390,8 +396,7 @@ func GetParticipationProof(c *cli.Command, validatorIndex uint64, validatorPubke // Extend the participation chunk proof up to the anchor block root, via // the anchor's state_roots vector when the participation slot is recent - // or via historical_summaries otherwise (matching the on-chain - // _pathBeaconStateToPastStateRoot branch) + // or via historical_summaries otherwise var participationWitnesses [][]byte if participationSlot+generic.SlotsPerHistoricalRoot >= anchorSlot { if err := verifyParticipationStateLink(anchorState, participationState, participationSlot); err != nil { diff --git a/shared/types/eth2/fork/gloas/gloas_proofs_test.go b/shared/types/eth2/fork/gloas/gloas_proofs_test.go index 5dbd8bbe4..0dad405e9 100644 --- a/shared/types/eth2/fork/gloas/gloas_proofs_test.go +++ b/shared/types/eth2/fork/gloas/gloas_proofs_test.go @@ -22,6 +22,11 @@ func TestGloasFieldGindices(t *testing.T) { {"historical_summaries", GetGeneralizedIndexForHistoricalSummaries(), 2950}, {"validators[0]", GetGeneralizedIndexForValidator(0), 1432}, {"validators[1]", GetGeneralizedIndexForValidator(1), 11464}, + {"previous_epoch_participation", GetGeneralizedIndexForPreviousEpochParticipation(), 362}, + {"participation chunk 0", GetGeneralizedIndexForParticipationChunk(0), 1448}, + {"participation chunk 1", GetGeneralizedIndexForParticipationChunk(1), 11592}, + {"participation chunk 4", GetGeneralizedIndexForParticipationChunk(4), 11595}, + {"participation chunk 5", GetGeneralizedIndexForParticipationChunk(5), 92768}, } for _, tc := range cases { if tc.got != tc.want { @@ -442,3 +447,127 @@ func TestHistoricalBoundarySplit(t *testing.T) { t.Fatalf("W-1 at distance 8193 should use the historical path") } } + +func TestPreviousEpochParticipationChunkProofProgressive(t *testing.T) { + state := minimalBeaconState() + + // 180 flag bytes span packed chunks 0-5: chunks 0-4 fill progressive + // levels 0 and 1, chunk 5 is the first (partial) chunk of level 2. + const numValidators = 180 + state.PreviousEpochParticipation = make([]byte, numValidators) + for i := range state.PreviousEpochParticipation { + state.PreviousEpochParticipation[i] = byte(i % 8) + } + + tree, err := generic.SSZ.GetTree(state) + if err != nil { + t.Fatalf("GetTree: %v", err) + } + + cases := []struct { + validatorIndex uint64 + wantDepth int + }{ + {0, 10}, // chunk 0 (progressive level 0) + {31, 10}, // chunk 0, last byte + {32, 13}, // chunk 1, first chunk of level 1 + {70, 13}, // chunk 2, mid level 1 + {127, 13}, // chunk 3, last byte + {128, 13}, // chunk 4, last chunk of level 1 + {160, 16}, // chunk 5, first chunk of level 2 + {179, 16}, // chunk 5, tail of the partial chunk + } + for _, tc := range cases { + chunk, hashes, err := state.PreviousEpochParticipationChunkProof(tc.validatorIndex) + if err != nil { + t.Fatalf("chunk proof for validator %d: %v", tc.validatorIndex, err) + } + if got, want := chunk[tc.validatorIndex%32], state.PreviousEpochParticipation[tc.validatorIndex]; got != want { + t.Fatalf("validator %d: chunk byte %d != flags %d", tc.validatorIndex, got, want) + } + if len(hashes) != tc.wantDepth { + t.Fatalf("validator %d: proof depth %d, want %d", tc.validatorIndex, len(hashes), tc.wantDepth) + } + + gid := GetGeneralizedIndexForParticipationChunk(tc.validatorIndex / 32) + direct, err := tree.Prove(int(gid)) + if err != nil { + t.Fatalf("direct Prove gid %d: %v", gid, err) + } + if !bytes.Equal(direct.Leaf, chunk[:]) { + t.Fatalf("validator %d: direct leaf %x != chunk %x", tc.validatorIndex, direct.Leaf, chunk) + } + if ok, err := treeproof.VerifyProof(tree.Hash(), direct); err != nil || !ok { + t.Fatalf("validator %d: verify participation chunk: ok=%v err=%v", tc.validatorIndex, ok, err) + } + if len(hashes) != len(direct.Hashes) { + t.Fatalf("validator %d: helper proof length %d != direct %d", tc.validatorIndex, len(hashes), len(direct.Hashes)) + } + for i := range hashes { + if !bytes.Equal(hashes[i], direct.Hashes[i]) { + t.Fatalf("validator %d: proof hash[%d] mismatch", tc.validatorIndex, i) + } + } + } + + // Out-of-bounds index must error. + if _, _, err := state.PreviousEpochParticipationChunkProof(numValidators); err == nil { + t.Fatalf("expected out-of-bounds error") + } + + // An empty participation list must reject every index. + empty := minimalBeaconState() + if _, _, err := empty.PreviousEpochParticipationChunkProof(0); err == nil { + t.Fatalf("expected error for empty participation list") + } +} + +func TestPreviousEpochParticipationChunkProofLargeIndex(t *testing.T) { + state := minimalBeaconState() + + // A registry of over a million validators: index 1,048,600 sits in packed + // chunk 32768, which lives in progressive level 8 of the list, so the + // proof must be 10 + 3*8 = 34 hashes deep. + const validatorIndex = uint64(1_048_600) + const numValidators = validatorIndex + 50 + state.PreviousEpochParticipation = make([]byte, numValidators) + for i := range state.PreviousEpochParticipation { + state.PreviousEpochParticipation[i] = byte(i % 8) + } + + tree, err := generic.SSZ.GetTree(state) + if err != nil { + t.Fatalf("GetTree: %v", err) + } + + chunk, hashes, err := state.PreviousEpochParticipationChunkProof(validatorIndex) + if err != nil { + t.Fatalf("chunk proof for validator %d: %v", validatorIndex, err) + } + if got, want := chunk[validatorIndex%32], state.PreviousEpochParticipation[validatorIndex]; got != want { + t.Fatalf("chunk byte %d != flags %d", got, want) + } + if len(hashes) != 34 { + t.Fatalf("proof depth %d, want 34", len(hashes)) + } + + gid := GetGeneralizedIndexForParticipationChunk(validatorIndex / 32) + direct, err := tree.Prove(int(gid)) + if err != nil { + t.Fatalf("direct Prove gid %d: %v", gid, err) + } + if !bytes.Equal(direct.Leaf, chunk[:]) { + t.Fatalf("direct leaf %x != chunk %x", direct.Leaf, chunk) + } + if ok, err := treeproof.VerifyProof(tree.Hash(), direct); err != nil || !ok { + t.Fatalf("verify participation chunk: ok=%v err=%v", ok, err) + } + if len(hashes) != len(direct.Hashes) { + t.Fatalf("helper proof length %d != direct %d", len(hashes), len(direct.Hashes)) + } + for i := range hashes { + if !bytes.Equal(hashes[i], direct.Hashes[i]) { + t.Fatalf("proof hash[%d] mismatch", i) + } + } +} diff --git a/shared/types/eth2/fork/gloas/state_gloas.go b/shared/types/eth2/fork/gloas/state_gloas.go index c0d269c7a..8233917a1 100644 --- a/shared/types/eth2/fork/gloas/state_gloas.go +++ b/shared/types/eth2/fork/gloas/state_gloas.go @@ -20,6 +20,8 @@ const ( beaconStateBalancesFieldIndex = generic.BeaconStateBalancesFieldIndex // 12 beaconStateNextWithdrawalIndexFieldIndex = generic.BeaconStateNextWithdrawalIndexFieldIndex // 25 beaconStateHistoricalSummariesFieldIndex = generic.BeaconStateHistoricalSummariesFieldIndex // 27 + + beaconStatePreviousEpochParticipationFieldIndex = generic.BeaconStatePreviousEpochParticipationFieldIndex // 15 // New in Gloas (EIP-7732): payload_expected_withdrawals commits to the // withdrawals of the execution payload bid on at this slot. beaconStatePayloadExpectedWithdrawalsFieldIndex = 44 @@ -188,6 +190,23 @@ func GetGeneralizedIndexForBalanceChunk(validatorIndex uint64) uint64 { ) } +// GetGeneralizedIndexForPreviousEpochParticipation returns the gindex of the +// previous_epoch_participation field root inside the Gloas ProgressiveContainer BeaconState. +func GetGeneralizedIndexForPreviousEpochParticipation() uint64 { + return generic.ProgressiveContainerFieldGindex(beaconStatePreviousEpochParticipationFieldIndex) +} + +// GetGeneralizedIndexForParticipationChunk returns the gindex of packed 32-byte +// chunk chunkIndex of previous_epoch_participation (ProgressiveContainer field +// + ProgressiveList packed chunk). The list holds basic uint8 elements, so the +// leaves are the packed chunks, not the individual bytes. +func GetGeneralizedIndexForParticipationChunk(chunkIndex uint64) uint64 { + return generic.GetGeneralizedIndexForProgressiveListElement( + GetGeneralizedIndexForPreviousEpochParticipation(), + chunkIndex, + ) +} + // ValidatorAndSlotProof produces both the validator proof and the slot proof // for the state's current slot, using EIP-7688 progressive g-indices. func (state *BeaconState) ValidatorAndSlotProof(validatorIndex uint64) ([][]byte, [][]byte, error) { @@ -534,6 +553,39 @@ func (state *BeaconState) GetPreviousEpochParticipation() []byte { return state.PreviousEpochParticipation } +// PreviousEpochParticipationChunkProof proves the previous_epoch_participation +// chunk containing validatorIndex's participation flags up to the state root +// chunk is the 32-byte merkle leaf holding the flags of validators +// [chunkIndex*32, chunkIndex*32+31], with the validator's own flags at byte +// validatorIndex % 32. Unlike pre-Gloas forks, the branch length is variable: +// the list merkleizes progressively (EIP-7916), so the proof grows by 3 hashes +// per progressive level of the chunk index. func (state *BeaconState) PreviousEpochParticipationChunkProof(validatorIndex uint64) ([32]byte, [][]byte, error) { - return [32]byte{}, nil, fmt.Errorf("participation proofs are not supported for gloas states yet") + if validatorIndex >= uint64(len(state.PreviousEpochParticipation)) { + return [32]byte{}, nil, errors.New("validator index out of bounds of the previous epoch participation list") + } + + // Pack the expected leaf chunk locally: 32 participation flag bytes, + // zero-padded at the tail of the list. + chunkIndex := validatorIndex / 32 + var chunk [32]byte + copy(chunk[:], state.PreviousEpochParticipation[chunkIndex*32:]) + + stateTree, err := generic.SSZ.GetTree(state) + if err != nil { + return [32]byte{}, nil, fmt.Errorf("could not get state tree: %w", err) + } + + chunkGid := GetGeneralizedIndexForParticipationChunk(chunkIndex) + participationStateProof, err := stateTree.Prove(int(chunkGid)) + if err != nil { + return [32]byte{}, nil, fmt.Errorf("could not get proof for participation chunk: %w", err) + } + + // Sanity check that the proof leaf matches the locally packed chunk + if !bytes.Equal(participationStateProof.Leaf, chunk[:]) { + return [32]byte{}, nil, fmt.Errorf("proof leaf does not match expected participation chunk") + } + + return chunk, participationStateProof.Hashes, nil } From 6d7ca8fefe198e74d1037298de3c1148b1cfe287 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:00:58 -0300 Subject: [PATCH 21/35] Add minipool contract v4 --- bindings/minipool/minipool-constructor.go | 4 + bindings/minipool/minipool-contract-v4.go | 665 ++++++++++++++++++++++ 2 files changed, 669 insertions(+) create mode 100644 bindings/minipool/minipool-contract-v4.go diff --git a/bindings/minipool/minipool-constructor.go b/bindings/minipool/minipool-constructor.go index 26ba289e3..b2ee611ec 100644 --- a/bindings/minipool/minipool-constructor.go +++ b/bindings/minipool/minipool-constructor.go @@ -33,6 +33,8 @@ func NewMinipool(rp *rocketpool.RocketPool, address common.Address, opts *bind.C return newMinipool_v2(rp, address) case 3: return newMinipool_v3(rp, address, opts) + case 4: + return newMinipool_v4(rp, address, opts) default: return nil, fmt.Errorf("unexpected minipool contract version [%d]", version) } @@ -45,6 +47,8 @@ func NewMinipoolFromVersion(rp *rocketpool.RocketPool, address common.Address, v return newMinipool_v2(rp, address) case 3: return newMinipool_v3(rp, address, opts) + case 4: + return newMinipool_v4(rp, address, opts) default: return nil, fmt.Errorf("unexpected minipool contract version [%d]", version) } diff --git a/bindings/minipool/minipool-contract-v4.go b/bindings/minipool/minipool-contract-v4.go new file mode 100644 index 000000000..2274b5aad --- /dev/null +++ b/bindings/minipool/minipool-contract-v4.go @@ -0,0 +1,665 @@ +package minipool + +import ( + "context" + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/core/types" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "golang.org/x/sync/errgroup" + + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/storage" + rptypes "github.com/rocket-pool/smartnode/bindings/types" + "github.com/rocket-pool/smartnode/bindings/utils/eth" +) + +const ( + minipoolV4EncodedAbi string = "" +) + +type MinipoolV4 interface { + Minipool + EstimateReduceBondAmountGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) + ReduceBondAmount(opts *bind.TransactOpts) (common.Hash, error) + EstimatePromoteGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) + Promote(opts *bind.TransactOpts) (common.Hash, error) + GetPreMigrationBalance(opts *bind.CallOpts) (*big.Int, error) + GetUserDistributed(opts *bind.CallOpts) (bool, error) + EstimateDistributeBalanceGas(rewardsOnly bool, opts *bind.TransactOpts) (rocketpool.GasInfo, error) + DistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (common.Hash, error) + PrepareDistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (*types.Transaction, error) + ForceExit(opts *bind.TransactOpts) (common.Hash, error) + EstimateForceExitGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) +} + +// Minipool contract +type minipool_v4 struct { + Address common.Address + Version uint8 + Contract *rocketpool.Contract + RocketPool *rocketpool.RocketPool +} + +// The decoded ABI for v2 minipools +var minipoolV4Abi *abi.ABI + +// Create new minipool contract +func newMinipool_v4(rp *rocketpool.RocketPool, address common.Address, opts *bind.CallOpts) (Minipool, error) { + + var contract *rocketpool.Contract + var err error + if minipoolV4Abi == nil { + // Get contract + contract, err = createMinipoolContractFromEncodedAbi(rp, address, minipoolV4EncodedAbi) + } else { + contract, err = createMinipoolContractFromAbi(rp, address, minipoolV4Abi) + } + if err != nil { + return nil, err + } else if minipoolV4Abi == nil { + minipoolV4Abi = contract.ABI + } + + // Create and return + return &minipool_v4{ + Address: address, + Version: 4, + Contract: contract, + RocketPool: rp, + }, nil +} + +// Get the minipool as a v4 minipool if it implements the required methods +func GetMinipoolAsV4(mp Minipool) (MinipoolV4, bool) { + castedMp, ok := mp.(MinipoolV4) + if ok { + return castedMp, true + } + return nil, false +} + +// Get the contract +func (mp *minipool_v4) GetContract() *rocketpool.Contract { + return mp.Contract +} + +// Get the contract address +func (mp *minipool_v4) GetAddress() common.Address { + return mp.Address +} + +// Get the contract version +func (mp *minipool_v4) GetVersion() uint8 { + return mp.Version +} + +// Get status details +func (mp *minipool_v4) GetStatusDetails(opts *bind.CallOpts) (StatusDetails, error) { + + // Data + var wg errgroup.Group + var status rptypes.MinipoolStatus + var statusBlock uint64 + var statusTime time.Time + var isVacant bool + + // Load data + wg.Go(func() error { + var err error + status, err = mp.GetStatus(opts) + return err + }) + wg.Go(func() error { + var err error + statusBlock, err = mp.GetStatusBlock(opts) + return err + }) + wg.Go(func() error { + var err error + statusTime, err = mp.GetStatusTime(opts) + return err + }) + wg.Go(func() error { + var err error + isVacant, err = mp.GetVacant(opts) + return err + }) + + // Wait for data + if err := wg.Wait(); err != nil { + return StatusDetails{}, err + } + + // Return + return StatusDetails{ + Status: status, + StatusBlock: statusBlock, + StatusTime: statusTime, + IsVacant: isVacant, + }, nil + +} +func (mp *minipool_v4) GetStatus(opts *bind.CallOpts) (rptypes.MinipoolStatus, error) { + status := new(uint8) + if err := mp.Contract.Call(opts, status, "getStatus"); err != nil { + return 0, fmt.Errorf("error getting minipool %s status: %w", mp.Address.Hex(), err) + } + return rptypes.MinipoolStatus(*status), nil +} +func (mp *minipool_v4) GetStatusBlock(opts *bind.CallOpts) (uint64, error) { + statusBlock := new(*big.Int) + if err := mp.Contract.Call(opts, statusBlock, "getStatusBlock"); err != nil { + return 0, fmt.Errorf("error getting minipool %s status changed block: %w", mp.Address.Hex(), err) + } + return (*statusBlock).Uint64(), nil +} +func (mp *minipool_v4) GetStatusTime(opts *bind.CallOpts) (time.Time, error) { + statusTime := new(*big.Int) + if err := mp.Contract.Call(opts, statusTime, "getStatusTime"); err != nil { + return time.Unix(0, 0), fmt.Errorf("error getting minipool %s status changed time: %w", mp.Address.Hex(), err) + } + return time.Unix((*statusTime).Int64(), 0), nil +} +func (mp *minipool_v4) GetFinalised(opts *bind.CallOpts) (bool, error) { + finalised := new(bool) + if err := mp.Contract.Call(opts, finalised, "getFinalised"); err != nil { + return false, fmt.Errorf("error getting minipool %s finalised: %w", mp.Address.Hex(), err) + } + return *finalised, nil +} + +// Get deposit type +func (mp *minipool_v4) GetDepositType(opts *bind.CallOpts) (rptypes.MinipoolDeposit, error) { + depositType := new(uint8) + if err := mp.Contract.Call(opts, depositType, "getDepositType"); err != nil { + return 0, fmt.Errorf("error getting minipool %s deposit type: %w", mp.Address.Hex(), err) + } + return rptypes.MinipoolDeposit(*depositType), nil +} + +// Get node details +func (mp *minipool_v4) GetNodeDetails(opts *bind.CallOpts) (NodeDetails, error) { + + // Data + var wg errgroup.Group + var address common.Address + var fee float64 + var depositBalance *big.Int + var refundBalance *big.Int + var depositAssigned bool + + // Load data + wg.Go(func() error { + var err error + address, err = mp.GetNodeAddress(opts) + return err + }) + wg.Go(func() error { + var err error + fee, err = mp.GetNodeFee(opts) + return err + }) + wg.Go(func() error { + var err error + depositBalance, err = mp.GetNodeDepositBalance(opts) + return err + }) + wg.Go(func() error { + var err error + refundBalance, err = mp.GetNodeRefundBalance(opts) + return err + }) + wg.Go(func() error { + var err error + depositAssigned, err = mp.GetNodeDepositAssigned(opts) + return err + }) + + // Wait for data + if err := wg.Wait(); err != nil { + return NodeDetails{}, err + } + + // Return + return NodeDetails{ + Address: address, + Fee: fee, + DepositBalance: depositBalance, + RefundBalance: refundBalance, + DepositAssigned: depositAssigned, + }, nil + +} +func (mp *minipool_v4) GetNodeAddress(opts *bind.CallOpts) (common.Address, error) { + nodeAddress := new(common.Address) + if err := mp.Contract.Call(opts, nodeAddress, "getNodeAddress"); err != nil { + return common.Address{}, fmt.Errorf("error getting minipool %s node address: %w", mp.Address.Hex(), err) + } + return *nodeAddress, nil +} +func (mp *minipool_v4) GetNodeFee(opts *bind.CallOpts) (float64, error) { + nodeFee := new(*big.Int) + if err := mp.Contract.Call(opts, nodeFee, "getNodeFee"); err != nil { + return 0, fmt.Errorf("error getting minipool %s node fee: %w", mp.Address.Hex(), err) + } + return eth.WeiToEth(*nodeFee), nil +} +func (mp *minipool_v4) GetNodeFeeRaw(opts *bind.CallOpts) (*big.Int, error) { + nodeFee := new(*big.Int) + if err := mp.Contract.Call(opts, nodeFee, "getNodeFee"); err != nil { + return nil, fmt.Errorf("error getting minipool %s node fee: %w", mp.Address.Hex(), err) + } + return *nodeFee, nil +} +func (mp *minipool_v4) GetNodeDepositBalance(opts *bind.CallOpts) (*big.Int, error) { + nodeDepositBalance := new(*big.Int) + if err := mp.Contract.Call(opts, nodeDepositBalance, "getNodeDepositBalance"); err != nil { + return nil, fmt.Errorf("error getting minipool %s node deposit balance: %w", mp.Address.Hex(), err) + } + return *nodeDepositBalance, nil +} +func (mp *minipool_v4) GetNodeRefundBalance(opts *bind.CallOpts) (*big.Int, error) { + nodeRefundBalance := new(*big.Int) + if err := mp.Contract.Call(opts, nodeRefundBalance, "getNodeRefundBalance"); err != nil { + return nil, fmt.Errorf("error getting minipool %s node refund balance: %w", mp.Address.Hex(), err) + } + return *nodeRefundBalance, nil +} +func (mp *minipool_v4) GetNodeDepositAssigned(opts *bind.CallOpts) (bool, error) { + nodeDepositAssigned := new(bool) + if err := mp.Contract.Call(opts, nodeDepositAssigned, "getNodeDepositAssigned"); err != nil { + return false, fmt.Errorf("error getting minipool %s node deposit assigned status: %w", mp.Address.Hex(), err) + } + return *nodeDepositAssigned, nil +} +func (mp *minipool_v4) GetVacant(opts *bind.CallOpts) (bool, error) { + isVacant := new(bool) + if err := mp.Contract.Call(opts, isVacant, "getVacant"); err != nil { + return false, fmt.Errorf("error getting minipool %s vacant status: %w", mp.Address.Hex(), err) + } + return *isVacant, nil +} +func (mp *minipool_v4) GetPreMigrationBalance(opts *bind.CallOpts) (*big.Int, error) { + preMigrationBalance := new(*big.Int) + if err := mp.Contract.Call(opts, preMigrationBalance, "getPreMigrationBalance"); err != nil { + return nil, fmt.Errorf("error getting minipool %s pre-migration balance: %w", mp.Address.Hex(), err) + } + return *preMigrationBalance, nil +} + +// Get user deposit details +func (mp *minipool_v4) GetUserDetails(opts *bind.CallOpts) (UserDetails, error) { + + // Data + var wg errgroup.Group + var depositBalance *big.Int + var depositAssigned bool + var depositAssignedTime time.Time + + // Load data + wg.Go(func() error { + var err error + depositBalance, err = mp.GetUserDepositBalance(opts) + return err + }) + wg.Go(func() error { + var err error + depositAssigned, err = mp.GetUserDepositAssigned(opts) + return err + }) + wg.Go(func() error { + var err error + depositAssignedTime, err = mp.GetUserDepositAssignedTime(opts) + return err + }) + + // Wait for data + if err := wg.Wait(); err != nil { + return UserDetails{}, err + } + + // Return + return UserDetails{ + DepositBalance: depositBalance, + DepositAssigned: depositAssigned, + DepositAssignedTime: depositAssignedTime, + }, nil + +} +func (mp *minipool_v4) GetUserDepositBalance(opts *bind.CallOpts) (*big.Int, error) { + userDepositBalance := new(*big.Int) + if err := mp.Contract.Call(opts, userDepositBalance, "getUserDepositBalance"); err != nil { + return nil, fmt.Errorf("error getting minipool %s user deposit balance: %w", mp.Address.Hex(), err) + } + return *userDepositBalance, nil +} +func (mp *minipool_v4) GetUserDepositAssigned(opts *bind.CallOpts) (bool, error) { + userDepositAssigned := new(bool) + if err := mp.Contract.Call(opts, userDepositAssigned, "getUserDepositAssigned"); err != nil { + return false, fmt.Errorf("error getting minipool %s user deposit assigned status: %w", mp.Address.Hex(), err) + } + return *userDepositAssigned, nil +} +func (mp *minipool_v4) GetUserDepositAssignedTime(opts *bind.CallOpts) (time.Time, error) { + depositAssignedTime := new(*big.Int) + if err := mp.Contract.Call(opts, depositAssignedTime, "getUserDepositAssignedTime"); err != nil { + return time.Unix(0, 0), fmt.Errorf("error getting minipool %s user deposit assigned time: %w", mp.Address.Hex(), err) + } + return time.Unix((*depositAssignedTime).Int64(), 0), nil +} + +// Estimate the gas of Refund +func (mp *minipool_v4) EstimateRefundGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return mp.Contract.GetTransactionGasInfo(opts, "refund") +} + +// Refund node ETH from the minipool +func (mp *minipool_v4) Refund(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "refund") + if err != nil { + return common.Hash{}, fmt.Errorf("error refunding from minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Check if the minipool's balance has already been distributed +func (mp *minipool_v4) GetUserDistributed(opts *bind.CallOpts) (bool, error) { + distributed := new(bool) + if err := mp.Contract.Call(opts, distributed, "getUserDistributed"); err != nil { + return false, fmt.Errorf("error getting user distributed status for minipool %s: %w", mp.Address.Hex(), err) + } + return *distributed, nil +} + +// Estimate the gas of DistributeBalance +func (mp *minipool_v4) EstimateDistributeBalanceGas(rewardsOnly bool, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return mp.Contract.GetTransactionGasInfo(opts, "distributeBalance", rewardsOnly) +} + +// Distribute the minipool's ETH balance to the node operator and rETH staking pool. +func (mp *minipool_v4) DistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "distributeBalance", rewardsOnly) + if err != nil { + return common.Hash{}, fmt.Errorf("error processing withdrawal for minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// PrepareDistributeBalance is like DistributeBalance but forces NoSend and returns the signed transaction +// (instead of sending it). Useful for assembling Flashbots bundles. +func (mp *minipool_v4) PrepareDistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (*types.Transaction, error) { + if opts == nil { + opts = &bind.TransactOpts{} + } + opts.NoSend = true + tx, err := mp.Contract.Transact(opts, "distributeBalance", rewardsOnly) + if err != nil { + return nil, fmt.Errorf("error preparing distribute tx for minipool %s: %w", mp.Address.Hex(), err) + } + return tx, nil +} + +// Estimate the gas of Stake +func (mp *minipool_v4) EstimateStakeGas(validatorSignature rptypes.ValidatorSignature, depositDataRoot common.Hash, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return mp.Contract.GetTransactionGasInfo(opts, "stake", validatorSignature[:], depositDataRoot) +} + +// Progress the prelaunch minipool to staking +func (mp *minipool_v4) Stake(validatorSignature rptypes.ValidatorSignature, depositDataRoot common.Hash, opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "stake", validatorSignature[:], depositDataRoot) + if err != nil { + return common.Hash{}, fmt.Errorf("error staking minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of Dissolve +func (mp *minipool_v4) EstimateDissolveGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return mp.Contract.GetTransactionGasInfo(opts, "dissolve") +} + +// Dissolve the initialized or prelaunch minipool +func (mp *minipool_v4) Dissolve(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "dissolve") + if err != nil { + return common.Hash{}, fmt.Errorf("error dissolving minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of Close +func (mp *minipool_v4) EstimateCloseGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return mp.Contract.GetTransactionGasInfo(opts, "close") +} + +// Withdraw node balances from the dissolved minipool and close it +func (mp *minipool_v4) Close(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "close") + if err != nil { + return common.Hash{}, fmt.Errorf("error closing minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of Finalise +func (mp *minipool_v4) EstimateFinaliseGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return mp.Contract.GetTransactionGasInfo(opts, "finalise") +} + +// Finalise a minipool to get the RPL stake back +func (mp *minipool_v4) Finalise(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "finalise") + if err != nil { + return common.Hash{}, fmt.Errorf("error finalizing minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of DelegateUpgrade +func (mp *minipool_v4) EstimateDelegateUpgradeGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return mp.Contract.GetTransactionGasInfo(opts, "delegateUpgrade") +} + +// Upgrade this minipool to the latest network delegate contract +func (mp *minipool_v4) DelegateUpgrade(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "delegateUpgrade") + if err != nil { + return common.Hash{}, fmt.Errorf("error upgrading delegate for minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of SetUseLatestDelegate +func (mp *minipool_v4) EstimateSetUseLatestDelegateGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return mp.Contract.GetTransactionGasInfo(opts, "setUseLatestDelegate", true) +} + +// If set to true, will automatically use the latest delegate contract +func (mp *minipool_v4) SetUseLatestDelegate(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "setUseLatestDelegate", true) + if err != nil { + return common.Hash{}, fmt.Errorf("error setting use latest delegate for minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Getter for useLatestDelegate setting +func (mp *minipool_v4) GetUseLatestDelegate(opts *bind.CallOpts) (bool, error) { + setting := new(bool) + if err := mp.Contract.Call(opts, setting, "getUseLatestDelegate"); err != nil { + return false, fmt.Errorf("error getting use latest delegate for minipool %s: %w", mp.Address.Hex(), err) + } + return *setting, nil +} + +// Returns the address of the minipool's stored delegate +func (mp *minipool_v4) GetDelegate(opts *bind.CallOpts) (common.Address, error) { + address := new(common.Address) + if err := mp.Contract.Call(opts, address, "getDelegate"); err != nil { + return common.Address{}, fmt.Errorf("error getting delegate for minipool %s: %w", mp.Address.Hex(), err) + } + return *address, nil +} + +// Returns the address of the minipool's previous delegate (or address(0) if not set) +func (mp *minipool_v4) GetPreviousDelegate(opts *bind.CallOpts) (common.Address, error) { + address := new(common.Address) + if err := mp.Contract.Call(opts, address, "getPreviousDelegate"); err != nil { + return common.Address{}, fmt.Errorf("error getting previous delegate for minipool %s: %w", mp.Address.Hex(), err) + } + return *address, nil +} + +// Returns the delegate which will be used when calling this minipool taking into account useLatestDelegate setting +func (mp *minipool_v4) GetEffectiveDelegate(opts *bind.CallOpts) (common.Address, error) { + address := new(common.Address) + if err := mp.Contract.Call(opts, address, "getEffectiveDelegate"); err != nil { + return common.Address{}, fmt.Errorf("error getting effective delegate for minipool %s: %w", mp.Address.Hex(), err) + } + return *address, nil +} + +// Estimate the gas required to reduce a minipool's bond +func (mp *minipool_v4) EstimateReduceBondAmountGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return mp.Contract.GetTransactionGasInfo(opts, "reduceBondAmount") +} + +// Reduce a minipool's bond +func (mp *minipool_v4) ReduceBondAmount(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "reduceBondAmount") + if err != nil { + return common.Hash{}, fmt.Errorf("error reducing bond for minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Given a validator balance, calculates how much belongs to the node taking into consideration rewards and penalties +func (mp *minipool_v4) CalculateNodeShare(balance *big.Int, opts *bind.CallOpts) (*big.Int, error) { + nodeAmount := new(*big.Int) + if err := mp.Contract.Call(opts, nodeAmount, "calculateNodeShare", balance); err != nil { + return nil, fmt.Errorf("error getting minipool node portion: %w", err) + } + return *nodeAmount, nil +} + +// Given a validator balance, calculates how much belongs to rETH users taking into consideration rewards and penalties +func (mp *minipool_v4) CalculateUserShare(balance *big.Int, opts *bind.CallOpts) (*big.Int, error) { + userAmount := new(*big.Int) + if err := mp.Contract.Call(opts, userAmount, "calculateUserShare", balance); err != nil { + return nil, fmt.Errorf("error getting minipool user portion: %w", err) + } + return *userAmount, nil +} + +// Estimate the gas required to vote to scrub a minipool +func (mp *minipool_v4) EstimateVoteScrubGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return mp.Contract.GetTransactionGasInfo(opts, "voteScrub") +} + +// Vote to scrub a minipool +func (mp *minipool_v4) VoteScrub(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "voteScrub") + if err != nil { + return common.Hash{}, fmt.Errorf("error voting to scrub minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas required to promote a vacant minipool +func (mp *minipool_v4) EstimatePromoteGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return mp.Contract.GetTransactionGasInfo(opts, "promote") +} + +// Promote a vacant minipool +func (mp *minipool_v4) Promote(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "promote") + if err != nil { + return common.Hash{}, fmt.Errorf("error promoting minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Get the data from this minipool's MinipoolPrestaked event +func (mp *minipool_v4) GetPrestakeEvent(intervalSize *big.Int, opts *bind.CallOpts) (PrestakeData, error) { + + addressFilter := []common.Address{mp.Address} + topicFilter := [][]common.Hash{{mp.Contract.ABI.Events["MinipoolPrestaked"].ID}} + + // Grab the latest block number + currentBlock, err := mp.RocketPool.Client.BlockNumber(context.Background()) + if err != nil { + return PrestakeData{}, fmt.Errorf("Error getting current block %s: %w", mp.Address.Hex(), err) + } + + // Grab the lowest block number worth querying from (should never have to go back this far in practice) + fromBlockBig, err := storage.GetDeployBlock(mp.RocketPool) + if err != nil { + return PrestakeData{}, fmt.Errorf("Error getting deploy block %s: %w", mp.Address.Hex(), err) + } + + fromBlock := fromBlockBig.Uint64() + var log types.Log + found := false + + // Backwards scan through blocks to find the event + for i := currentBlock; i >= fromBlock; i -= EventScanInterval { + from := max(i-EventScanInterval+1, fromBlock) + + fromBig := big.NewInt(0).SetUint64(from) + toBig := big.NewInt(0).SetUint64(i) + + logs, err := eth.GetLogs(mp.RocketPool, addressFilter, topicFilter, intervalSize, fromBig, toBig, nil) + if err != nil { + return PrestakeData{}, fmt.Errorf("Error getting prestake logs for minipool %s: %w", mp.Address.Hex(), err) + } + + if len(logs) > 0 { + log = logs[0] + found = true + break + } + } + + if !found { + // This should never happen + return PrestakeData{}, fmt.Errorf("Error finding prestake log for minipool %s", mp.Address.Hex()) + } + + // Decode the event + prestakeEvent := new(MinipoolPrestakeEvent) + err = mp.Contract.Contract.UnpackLog(prestakeEvent, "MinipoolPrestaked", log) + if err != nil { + return PrestakeData{}, fmt.Errorf("Error unpacking prestake data: %w", err) + } + + // Convert the event to a more useable struct + prestakeData := PrestakeData{ + Pubkey: rptypes.BytesToValidatorPubkey(prestakeEvent.Pubkey), + WithdrawalCredentials: common.BytesToHash(prestakeEvent.WithdrawalCredentials), + Amount: prestakeEvent.Amount, + Signature: rptypes.BytesToValidatorSignature(prestakeEvent.Signature), + DepositDataRoot: prestakeEvent.DepositDataRoot, + Time: time.Unix(prestakeEvent.Time.Int64(), 0), + } + return prestakeData, nil +} + +// Estimate the gas required to force exit a minipool +func (mp *minipool_v4) EstimateForceExitGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return mp.Contract.GetTransactionGasInfo(opts, "forceExit") +} + +// Force exit a minipool +func (mp *minipool_v4) ForceExit(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "forceExit") + if err != nil { + return common.Hash{}, fmt.Errorf("error forcing exit for minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} From 7f82bd8ef187e6f33f1f965301eeabe175a01cb5 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:10:28 -0300 Subject: [PATCH 22/35] Add CheckMinipoolExitRequests task --- bindings/minipool/exit-requests.go | 46 +++ .../node/check-minipool-exit-requests.go | 360 ++++++++++++++++++ rocketpool/node/node.go | 13 + 3 files changed, 419 insertions(+) create mode 100644 bindings/minipool/exit-requests.go create mode 100644 rocketpool/node/check-minipool-exit-requests.go diff --git a/bindings/minipool/exit-requests.go b/bindings/minipool/exit-requests.go new file mode 100644 index 000000000..b5b98ef28 --- /dev/null +++ b/bindings/minipool/exit-requests.go @@ -0,0 +1,46 @@ +package minipool + +import ( + "fmt" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/core/types" + + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/rocketpool" +) + +// A pending request for a minipool validator to exit the beacon chain +type MinipoolExitRequest struct { + ValidatorIndex uint64 // Beacon chain validator index + RequestTimestamp uint64 // Unix seconds when the exit was requested +} + +// Get the list of pending minipool exit requests +// TODO: stub — the contract view is not available yet; replace with the real +// contract call once it is deployed. Returns a fixed example response for now. +func GetMinipoolExitRequests(rp *rocketpool.RocketPool, opts *bind.CallOpts) ([]MinipoolExitRequest, error) { + return []MinipoolExitRequest{ + { + ValidatorIndex: 1000, + RequestTimestamp: uint64(time.Now().Add(-48 * time.Hour).Unix()), + }, + { + ValidatorIndex: 1001, + RequestTimestamp: uint64(time.Now().Add(-1 * time.Hour).Unix()), + }, + }, nil +} + +// Estimate the gas to call NotifyMinipoolDidNotExit +// TODO: placeholder — the contract method does not exist yet +func EstimateNotifyMinipoolDidNotExitGas(rp *rocketpool.RocketPool, validatorIndex uint64, slotTimestamp uint64, validatorProof megapool.ValidatorProof, slotProof megapool.SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return rocketpool.GasInfo{}, fmt.Errorf("not implemented: the minipool did-not-exit contract method is not yet available") +} + +// Report that a minipool validator did not exit within the cooperative exit phase +// TODO: placeholder — the contract method does not exist yet +func NotifyMinipoolDidNotExit(rp *rocketpool.RocketPool, validatorIndex uint64, slotTimestamp uint64, validatorProof megapool.ValidatorProof, slotProof megapool.SlotProof, opts *bind.TransactOpts) (*types.Transaction, error) { + return nil, fmt.Errorf("not implemented: the minipool did-not-exit contract method is not yet available") +} diff --git a/rocketpool/node/check-minipool-exit-requests.go b/rocketpool/node/check-minipool-exit-requests.go new file mode 100644 index 000000000..c964cc84e --- /dev/null +++ b/rocketpool/node/check-minipool-exit-requests.go @@ -0,0 +1,360 @@ +package node + +import ( + "fmt" + "math/big" + "strconv" + "time" + + "github.com/docker/docker/client" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v3" + + "github.com/rocket-pool/smartnode/bindings/minipool" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/bindings/types" + "github.com/rocket-pool/smartnode/bindings/utils/eth" + rpstate "github.com/rocket-pool/smartnode/bindings/utils/state" + + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/config" + rpgas "github.com/rocket-pool/smartnode/shared/services/gas" + "github.com/rocket-pool/smartnode/shared/services/state" + "github.com/rocket-pool/smartnode/shared/services/wallet" + "github.com/rocket-pool/smartnode/shared/types/eth2" + "github.com/rocket-pool/smartnode/shared/utils/api" + "github.com/rocket-pool/smartnode/shared/utils/log" +) + +// TODO: flip to true once the did-not-exit contract method exists +const didNotExitTxEnabled = false + +// A minipool validator that did not exit within the cooperative exit phase +type didNotExitValidator struct { + validatorIndex uint64 + pubkey types.ValidatorPubkey + minipoolAddress common.Address +} + +// Check minipool exit requests task +type checkMinipoolExitRequests struct { + c *cli.Command + log log.ColorLogger + cfg *config.RocketPoolConfig + w wallet.Wallet + rp *rocketpool.RocketPool + bc beacon.Client + d *client.Client + gasThreshold float64 + maxFee *big.Int + maxPriorityFee *big.Int + gasLimit uint64 +} + +// Create check minipool exit requests task +func newCheckMinipoolExitRequests(c *cli.Command, logger log.ColorLogger) (*checkMinipoolExitRequests, error) { + + // Get services + cfg, err := services.GetConfig(c) + if err != nil { + return nil, err + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + d, err := services.GetDocker(c) + if err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + gasThreshold := cfg.Smartnode.AutoTxGasThreshold.Value.(float64) + + // Get the user-requested max fee + maxFeeGwei := cfg.Smartnode.ManualMaxFee.Value.(float64) + var maxFee *big.Int + if maxFeeGwei == 0 { + maxFee = nil + } else { + maxFee = eth.GweiToWei(maxFeeGwei) + } + + // Get the user-requested priority fee + priorityFeeGwei := cfg.Smartnode.PriorityFee.Value.(float64) + var priorityFee *big.Int + if priorityFeeGwei == 0 { + logger.Printlnf("WARNING: priority fee was missing or 0, setting a default of %.2f.", rpgas.DefaultPriorityFeeGwei) + priorityFee = eth.GweiToWei(rpgas.DefaultPriorityFeeGwei) + } else { + priorityFee = eth.GweiToWei(priorityFeeGwei) + } + + // Return task + return &checkMinipoolExitRequests{ + c: c, + log: logger, + cfg: cfg, + w: w, + rp: rp, + bc: bc, + d: d, + gasThreshold: gasThreshold, + maxFee: maxFee, + maxPriorityFee: priorityFee, + gasLimit: 0, + }, nil + +} + +// Check for minipool validators that did not respond to an exit request +func (t *checkMinipoolExitRequests) run(state *state.NetworkState) error { + // Log + t.log.Println("Checking for minipool validators that did not respond to an exit request...") + + // Get the latest state + opts := &bind.CallOpts{ + BlockNumber: big.NewInt(0).SetUint64(state.ElBlockNumber), + } + + // Get the pending exit requests + exitRequests, err := minipool.GetMinipoolExitRequests(t.rp, opts) + if err != nil { + return err + } + if len(exitRequests) == 0 { + return nil + } + + // Get the cooperative exit phase duration + cooperativeExitPhase, err := protocol.GetCooperativeExitPhase(t.rp, opts) + if err != nil { + return err + } + + // Index the minipool details by validator pubkey + minipoolDetailsByPubkey := make(map[types.ValidatorPubkey]*rpstate.NativeMinipoolDetails, len(state.MinipoolDetails)) + for i := range state.MinipoolDetails { + minipoolDetailsByPubkey[state.MinipoolDetails[i].Pubkey] = &state.MinipoolDetails[i] + } + + validatorsToProve := []didNotExitValidator{} + for _, request := range exitRequests { + + status, err := t.bc.GetValidatorStatusByIndex(strconv.FormatUint(request.ValidatorIndex, 10), nil) + if err != nil { + t.log.Printlnf("Error getting the status of validator %d: %s", request.ValidatorIndex, err.Error()) + continue + } + if !status.Exists { + t.log.Printlnf("Validator %d not found on the beacon chain", request.ValidatorIndex) + continue + } + + // An initiated exit satisfies the request + if status.ExitEpoch != FarFutureEpoch { + t.log.Printlnf("Validator %d has already exited", request.ValidatorIndex) + continue + } + + minipoolDetails, exists := minipoolDetailsByPubkey[status.Pubkey] + if !exists { + t.log.Printlnf("Validator %d does not belong to a known minipool", request.ValidatorIndex) + continue + } + + // Delegates from version 4 support a forced exit request, so the + // did-not-exit path only applies to older delegates + if minipoolDetails.Version >= 4 { + t.log.Printlnf("Minipool %s (validator %d) uses delegate version %d; submitting ForceExit", minipoolDetails.MinipoolAddress.Hex(), request.ValidatorIndex, minipoolDetails.Version) + err := t.forceExitMinipool(minipoolDetails, opts) + if err != nil { + t.log.Printlnf("Error force-exiting minipool %s: %s", minipoolDetails.MinipoolAddress.Hex(), err.Error()) + } + continue + } + // Skip requests still within the cooperative exit phase + deadline := time.Unix(int64(request.RequestTimestamp), 0).Add(cooperativeExitPhase) + if time.Now().Before(deadline) { + continue + } + + validatorsToProve = append(validatorsToProve, didNotExitValidator{ + validatorIndex: request.ValidatorIndex, + pubkey: status.Pubkey, + minipoolAddress: minipoolDetails.MinipoolAddress, + }) + } + + // Check if there are any validators to prove + if len(validatorsToProve) == 0 { + return nil + } + + beaconState, err := services.GetBeaconState(t.bc) + if err != nil { + return err + } + + for _, validator := range validatorsToProve { + + // Log + t.log.Printlnf("The validator %d (minipool %s) did not exit within the cooperative exit phase", validator.validatorIndex, validator.minipoolAddress.Hex()) + + err := t.proveDidNotExit(beaconState, state, validator) + // dont return if there was an error, just log it so we can continue with the next validator + if err != nil { + t.log.Printlnf("Error proving validator %d did not exit: %s", validator.validatorIndex, err.Error()) + } + } + + // Return + return nil + +} + +func (t *checkMinipoolExitRequests) forceExitMinipool(mpd *rpstate.NativeMinipoolDetails, callOpts *bind.CallOpts) error { + + mp, err := minipool.NewMinipoolFromVersion(t.rp, mpd.MinipoolAddress, mpd.Version, callOpts) + if err != nil { + return fmt.Errorf("cannot create binding for minipool %s: %w", mpd.MinipoolAddress.Hex(), err) + } + + mpv4, success := minipool.GetMinipoolAsV4(mp) + if !success { + return fmt.Errorf("minipool %s cannot be converted to v4 (current version: %d)", mpd.MinipoolAddress.Hex(), mp.GetVersion()) + } + + // Get transactor + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + + // Get the gas limit + gasInfo, err := mpv4.EstimateForceExitGas(opts) + if err != nil { + return fmt.Errorf("could not estimate the gas required to force exit minipool %s: %w", mpd.MinipoolAddress.Hex(), err) + } + var gas *big.Int + if t.gasLimit != 0 { + gas = new(big.Int).SetUint64(t.gasLimit) + } else { + gas = new(big.Int).SetUint64(gasInfo.SafeGasLimit) + } + + // Get the max fee + maxFee := t.maxFee + if maxFee == nil || maxFee.Uint64() == 0 { + maxFee, err = rpgas.GetHeadlessMaxFeeWeiWithLatestBlock(t.cfg, t.rp) + if err != nil { + return err + } + } + + // Print the gas info + if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + return nil + } + + opts.GasFeeCap = maxFee + opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) + opts.GasLimit = gas.Uint64() + + // Force exit the minipool + hash, err := mpv4.ForceExit(opts) + if err != nil { + return err + } + + // Print TX info and wait for it to be included in a block + err = api.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) + if err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully submitted ForceExit for minipool %s.", mpd.MinipoolAddress.Hex()) + + // Return + return nil +} + +func (t *checkMinipoolExitRequests) proveDidNotExit(beaconState eth2.BeaconState, state *state.NetworkState, validator didNotExitValidator) error { + + t.log.Printlnf("[STARTED] Crafting a did-not-exit proof. This process can take several seconds and is CPU and memory intensive. If you don't see a [FINISHED] log entry your system may not have enough resources to perform this operation.") + + // The megapool proof structs are generic SSZ proofs; the address parameter is unused by GetValidatorProof + validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(t.c, 0, t.w, state.BeaconConfig, common.Address{}, validator.pubkey, beaconState) + if err != nil { + t.log.Printlnf("[ERROR] There was an error during the proof creation process: %s", err.Error()) + return err + } + + t.log.Printlnf("[FINISHED] The did-not-exit proof for validator %d has been successfully created (exit epoch %d at slot %d).", validator.validatorIndex, validatorProof.Validator.ExitEpoch, slotProof.Slot) + + if !didNotExitTxEnabled { + // TODO: remove this check once the did-not-exit contract method exists + t.log.Printlnf("[TODO] The contract method to report that validator %d did not exit is not yet available; skipping the transaction.", validator.validatorIndex) + return nil + } + + // Get transactor + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + + // Get the gas limit + gasInfo, err := minipool.EstimateNotifyMinipoolDidNotExitGas(t.rp, validator.validatorIndex, slotTimestamp, validatorProof, slotProof, opts) + if err != nil { + t.log.Printlnf("Could not estimate the gas required to report that validator %d did not exit: %s", validator.validatorIndex, err.Error()) + return err + } + gas := big.NewInt(int64(gasInfo.SafeGasLimit)) + // Get the max fee + maxFee := t.maxFee + if maxFee == nil || maxFee.Uint64() == 0 { + maxFee, err = rpgas.GetHeadlessMaxFeeWeiWithLatestBlock(t.cfg, t.rp) + if err != nil { + return err + } + } + + // Print the gas info + if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + return nil + } + + opts.GasFeeCap = maxFee + opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) + opts.GasLimit = gas.Uint64() + + // Report that the validator did not exit + tx, err := minipool.NotifyMinipoolDidNotExit(t.rp, validator.validatorIndex, slotTimestamp, validatorProof, slotProof, opts) + if err != nil { + return err + } + + // Print TX info and wait for it to be included in a block + err = api.PrintAndWaitForTransaction(t.cfg, tx.Hash(), t.rp.Client, &t.log) + if err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully reported that validator %d did not exit.", validator.validatorIndex) + + // Return + return nil +} diff --git a/rocketpool/node/node.go b/rocketpool/node/node.go index b68debe91..c845e1a78 100644 --- a/rocketpool/node/node.go +++ b/rocketpool/node/node.go @@ -53,6 +53,7 @@ const ( StakeMegapoolValidatorColor = color.FgHiBlue NotifyValidatorExitColor = color.FgHiYellow NotifyFinalBalanceColor = color.FgHiMagenta + CheckMinipoolExitRequestsColor = color.FgHiCyan DefendChallengeExitColor = color.FgHiGreen DefendChallengePerformanceColor = color.FgHiBlue ProvisionExpressTickets = color.FgMagenta @@ -232,6 +233,10 @@ func run(c *cli.Command) error { if err != nil { return err } + checkMinipoolExitRequests, err := newCheckMinipoolExitRequests(c, log.NewColorLogger(CheckMinipoolExitRequestsColor)) + if err != nil { + return err + } downloadRewardsTrees, err := newDownloadRewardsTrees(c, log.NewColorLogger(DownloadRewardsTreesColor)) if err != nil { return err @@ -438,6 +443,14 @@ func run(c *cli.Command) error { return } + // Run the minipool exit request check + if err := checkMinipoolExitRequests.run(state); err != nil { + errorLog.Println(err) + } + if !sleepWithContext(ctx, taskCooldown) { + return + } + // Run the megapool provision express ticket check if err := provisionExpressTickets.run(state); err != nil { errorLog.Println(err) From a9de921c6ae2a7dd3958aa36bc2e1e64d894262e Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:20:16 -0300 Subject: [PATCH 23/35] Exit own validator when requested --- .../node/check-minipool-exit-requests.go | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/rocketpool/node/check-minipool-exit-requests.go b/rocketpool/node/check-minipool-exit-requests.go index c964cc84e..ccbaa57e2 100644 --- a/rocketpool/node/check-minipool-exit-requests.go +++ b/rocketpool/node/check-minipool-exit-requests.go @@ -10,6 +10,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/urfave/cli/v3" + eth2types "github.com/wealdtech/go-eth2-types/v2" "github.com/rocket-pool/smartnode/bindings/minipool" "github.com/rocket-pool/smartnode/bindings/rocketpool" @@ -27,6 +28,7 @@ import ( "github.com/rocket-pool/smartnode/shared/types/eth2" "github.com/rocket-pool/smartnode/shared/utils/api" "github.com/rocket-pool/smartnode/shared/utils/log" + rpvalidator "github.com/rocket-pool/smartnode/shared/utils/validator" ) // TODO: flip to true once the did-not-exit contract method exists @@ -127,6 +129,12 @@ func (t *checkMinipoolExitRequests) run(state *state.NetworkState) error { BlockNumber: big.NewInt(0).SetUint64(state.ElBlockNumber), } + // Get node account + nodeAccount, err := t.w.GetNodeAccount() + if err != nil { + return err + } + // Get the pending exit requests exitRequests, err := minipool.GetMinipoolExitRequests(t.rp, opts) if err != nil { @@ -173,6 +181,16 @@ func (t *checkMinipoolExitRequests) run(state *state.NetworkState) error { continue } + // If the minipool belongs to this node, cooperate: sign and submit the voluntary exit + if minipoolDetails.NodeAddress == nodeAccount.Address { + t.log.Printlnf("Minipool %s (validator %d) belongs to this node; submitting a voluntary exit", minipoolDetails.MinipoolAddress.Hex(), request.ValidatorIndex) + err := t.exitOwnMinipool(minipoolDetails, status) + if err != nil { + t.log.Printlnf("Error exiting minipool %s: %s", minipoolDetails.MinipoolAddress.Hex(), err.Error()) + } + continue + } + // Delegates from version 4 support a forced exit request, so the // did-not-exit path only applies to older delegates if minipoolDetails.Version >= 4 { @@ -223,6 +241,50 @@ func (t *checkMinipoolExitRequests) run(state *state.NetworkState) error { } +// Sign and broadcast the voluntary exit for a minipool validator belonging to this node +func (t *checkMinipoolExitRequests) exitOwnMinipool(mpd *rpstate.NativeMinipoolDetails, status beacon.ValidatorStatus) error { + + // Check the minipool status + if mpd.Status != types.Staking { + return fmt.Errorf("minipool %s is not in staking status", mpd.MinipoolAddress.Hex()) + } + + // Get the validator private key + validatorKey, err := t.w.GetValidatorKeyByPubkey(mpd.Pubkey) + if err != nil { + return err + } + + // Get beacon head + head, err := t.bc.GetBeaconHead() + if err != nil { + return err + } + + // Get voluntary exit signature domain + signatureDomain, err := t.bc.GetDomainData(eth2types.DomainVoluntaryExit[:], head.Epoch, false) + if err != nil { + return err + } + + // Get signed voluntary exit message + signature, err := rpvalidator.GetSignedExitMessage(validatorKey, status.Index, head.Epoch, signatureDomain) + if err != nil { + return err + } + + // Broadcast voluntary exit message + if err := t.bc.ExitValidator(status.Index, head.Epoch, signature); err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully submitted a voluntary exit for validator %s (minipool %s).", status.Index, mpd.MinipoolAddress.Hex()) + + // Return + return nil +} + func (t *checkMinipoolExitRequests) forceExitMinipool(mpd *rpstate.NativeMinipoolDetails, callOpts *bind.CallOpts) error { mp, err := minipool.NewMinipoolFromVersion(t.rp, mpd.MinipoolAddress, mpd.Version, callOpts) From e7dbc0f53ba42bcc587ebf65dc396b81708ddef4 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:18:44 -0300 Subject: [PATCH 24/35] Add PenaliseMinipool --- bindings/minipool/exit-requests.go | 15 -- bindings/network/exit.go | 185 ++++++++++++++++++ .../node/check-minipool-exit-requests.go | 46 ++--- 3 files changed, 199 insertions(+), 47 deletions(-) create mode 100644 bindings/network/exit.go diff --git a/bindings/minipool/exit-requests.go b/bindings/minipool/exit-requests.go index b5b98ef28..dd6b0404d 100644 --- a/bindings/minipool/exit-requests.go +++ b/bindings/minipool/exit-requests.go @@ -1,13 +1,10 @@ package minipool import ( - "fmt" "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/core/types" - "github.com/rocket-pool/smartnode/bindings/megapool" "github.com/rocket-pool/smartnode/bindings/rocketpool" ) @@ -32,15 +29,3 @@ func GetMinipoolExitRequests(rp *rocketpool.RocketPool, opts *bind.CallOpts) ([] }, }, nil } - -// Estimate the gas to call NotifyMinipoolDidNotExit -// TODO: placeholder — the contract method does not exist yet -func EstimateNotifyMinipoolDidNotExitGas(rp *rocketpool.RocketPool, validatorIndex uint64, slotTimestamp uint64, validatorProof megapool.ValidatorProof, slotProof megapool.SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { - return rocketpool.GasInfo{}, fmt.Errorf("not implemented: the minipool did-not-exit contract method is not yet available") -} - -// Report that a minipool validator did not exit within the cooperative exit phase -// TODO: placeholder — the contract method does not exist yet -func NotifyMinipoolDidNotExit(rp *rocketpool.RocketPool, validatorIndex uint64, slotTimestamp uint64, validatorProof megapool.ValidatorProof, slotProof megapool.SlotProof, opts *bind.TransactOpts) (*types.Transaction, error) { - return nil, fmt.Errorf("not implemented: the minipool did-not-exit contract method is not yet available") -} diff --git a/bindings/network/exit.go b/bindings/network/exit.go new file mode 100644 index 000000000..799daa774 --- /dev/null +++ b/bindings/network/exit.go @@ -0,0 +1,185 @@ +package network + +import ( + "fmt" + "math/big" + "sync" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/rocketpool" +) + +// Get the amount of ETH currently requested to exit +func GetRequestedEth(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, opts) + if err != nil { + return nil, err + } + value := new(*big.Int) + if err := rocketNetworkExit.Call(opts, value, "getRequestedEth"); err != nil { + return nil, fmt.Errorf("error getting requested ETH: %w", err) + } + return *value, nil +} + +// Get the start of the cooperative exit phase for a Minipool +func GetMinipoolCooperativeExitStart(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.CallOpts) (time.Time, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, opts) + if err != nil { + return time.Time{}, err + } + value := new(*big.Int) + if err := rocketNetworkExit.Call(opts, value, "getMinipoolCooperativeExitStart", minipoolAddress); err != nil { + return time.Time{}, fmt.Errorf("error getting minipool cooperative exit start for %s: %w", minipoolAddress.Hex(), err) + } + return time.Unix((*value).Int64(), 0), nil +} + +// Get the start of the cooperative exit phase for a Megapool validator +func GetMegapoolCooperativeExitStart(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.CallOpts) (time.Time, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, opts) + if err != nil { + return time.Time{}, err + } + value := new(*big.Int) + if err := rocketNetworkExit.Call(opts, value, "getMegapoolCooperativeExitStart", megapoolAddress, validatorId); err != nil { + return time.Time{}, fmt.Errorf("error getting megapool cooperative exit start for %s validator %d: %w", megapoolAddress.Hex(), validatorId, err) + } + return time.Unix((*value).Int64(), 0), nil +} + +// Get the timestamp of the last exit request for a Minipool (or zero time if never requested) +func GetMinipoolLastExit(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.CallOpts) (time.Time, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, opts) + if err != nil { + return time.Time{}, err + } + value := new(*big.Int) + if err := rocketNetworkExit.Call(opts, value, "getMinipoolLastExit", minipoolAddress); err != nil { + return time.Time{}, fmt.Errorf("error getting minipool last exit for %s: %w", minipoolAddress.Hex(), err) + } + return time.Unix((*value).Int64(), 0), nil +} + +// Estimate the gas of RequestMinipoolExit +func EstimateRequestMinipoolExitGas(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return rocketpool.GasInfo{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "requestMinipoolExit", minipoolAddress) +} + +// Request a Minipool to exit cooperatively +func RequestMinipoolExit(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "requestMinipoolExit", minipoolAddress) + if err != nil { + return common.Hash{}, fmt.Errorf("error requesting minipool exit for %s: %w", minipoolAddress.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of ForceMinipoolExit +func EstimateForceMinipoolExitGas(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return rocketpool.GasInfo{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "forceMinipoolExit", minipoolAddress) +} + +// Force a Minipool to exit after the cooperative exit phase +func ForceMinipoolExit(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "forceMinipoolExit", minipoolAddress) + if err != nil { + return common.Hash{}, fmt.Errorf("error forcing minipool exit for %s: %w", minipoolAddress.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of PenaliseMinipool +func EstimatePenaliseMinipoolGas(rp *rocketpool.RocketPool, minipoolAddress common.Address, slotTimestamp uint64, validatorProof megapool.ValidatorProof, slotProof megapool.SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return rocketpool.GasInfo{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "penaliseMinipool", minipoolAddress, slotTimestamp, validatorProof, slotProof) +} + +// Penalise a Minipool that failed to exit cooperatively (or force-exits if the delegate supports it) +func PenaliseMinipool(rp *rocketpool.RocketPool, minipoolAddress common.Address, slotTimestamp uint64, validatorProof megapool.ValidatorProof, slotProof megapool.SlotProof, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "penaliseMinipool", minipoolAddress, slotTimestamp, validatorProof, slotProof) + if err != nil { + return common.Hash{}, fmt.Errorf("error penalising minipool %s: %w", minipoolAddress.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of RequestMegapoolExit +func EstimateRequestMegapoolExitGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return rocketpool.GasInfo{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "requestMegapoolExit", megapoolAddress, validatorId) +} + +// Request a Megapool validator to exit cooperatively +func RequestMegapoolExit(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "requestMegapoolExit", megapoolAddress, validatorId) + if err != nil { + return common.Hash{}, fmt.Errorf("error requesting megapool exit for %s validator %d: %w", megapoolAddress.Hex(), validatorId, err) + } + return tx.Hash(), nil +} + +// Estimate the gas of ForceMegapoolExit +func EstimateForceMegapoolExitGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return rocketpool.GasInfo{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "forceMegapoolExit", megapoolAddress, validatorId) +} + +// Force a Megapool validator to exit after the cooperative exit phase +func ForceMegapoolExit(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "forceMegapoolExit", megapoolAddress, validatorId) + if err != nil { + return common.Hash{}, fmt.Errorf("error forcing megapool exit for %s validator %d: %w", megapoolAddress.Hex(), validatorId, err) + } + return tx.Hash(), nil +} + +// Get contracts +var rocketNetworkExitLock sync.Mutex + +func getRocketNetworkExit(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*rocketpool.Contract, error) { + rocketNetworkExitLock.Lock() + defer rocketNetworkExitLock.Unlock() + return rp.GetContract("rocketNetworkExit", opts) +} diff --git a/rocketpool/node/check-minipool-exit-requests.go b/rocketpool/node/check-minipool-exit-requests.go index ccbaa57e2..98f455675 100644 --- a/rocketpool/node/check-minipool-exit-requests.go +++ b/rocketpool/node/check-minipool-exit-requests.go @@ -13,6 +13,7 @@ import ( eth2types "github.com/wealdtech/go-eth2-types/v2" "github.com/rocket-pool/smartnode/bindings/minipool" + "github.com/rocket-pool/smartnode/bindings/network" "github.com/rocket-pool/smartnode/bindings/rocketpool" "github.com/rocket-pool/smartnode/bindings/settings/protocol" "github.com/rocket-pool/smartnode/bindings/types" @@ -31,9 +32,6 @@ import ( rpvalidator "github.com/rocket-pool/smartnode/shared/utils/validator" ) -// TODO: flip to true once the did-not-exit contract method exists -const didNotExitTxEnabled = false - // A minipool validator that did not exit within the cooperative exit phase type didNotExitValidator struct { validatorIndex uint64 @@ -181,7 +179,7 @@ func (t *checkMinipoolExitRequests) run(state *state.NetworkState) error { continue } - // If the minipool belongs to this node, cooperate: sign and submit the voluntary exit + // If the minipool belongs to this node, cooperate signing and submitting a voluntary exit if minipoolDetails.NodeAddress == nodeAccount.Address { t.log.Printlnf("Minipool %s (validator %d) belongs to this node; submitting a voluntary exit", minipoolDetails.MinipoolAddress.Hex(), request.ValidatorIndex) err := t.exitOwnMinipool(minipoolDetails, status) @@ -195,7 +193,7 @@ func (t *checkMinipoolExitRequests) run(state *state.NetworkState) error { // did-not-exit path only applies to older delegates if minipoolDetails.Version >= 4 { t.log.Printlnf("Minipool %s (validator %d) uses delegate version %d; submitting ForceExit", minipoolDetails.MinipoolAddress.Hex(), request.ValidatorIndex, minipoolDetails.Version) - err := t.forceExitMinipool(minipoolDetails, opts) + err := t.forceExitMinipool(minipoolDetails) if err != nil { t.log.Printlnf("Error force-exiting minipool %s: %s", minipoolDetails.MinipoolAddress.Hex(), err.Error()) } @@ -285,17 +283,7 @@ func (t *checkMinipoolExitRequests) exitOwnMinipool(mpd *rpstate.NativeMinipoolD return nil } -func (t *checkMinipoolExitRequests) forceExitMinipool(mpd *rpstate.NativeMinipoolDetails, callOpts *bind.CallOpts) error { - - mp, err := minipool.NewMinipoolFromVersion(t.rp, mpd.MinipoolAddress, mpd.Version, callOpts) - if err != nil { - return fmt.Errorf("cannot create binding for minipool %s: %w", mpd.MinipoolAddress.Hex(), err) - } - - mpv4, success := minipool.GetMinipoolAsV4(mp) - if !success { - return fmt.Errorf("minipool %s cannot be converted to v4 (current version: %d)", mpd.MinipoolAddress.Hex(), mp.GetVersion()) - } +func (t *checkMinipoolExitRequests) forceExitMinipool(mpd *rpstate.NativeMinipoolDetails) error { // Get transactor opts, err := t.w.GetNodeAccountTransactor() @@ -304,7 +292,7 @@ func (t *checkMinipoolExitRequests) forceExitMinipool(mpd *rpstate.NativeMinipoo } // Get the gas limit - gasInfo, err := mpv4.EstimateForceExitGas(opts) + gasInfo, err := network.EstimateForceMinipoolExitGas(t.rp, mpd.MinipoolAddress, opts) if err != nil { return fmt.Errorf("could not estimate the gas required to force exit minipool %s: %w", mpd.MinipoolAddress.Hex(), err) } @@ -333,8 +321,8 @@ func (t *checkMinipoolExitRequests) forceExitMinipool(mpd *rpstate.NativeMinipoo opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) opts.GasLimit = gas.Uint64() - // Force exit the minipool - hash, err := mpv4.ForceExit(opts) + // Force exit the minipool via rocketNetworkExit + hash, err := network.ForceMinipoolExit(t.rp, mpd.MinipoolAddress, opts) if err != nil { return err } @@ -346,7 +334,7 @@ func (t *checkMinipoolExitRequests) forceExitMinipool(mpd *rpstate.NativeMinipoo } // Log - t.log.Printlnf("Successfully submitted ForceExit for minipool %s.", mpd.MinipoolAddress.Hex()) + t.log.Printlnf("Successfully submitted ForceMinipoolExit for minipool %s.", mpd.MinipoolAddress.Hex()) // Return return nil @@ -365,12 +353,6 @@ func (t *checkMinipoolExitRequests) proveDidNotExit(beaconState eth2.BeaconState t.log.Printlnf("[FINISHED] The did-not-exit proof for validator %d has been successfully created (exit epoch %d at slot %d).", validator.validatorIndex, validatorProof.Validator.ExitEpoch, slotProof.Slot) - if !didNotExitTxEnabled { - // TODO: remove this check once the did-not-exit contract method exists - t.log.Printlnf("[TODO] The contract method to report that validator %d did not exit is not yet available; skipping the transaction.", validator.validatorIndex) - return nil - } - // Get transactor opts, err := t.w.GetNodeAccountTransactor() if err != nil { @@ -378,9 +360,9 @@ func (t *checkMinipoolExitRequests) proveDidNotExit(beaconState eth2.BeaconState } // Get the gas limit - gasInfo, err := minipool.EstimateNotifyMinipoolDidNotExitGas(t.rp, validator.validatorIndex, slotTimestamp, validatorProof, slotProof, opts) + gasInfo, err := network.EstimatePenaliseMinipoolGas(t.rp, validator.minipoolAddress, slotTimestamp, validatorProof, slotProof, opts) if err != nil { - t.log.Printlnf("Could not estimate the gas required to report that validator %d did not exit: %s", validator.validatorIndex, err.Error()) + t.log.Printlnf("Could not estimate the gas required to penalise minipool %s: %s", validator.minipoolAddress.Hex(), err.Error()) return err } gas := big.NewInt(int64(gasInfo.SafeGasLimit)) @@ -402,20 +384,20 @@ func (t *checkMinipoolExitRequests) proveDidNotExit(beaconState eth2.BeaconState opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) opts.GasLimit = gas.Uint64() - // Report that the validator did not exit - tx, err := minipool.NotifyMinipoolDidNotExit(t.rp, validator.validatorIndex, slotTimestamp, validatorProof, slotProof, opts) + // Penalise the minipool for failing to exit within the cooperative phase + hash, err := network.PenaliseMinipool(t.rp, validator.minipoolAddress, slotTimestamp, validatorProof, slotProof, opts) if err != nil { return err } // Print TX info and wait for it to be included in a block - err = api.PrintAndWaitForTransaction(t.cfg, tx.Hash(), t.rp.Client, &t.log) + err = api.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) if err != nil { return err } // Log - t.log.Printlnf("Successfully reported that validator %d did not exit.", validator.validatorIndex) + t.log.Printlnf("Successfully penalised minipool %s (validator %d) for not exiting.", validator.minipoolAddress.Hex(), validator.validatorIndex) // Return return nil From a1ab7d40ec90faf5e4fc871994f08637bf8627a0 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:31:24 -0300 Subject: [PATCH 25/35] Fetch minipool exit requests from the events --- bindings/minipool/exit-requests.go | 31 -------- bindings/network/exit.go | 74 +++++++++++++++++++ .../node/check-minipool-exit-requests.go | 61 ++++++++++----- 3 files changed, 117 insertions(+), 49 deletions(-) delete mode 100644 bindings/minipool/exit-requests.go diff --git a/bindings/minipool/exit-requests.go b/bindings/minipool/exit-requests.go deleted file mode 100644 index dd6b0404d..000000000 --- a/bindings/minipool/exit-requests.go +++ /dev/null @@ -1,31 +0,0 @@ -package minipool - -import ( - "time" - - "github.com/ethereum/go-ethereum/accounts/abi/bind" - - "github.com/rocket-pool/smartnode/bindings/rocketpool" -) - -// A pending request for a minipool validator to exit the beacon chain -type MinipoolExitRequest struct { - ValidatorIndex uint64 // Beacon chain validator index - RequestTimestamp uint64 // Unix seconds when the exit was requested -} - -// Get the list of pending minipool exit requests -// TODO: stub — the contract view is not available yet; replace with the real -// contract call once it is deployed. Returns a fixed example response for now. -func GetMinipoolExitRequests(rp *rocketpool.RocketPool, opts *bind.CallOpts) ([]MinipoolExitRequest, error) { - return []MinipoolExitRequest{ - { - ValidatorIndex: 1000, - RequestTimestamp: uint64(time.Now().Add(-48 * time.Hour).Unix()), - }, - { - ValidatorIndex: 1001, - RequestTimestamp: uint64(time.Now().Add(-1 * time.Hour).Unix()), - }, - }, nil -} diff --git a/bindings/network/exit.go b/bindings/network/exit.go index 799daa774..bbb5d31d8 100644 --- a/bindings/network/exit.go +++ b/bindings/network/exit.go @@ -1,6 +1,7 @@ package network import ( + "context" "fmt" "math/big" "sync" @@ -11,8 +12,18 @@ import ( "github.com/rocket-pool/smartnode/bindings/megapool" "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/types" + "github.com/rocket-pool/smartnode/bindings/utils/eth" ) +// A MinipoolExitRequested event from rocketNetworkExit +type MinipoolExitRequest struct { + MinipoolAddress common.Address `json:"minipoolAddress"` + Pubkey types.ValidatorPubkey `json:"pubkey"` + RequestTimestamp uint64 `json:"requestTimestamp"` + BlockNumber uint64 `json:"blockNumber"` +} + // Get the amount of ETH currently requested to exit func GetRequestedEth(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { rocketNetworkExit, err := getRocketNetworkExit(rp, opts) @@ -175,6 +186,69 @@ func ForceMegapoolExit(rp *rocketpool.RocketPool, megapoolAddress common.Address return tx.Hash(), nil } +// Get MinipoolExitRequested events emitted during the given block range +func GetMinipoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, fromBlock *big.Int, toBlock *big.Int, opts *bind.CallOpts) ([]MinipoolExitRequest, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, opts) + if err != nil { + return nil, err + } + + minipoolExitRequestedEvent, exists := rocketNetworkExit.ABI.Events["MinipoolExitRequested"] + if !exists { + return nil, fmt.Errorf("MinipoolExitRequested event not found in rocketNetworkExit ABI") + } + + addressFilter := []common.Address{*rocketNetworkExit.Address} + topicFilter := [][]common.Hash{{minipoolExitRequestedEvent.ID}} + + logs, err := eth.GetLogs(rp, addressFilter, topicFilter, intervalSize, fromBlock, toBlock, nil) + if err != nil { + return nil, err + } + if len(logs) == 0 { + return []MinipoolExitRequest{}, nil + } + + blockTimestamps := make(map[uint64]uint64, len(logs)) + requests := make([]MinipoolExitRequest, 0, len(logs)) + for _, log := range logs { + if len(log.Topics) < 2 { + return nil, fmt.Errorf("MinipoolExitRequested event had %d topics but at least 2 are required", len(log.Topics)) + } + + values, err := minipoolExitRequestedEvent.Inputs.Unpack(log.Data) + if err != nil { + return nil, fmt.Errorf("error unpacking MinipoolExitRequested event data: %w", err) + } + if len(values) < 1 { + return nil, fmt.Errorf("MinipoolExitRequested event had no data values") + } + pubkeyBytes, ok := values[0].([]byte) + if !ok { + return nil, fmt.Errorf("MinipoolExitRequested pubkey had unexpected type %T", values[0]) + } + + timestamp, cached := blockTimestamps[log.BlockNumber] + if !cached { + header, err := rp.Client.HeaderByNumber(context.Background(), new(big.Int).SetUint64(log.BlockNumber)) + if err != nil { + return nil, fmt.Errorf("error getting header for block %d: %w", log.BlockNumber, err) + } + timestamp = header.Time + blockTimestamps[log.BlockNumber] = timestamp + } + + requests = append(requests, MinipoolExitRequest{ + MinipoolAddress: common.BytesToAddress(log.Topics[1].Bytes()), + Pubkey: types.BytesToValidatorPubkey(pubkeyBytes), + RequestTimestamp: timestamp, + BlockNumber: log.BlockNumber, + }) + } + + return requests, nil +} + // Get contracts var rocketNetworkExitLock sync.Mutex diff --git a/rocketpool/node/check-minipool-exit-requests.go b/rocketpool/node/check-minipool-exit-requests.go index 98f455675..1deeac188 100644 --- a/rocketpool/node/check-minipool-exit-requests.go +++ b/rocketpool/node/check-minipool-exit-requests.go @@ -12,7 +12,6 @@ import ( "github.com/urfave/cli/v3" eth2types "github.com/wealdtech/go-eth2-types/v2" - "github.com/rocket-pool/smartnode/bindings/minipool" "github.com/rocket-pool/smartnode/bindings/network" "github.com/rocket-pool/smartnode/bindings/rocketpool" "github.com/rocket-pool/smartnode/bindings/settings/protocol" @@ -52,6 +51,7 @@ type checkMinipoolExitRequests struct { maxFee *big.Int maxPriorityFee *big.Int gasLimit uint64 + intervalSize *big.Int } // Create check minipool exit requests task @@ -100,6 +100,12 @@ func newCheckMinipoolExitRequests(c *cli.Command, logger log.ColorLogger) (*chec priorityFee = eth.GweiToWei(priorityFeeGwei) } + // Get the event log interval + eventLogInterval, err := cfg.GetEventLogInterval() + if err != nil { + return nil, err + } + // Return task return &checkMinipoolExitRequests{ c: c, @@ -113,6 +119,7 @@ func newCheckMinipoolExitRequests(c *cli.Command, logger log.ColorLogger) (*chec maxFee: maxFee, maxPriorityFee: priorityFee, gasLimit: 0, + intervalSize: big.NewInt(int64(eventLogInterval)), }, nil } @@ -133,20 +140,32 @@ func (t *checkMinipoolExitRequests) run(state *state.NetworkState) error { return err } - // Get the pending exit requests - exitRequests, err := minipool.GetMinipoolExitRequests(t.rp, opts) + // Get the cooperative exit phase duration + cooperativeExitPhase, err := protocol.GetCooperativeExitPhase(t.rp, opts) if err != nil { return err } - if len(exitRequests) == 0 { - return nil + + // Search MinipoolExitRequested events over a window covering the cooperative exit phase + lookbackBlocks := uint64(cooperativeExitPhase / (time.Duration(state.BeaconConfig.SecondsPerSlot) * time.Second)) + if lookbackBlocks == 0 { + lookbackBlocks = 1 + } + var fromBlock *big.Int + if state.ElBlockNumber > lookbackBlocks { + fromBlock = big.NewInt(int64(state.ElBlockNumber - lookbackBlocks)) + } else { + fromBlock = big.NewInt(0) } + toBlock := big.NewInt(int64(state.ElBlockNumber)) - // Get the cooperative exit phase duration - cooperativeExitPhase, err := protocol.GetCooperativeExitPhase(t.rp, opts) + exitRequests, err := network.GetMinipoolExitRequests(t.rp, t.intervalSize, fromBlock, toBlock, opts) if err != nil { return err } + if len(exitRequests) == 0 { + return nil + } // Index the minipool details by validator pubkey minipoolDetailsByPubkey := make(map[types.ValidatorPubkey]*rpstate.NativeMinipoolDetails, len(state.MinipoolDetails)) @@ -157,31 +176,37 @@ func (t *checkMinipoolExitRequests) run(state *state.NetworkState) error { validatorsToProve := []didNotExitValidator{} for _, request := range exitRequests { - status, err := t.bc.GetValidatorStatusByIndex(strconv.FormatUint(request.ValidatorIndex, 10), nil) + status, err := t.bc.GetValidatorStatus(request.Pubkey, nil) if err != nil { - t.log.Printlnf("Error getting the status of validator %d: %s", request.ValidatorIndex, err.Error()) + t.log.Printlnf("Error getting the status of validator %s: %s", request.Pubkey.Hex(), err.Error()) continue } if !status.Exists { - t.log.Printlnf("Validator %d not found on the beacon chain", request.ValidatorIndex) + t.log.Printlnf("Validator %s not found on the beacon chain", request.Pubkey.Hex()) + continue + } + + validatorIndex, err := strconv.ParseUint(status.Index, 10, 64) + if err != nil { + t.log.Printlnf("Error parsing validator index %s: %s", status.Index, err.Error()) continue } // An initiated exit satisfies the request if status.ExitEpoch != FarFutureEpoch { - t.log.Printlnf("Validator %d has already exited", request.ValidatorIndex) + t.log.Printlnf("Validator %d has already exited", validatorIndex) continue } - minipoolDetails, exists := minipoolDetailsByPubkey[status.Pubkey] + minipoolDetails, exists := minipoolDetailsByPubkey[request.Pubkey] if !exists { - t.log.Printlnf("Validator %d does not belong to a known minipool", request.ValidatorIndex) + t.log.Printlnf("Validator %d does not belong to a known minipool", validatorIndex) continue } // If the minipool belongs to this node, cooperate signing and submitting a voluntary exit if minipoolDetails.NodeAddress == nodeAccount.Address { - t.log.Printlnf("Minipool %s (validator %d) belongs to this node; submitting a voluntary exit", minipoolDetails.MinipoolAddress.Hex(), request.ValidatorIndex) + t.log.Printlnf("Minipool %s (validator %d) belongs to this node; submitting a voluntary exit", minipoolDetails.MinipoolAddress.Hex(), validatorIndex) err := t.exitOwnMinipool(minipoolDetails, status) if err != nil { t.log.Printlnf("Error exiting minipool %s: %s", minipoolDetails.MinipoolAddress.Hex(), err.Error()) @@ -192,7 +217,7 @@ func (t *checkMinipoolExitRequests) run(state *state.NetworkState) error { // Delegates from version 4 support a forced exit request, so the // did-not-exit path only applies to older delegates if minipoolDetails.Version >= 4 { - t.log.Printlnf("Minipool %s (validator %d) uses delegate version %d; submitting ForceExit", minipoolDetails.MinipoolAddress.Hex(), request.ValidatorIndex, minipoolDetails.Version) + t.log.Printlnf("Minipool %s (validator %d) uses delegate version %d; submitting ForceExit", minipoolDetails.MinipoolAddress.Hex(), validatorIndex, minipoolDetails.Version) err := t.forceExitMinipool(minipoolDetails) if err != nil { t.log.Printlnf("Error force-exiting minipool %s: %s", minipoolDetails.MinipoolAddress.Hex(), err.Error()) @@ -206,9 +231,9 @@ func (t *checkMinipoolExitRequests) run(state *state.NetworkState) error { } validatorsToProve = append(validatorsToProve, didNotExitValidator{ - validatorIndex: request.ValidatorIndex, - pubkey: status.Pubkey, - minipoolAddress: minipoolDetails.MinipoolAddress, + validatorIndex: validatorIndex, + pubkey: request.Pubkey, + minipoolAddress: request.MinipoolAddress, }) } From f07e9e6bcf87df8a2140a5dadfaef73d880f18a2 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:40:15 -0300 Subject: [PATCH 26/35] Add CheckMegapoolExitRequests --- bindings/megapool/megapool-constructor.go | 29 ++ bindings/megapool/megapool-contract-v2.go | 72 ++++ bindings/network/exit.go | 95 +++++ .../node/check-megapool-exit-requests.go | 394 ++++++++++++++++++ rocketpool/node/node.go | 13 + 5 files changed, 603 insertions(+) create mode 100644 bindings/megapool/megapool-constructor.go create mode 100644 bindings/megapool/megapool-contract-v2.go create mode 100644 rocketpool/node/check-megapool-exit-requests.go diff --git a/bindings/megapool/megapool-constructor.go b/bindings/megapool/megapool-constructor.go new file mode 100644 index 000000000..4f240094b --- /dev/null +++ b/bindings/megapool/megapool-constructor.go @@ -0,0 +1,29 @@ +package megapool + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/rocketpool" +) + +// Create a megapool binding for the contract version deployed at the given address +func NewMegapool(rp *rocketpool.RocketPool, address common.Address, opts *bind.CallOpts) (Megapool, error) { + + // Get the contract version + version, err := rocketpool.GetContractVersion(rp, address, opts) + if err != nil { + return nil, fmt.Errorf("error getting megapool contract version: %w", err) + } + + switch version { + case 1: + return NewMegaPoolV1(rp, address, opts) + case 2: + return NewMegaPoolV2(rp, address, opts) + default: + return nil, fmt.Errorf("unexpected megapool contract version [%d]", version) + } +} diff --git a/bindings/megapool/megapool-contract-v2.go b/bindings/megapool/megapool-contract-v2.go new file mode 100644 index 000000000..fcafb156a --- /dev/null +++ b/bindings/megapool/megapool-contract-v2.go @@ -0,0 +1,72 @@ +package megapool + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/rocketpool" +) + +type MegapoolV2 interface { + Megapool + EstimateForceExitGas(validatorIds []uint32, feeLimit *big.Int, opts *bind.TransactOpts) (rocketpool.GasInfo, error) + ForceExit(validatorIds []uint32, feeLimit *big.Int, opts *bind.TransactOpts) (common.Hash, error) +} + +// Megapool contract from delegate version 2, which adds support for EL-triggered forced exits +type megapoolV2 struct { + megapoolV1 +} + +const ( + megapoolV2EncodedAbi string = "eJztWktv2zgQ/isLn4M9tLs99OYmbhHATVMn3j0EgUFLY5sITaokZcco9r/vUC/rZYuyJEOtc0pkksN58ZsZDp9+DggXfLcWvhp8XBCm4GpAuedr/Hz6if+68ApuakiD5IQ97jwYfBz4+P3u7w+DqwEna/MDQUJc47fOTvjvqj4tTfFPkdJzMuErLIknBPskuDsB13eQeDIfNoCMmH1/fQFvYK5vuSOBqN9ZxN/ahveAVPRu6HmMNpFRS9+CrQ1h1CVayFv3zHL+E+88VIou+UWIegM/fPAvQ1SqlGCbi5B1xDsz6/t3FpLipDMJ+kr1ZVgUBaV8eQGSjoXzchEmfdDkMgSdctbQpn1MjyawJdJV14zgzO5l48KFYYvybQSOt0lQgl61Sc+TQgsH82si2iRra1dMF7Skc/9AeMlY8viOs4Nuud+UYHK9ixJtnCh8HZHHKUoTDV99TeaUURw2vsA9siNzlpJk4XNHU8EtuEuH8NnRGJ5iMMiJP/vcVdX8WTK3p+4Q5vgMyaAKXAxzkQ0yO1Ur2hyR/cpSX7E4E40oJE47/NaIjjlMhwmUKn1DYXuSO9R21sRab2Y6o5lOObXOijAGfAkmfWwTV1JbmPjXDWkXPAkOUuolIrph6dxP3qJaN0nBOjJQEiO7ob8EPcQJKTmuI3CqwJu0zkr1dNq5zHIW3RIhbzkvqMaNNsEiw5S5jOwLL1HJ2kfbjV49KomZ9Bimg71Q2JgonaSdfeMtqOD6aMs72CZshf2UHz6VsAYbBs+jvDtTu7muBFWRK5FkUoGleKhFloy2+qSi78GdZZ+4alSOdMbVBHBSv+LOVIG8Jh7VhPWJpdCjesZYT0C0tWQzLdItX4icRI5Ye4IjGlttyjAGhpmVAfDHwmVN0lmopGLiACi9T9CaUYlgqTYRLGKEotqaibkQbL9a5e6Ig9HKVZDrititovx7VNDUXHaPgcnwWZ9Nz4S0qarPq1ts5tmrJtNHsVuWv8A+tMrY9cNf2e0+EUa4kzM+TrJYHm5bPAVm9XPhrhMzR9/Rf0zMIh3fwj/gqSRLGJMdHso/8we1ACva97BC6yGqDLl7789fIHs9+gYvb/DyBi99hxcafmUhpkQnOw2p2seLj7tOD/cMmkox6bhc7UmUYelfqleuJFvCriW4iG6UsIqSJdi/PL2MhzrtNcwR9I739o6cmZmvYBQe8UdqXNLi0GUNsbf3fbmr1aDwgCGFaF+CLZGM40UofkM0mQihy80Qt09S9x2D/lw+V6PNbBt7KHIz8oSzOgWzZhIcdO4D61NqEpoudm33PbpXUth/u+VftkBt9FO4Mpo5pucj92uTm6M6JmJnMtBnipT2IewXMpRiIkggkcu1Vy3ondBnccba3VwZvF0u3Lq11sSRgEsKfYkm7wZaC+RxWtmB1BuQygyf/c6pyaOjApYI5t4Aw1RPwyE8qUcQg1cjgvWf88TbTb2lJG7r7+0KEi6kWLcj2llfs430yrypcIA2eiFdzk6h6NTlxVzLMmHlN8atlJ56CAblYtmAEMbTOXFeDj9TyHhYN3gS9HaTg9OLFlLMT9BMhYqeTdYF8tZvqau7WEDwXKBvmkoc0Y6xjnRlkTryTKOyRBlJQU9NeUlVtzlbruArBY5UNK9S9GnsWSCEDHHTKkt5ei7PU3IPyIKJNk/QZguAMV3T40neQkinlUdgz/8DcNqKAw==" +) + +// The decoded ABI for v2 megapools +var megapoolV2Abi *abi.ABI + +// Create new megapool contract +func NewMegaPoolV2(rp *rocketpool.RocketPool, address common.Address, opts *bind.CallOpts) (MegapoolV2, error) { + + var contract *rocketpool.Contract + var err error + if megapoolV2Abi == nil { + // Get contract + contract, err = createMegapoolContractFromEncodedAbi(rp, address, megapoolV2EncodedAbi) + } else { + contract, err = createMegapoolContractFromAbi(rp, address, megapoolV2Abi) + } + if err != nil { + return nil, err + } else if megapoolV2Abi == nil { + megapoolV2Abi = contract.ABI + } + + // Create and return + return &megapoolV2{ + megapoolV1: megapoolV1{ + Address: address, + Version: 2, + Contract: contract, + RocketPool: rp, + }, + }, nil +} + +// Estimate the gas of ForceExit +func (mp *megapoolV2) EstimateForceExitGas(validatorIds []uint32, feeLimit *big.Int, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + return mp.Contract.GetTransactionGasInfo(opts, "forceExit", validatorIds, feeLimit) +} + +// Force exit megapool validators that failed to exit within the cooperative exit phase +func (mp *megapoolV2) ForceExit(validatorIds []uint32, feeLimit *big.Int, opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "forceExit", validatorIds, feeLimit) + if err != nil { + return common.Hash{}, fmt.Errorf("error force exiting megapool %s validators: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} diff --git a/bindings/network/exit.go b/bindings/network/exit.go index bbb5d31d8..6727e89bb 100644 --- a/bindings/network/exit.go +++ b/bindings/network/exit.go @@ -24,6 +24,15 @@ type MinipoolExitRequest struct { BlockNumber uint64 `json:"blockNumber"` } +// A MegapoolExitRequested event from rocketNetworkExit +type MegapoolExitRequest struct { + MegapoolAddress common.Address `json:"megapoolAddress"` + ValidatorId uint32 `json:"validatorId"` + Pubkey types.ValidatorPubkey `json:"pubkey"` + RequestTimestamp uint64 `json:"requestTimestamp"` + BlockNumber uint64 `json:"blockNumber"` +} + // Get the amount of ETH currently requested to exit func GetRequestedEth(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { rocketNetworkExit, err := getRocketNetworkExit(rp, opts) @@ -186,6 +195,28 @@ func ForceMegapoolExit(rp *rocketpool.RocketPool, megapoolAddress common.Address return tx.Hash(), nil } +// Estimate the gas of PenaliseMegapoolValidator +func EstimatePenaliseMegapoolValidatorGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return rocketpool.GasInfo{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "penaliseMegapool", megapoolAddress, validatorId) +} + +// Penalise a megapool validator that failed to exit within the cooperative exit phase +func PenaliseMegapoolValidator(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "penaliseMegapool", megapoolAddress, validatorId) + if err != nil { + return common.Hash{}, fmt.Errorf("error penalising megapool %s validator %d: %w", megapoolAddress.Hex(), validatorId, err) + } + return tx.Hash(), nil +} + // Get MinipoolExitRequested events emitted during the given block range func GetMinipoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, fromBlock *big.Int, toBlock *big.Int, opts *bind.CallOpts) ([]MinipoolExitRequest, error) { rocketNetworkExit, err := getRocketNetworkExit(rp, opts) @@ -249,6 +280,70 @@ func GetMinipoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, f return requests, nil } +// Get MegapoolExitRequested events emitted during the given block range +func GetMegapoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, fromBlock *big.Int, toBlock *big.Int, opts *bind.CallOpts) ([]MegapoolExitRequest, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, opts) + if err != nil { + return nil, err + } + + megapoolExitRequestedEvent, exists := rocketNetworkExit.ABI.Events["MegapoolExitRequested"] + if !exists { + return nil, fmt.Errorf("MegapoolExitRequested event not found in rocketNetworkExit ABI") + } + + addressFilter := []common.Address{*rocketNetworkExit.Address} + topicFilter := [][]common.Hash{{megapoolExitRequestedEvent.ID}} + + logs, err := eth.GetLogs(rp, addressFilter, topicFilter, intervalSize, fromBlock, toBlock, nil) + if err != nil { + return nil, err + } + if len(logs) == 0 { + return []MegapoolExitRequest{}, nil + } + + blockTimestamps := make(map[uint64]uint64, len(logs)) + requests := make([]MegapoolExitRequest, 0, len(logs)) + for _, log := range logs { + if len(log.Topics) < 3 { + return nil, fmt.Errorf("MegapoolExitRequested event had %d topics but at least 3 are required", len(log.Topics)) + } + + values, err := megapoolExitRequestedEvent.Inputs.Unpack(log.Data) + if err != nil { + return nil, fmt.Errorf("error unpacking MegapoolExitRequested event data: %w", err) + } + if len(values) < 1 { + return nil, fmt.Errorf("MegapoolExitRequested event had no data values") + } + pubkeyBytes, ok := values[0].([]byte) + if !ok { + return nil, fmt.Errorf("MegapoolExitRequested pubkey had unexpected type %T", values[0]) + } + + timestamp, cached := blockTimestamps[log.BlockNumber] + if !cached { + header, err := rp.Client.HeaderByNumber(context.Background(), new(big.Int).SetUint64(log.BlockNumber)) + if err != nil { + return nil, fmt.Errorf("error getting header for block %d: %w", log.BlockNumber, err) + } + timestamp = header.Time + blockTimestamps[log.BlockNumber] = timestamp + } + + requests = append(requests, MegapoolExitRequest{ + MegapoolAddress: common.BytesToAddress(log.Topics[1].Bytes()), + ValidatorId: uint32(new(big.Int).SetBytes(log.Topics[2].Bytes()).Uint64()), + Pubkey: types.BytesToValidatorPubkey(pubkeyBytes), + RequestTimestamp: timestamp, + BlockNumber: log.BlockNumber, + }) + } + + return requests, nil +} + // Get contracts var rocketNetworkExitLock sync.Mutex diff --git a/rocketpool/node/check-megapool-exit-requests.go b/rocketpool/node/check-megapool-exit-requests.go new file mode 100644 index 000000000..9e4cc9613 --- /dev/null +++ b/rocketpool/node/check-megapool-exit-requests.go @@ -0,0 +1,394 @@ +package node + +import ( + "fmt" + "math/big" + "strconv" + "time" + + "github.com/docker/docker/client" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/urfave/cli/v3" + eth2types "github.com/wealdtech/go-eth2-types/v2" + + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/network" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/bindings/utils/eth" + + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/config" + rpgas "github.com/rocket-pool/smartnode/shared/services/gas" + "github.com/rocket-pool/smartnode/shared/services/state" + "github.com/rocket-pool/smartnode/shared/services/wallet" + "github.com/rocket-pool/smartnode/shared/utils/api" + "github.com/rocket-pool/smartnode/shared/utils/log" + rpvalidator "github.com/rocket-pool/smartnode/shared/utils/validator" +) + +// Check megapool exit requests task +type checkMegapoolExitRequests struct { + c *cli.Command + log log.ColorLogger + cfg *config.RocketPoolConfig + w wallet.Wallet + rp *rocketpool.RocketPool + bc beacon.Client + d *client.Client + gasThreshold float64 + maxFee *big.Int + maxPriorityFee *big.Int + gasLimit uint64 + intervalSize *big.Int +} + +// Create check megapool exit requests task +func newCheckMegapoolExitRequests(c *cli.Command, logger log.ColorLogger) (*checkMegapoolExitRequests, error) { + + // Get services + cfg, err := services.GetConfig(c) + if err != nil { + return nil, err + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + d, err := services.GetDocker(c) + if err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + gasThreshold := cfg.Smartnode.AutoTxGasThreshold.Value.(float64) + + // Get the user-requested max fee + maxFeeGwei := cfg.Smartnode.ManualMaxFee.Value.(float64) + var maxFee *big.Int + if maxFeeGwei == 0 { + maxFee = nil + } else { + maxFee = eth.GweiToWei(maxFeeGwei) + } + + // Get the user-requested priority fee + priorityFeeGwei := cfg.Smartnode.PriorityFee.Value.(float64) + var priorityFee *big.Int + if priorityFeeGwei == 0 { + logger.Printlnf("WARNING: priority fee was missing or 0, setting a default of %.2f.", rpgas.DefaultPriorityFeeGwei) + priorityFee = eth.GweiToWei(rpgas.DefaultPriorityFeeGwei) + } else { + priorityFee = eth.GweiToWei(priorityFeeGwei) + } + + // Get the event log interval + eventLogInterval, err := cfg.GetEventLogInterval() + if err != nil { + return nil, err + } + + // Return task + return &checkMegapoolExitRequests{ + c: c, + log: logger, + cfg: cfg, + w: w, + rp: rp, + bc: bc, + d: d, + gasThreshold: gasThreshold, + maxFee: maxFee, + maxPriorityFee: priorityFee, + gasLimit: 0, + intervalSize: big.NewInt(int64(eventLogInterval)), + }, nil + +} + +// Check for megapool validators that did not respond to an exit request +func (t *checkMegapoolExitRequests) run(state *state.NetworkState) error { + // Log + t.log.Println("Checking for megapool validators that did not respond to an exit request...") + + // Get the latest state + opts := &bind.CallOpts{ + BlockNumber: big.NewInt(0).SetUint64(state.ElBlockNumber), + } + + // Get node account + nodeAccount, err := t.w.GetNodeAccount() + if err != nil { + return err + } + + // Get the cooperative exit phase duration + cooperativeExitPhase, err := protocol.GetCooperativeExitPhase(t.rp, opts) + if err != nil { + return err + } + + // Search MegapoolExitRequested events over a window covering the cooperative exit phase + lookbackBlocks := uint64(cooperativeExitPhase / (time.Duration(state.BeaconConfig.SecondsPerSlot) * time.Second)) + if lookbackBlocks == 0 { + lookbackBlocks = 1 + } + var fromBlock *big.Int + if state.ElBlockNumber > lookbackBlocks { + fromBlock = big.NewInt(int64(state.ElBlockNumber - lookbackBlocks)) + } else { + fromBlock = big.NewInt(0) + } + toBlock := big.NewInt(int64(state.ElBlockNumber)) + + exitRequests, err := network.GetMegapoolExitRequests(t.rp, t.intervalSize, fromBlock, toBlock, opts) + if err != nil { + return err + } + if len(exitRequests) == 0 { + return nil + } + + // Get this node's megapool address, if one is deployed + ownMegapoolDeployed := false + nodeDetails, exists := state.NodeDetailsByAddress[nodeAccount.Address] + if exists { + ownMegapoolDeployed = nodeDetails.MegapoolDeployed + } + + for _, request := range exitRequests { + + status, err := t.bc.GetValidatorStatus(request.Pubkey, nil) + if err != nil { + t.log.Printlnf("Error getting the status of validator %s: %s", request.Pubkey.Hex(), err.Error()) + continue + } + if !status.Exists { + t.log.Printlnf("Validator %s not found on the beacon chain", request.Pubkey.Hex()) + continue + } + + validatorIndex, err := strconv.ParseUint(status.Index, 10, 64) + if err != nil { + t.log.Printlnf("Error parsing validator index %s: %s", status.Index, err.Error()) + continue + } + + // An initiated exit satisfies the request + if status.ExitEpoch != FarFutureEpoch { + t.log.Printlnf("Validator %d has already exited", validatorIndex) + continue + } + + // If the megapool belongs to this node, cooperate signing and submitting a voluntary exit + if ownMegapoolDeployed && request.MegapoolAddress == nodeDetails.MegapoolAddress { + t.log.Printlnf("Megapool %s (validator %d) belongs to this node; submitting a voluntary exit", request.MegapoolAddress.Hex(), validatorIndex) + err := t.exitOwnMegapoolValidator(state, request, status) + if err != nil { + t.log.Printlnf("Error exiting megapool %s validator %d: %s", request.MegapoolAddress.Hex(), request.ValidatorId, err.Error()) + } + continue + } + + mp, err := megapool.NewMegapool(t.rp, request.MegapoolAddress, opts) + if err != nil { + t.log.Printlnf("Error creating a binding for megapool %s: %s", request.MegapoolAddress.Hex(), err.Error()) + continue + } + + // Skip requests still within the cooperative exit phase + deadline := time.Unix(int64(request.RequestTimestamp), 0).Add(cooperativeExitPhase) + if time.Now().Before(deadline) { + continue + } + + // Megapools from version 2 support a forced exit request, so the + // did-not-exit penalty only applies to version 1 + if mpv2, ok := mp.(megapool.MegapoolV2); ok { + t.log.Printlnf("Megapool %s (validator %d) uses version %d; submitting ForceExit", request.MegapoolAddress.Hex(), validatorIndex, mp.GetVersion()) + err := t.forceExitMegapoolValidator(mpv2, request) + if err != nil { + t.log.Printlnf("Error force-exiting megapool %s validator %d: %s", request.MegapoolAddress.Hex(), request.ValidatorId, err.Error()) + } + continue + } + + // Log + t.log.Printlnf("The validator %d (megapool %s) did not exit within the cooperative exit phase", validatorIndex, request.MegapoolAddress.Hex()) + + err = t.penaliseMegapoolValidator(request) + // dont return if there was an error, just log it so we can continue with the next validator + if err != nil { + t.log.Printlnf("Error penalising megapool %s validator %d: %s", request.MegapoolAddress.Hex(), request.ValidatorId, err.Error()) + } + } + + // Return + return nil + +} + +// Sign and broadcast the voluntary exit for a megapool validator belonging to this node +func (t *checkMegapoolExitRequests) exitOwnMegapoolValidator(state *state.NetworkState, request network.MegapoolExitRequest, status beacon.ValidatorStatus) error { + + // Check the validator status on the megapool + validatorInfo, exists := state.MegapoolValidatorInfo[request.Pubkey] + if !exists { + return fmt.Errorf("validator %s not found in the megapool validator info map", request.Pubkey.Hex()) + } + if !validatorInfo.ValidatorInfo.Staked { + return fmt.Errorf("megapool %s validator %d is not staked", request.MegapoolAddress.Hex(), request.ValidatorId) + } + + // Get the validator private key + validatorKey, err := t.w.GetValidatorKeyByPubkey(request.Pubkey) + if err != nil { + return err + } + + // Get beacon head + head, err := t.bc.GetBeaconHead() + if err != nil { + return err + } + + // Get voluntary exit signature domain + signatureDomain, err := t.bc.GetDomainData(eth2types.DomainVoluntaryExit[:], head.Epoch, false) + if err != nil { + return err + } + + // Get signed voluntary exit message + signature, err := rpvalidator.GetSignedExitMessage(validatorKey, status.Index, head.Epoch, signatureDomain) + if err != nil { + return err + } + + // Broadcast voluntary exit message + if err := t.bc.ExitValidator(status.Index, head.Epoch, signature); err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully submitted a voluntary exit for validator %s (megapool %s).", status.Index, request.MegapoolAddress.Hex()) + + // Return + return nil +} + +func (t *checkMegapoolExitRequests) forceExitMegapoolValidator(mp megapool.MegapoolV2, request network.MegapoolExitRequest) error { + + // Get transactor + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + + // Get the gas limit + gasInfo, err := network.EstimateForceMegapoolExitGas(t.rp, request.MegapoolAddress, request.ValidatorId, opts) + if err != nil { + return fmt.Errorf("could not estimate the gas required to force exit megapool %s validator %d: %w", request.MegapoolAddress.Hex(), request.ValidatorId, err) + } + var gas *big.Int + if t.gasLimit != 0 { + gas = new(big.Int).SetUint64(t.gasLimit) + } else { + gas = new(big.Int).SetUint64(gasInfo.SafeGasLimit) + } + + // Get the max fee + maxFee := t.maxFee + if maxFee == nil || maxFee.Uint64() == 0 { + maxFee, err = rpgas.GetHeadlessMaxFeeWeiWithLatestBlock(t.cfg, t.rp) + if err != nil { + return err + } + } + + // Print the gas info + if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + return nil + } + + opts.GasFeeCap = maxFee + opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) + opts.GasLimit = gas.Uint64() + + // Force exit the validator via the megapool contract + hash, err := network.ForceMegapoolExit(t.rp, request.MegapoolAddress, request.ValidatorId, opts) + if err != nil { + return err + } + + // Print TX info and wait for it to be included in a block + err = api.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) + if err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully submitted ForceExit for megapool %s validator %d.", request.MegapoolAddress.Hex(), request.ValidatorId) + + // Return + return nil +} + +func (t *checkMegapoolExitRequests) penaliseMegapoolValidator(request network.MegapoolExitRequest) error { + + // Get transactor + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + + // Get the gas limit + gasInfo, err := network.EstimatePenaliseMegapoolValidatorGas(t.rp, request.MegapoolAddress, request.ValidatorId, opts) + if err != nil { + return fmt.Errorf("could not estimate the gas required to penalise megapool %s validator %d: %w", request.MegapoolAddress.Hex(), request.ValidatorId, err) + } + gas := big.NewInt(int64(gasInfo.SafeGasLimit)) + + // Get the max fee + maxFee := t.maxFee + if maxFee == nil || maxFee.Uint64() == 0 { + maxFee, err = rpgas.GetHeadlessMaxFeeWeiWithLatestBlock(t.cfg, t.rp) + if err != nil { + return err + } + } + + // Print the gas info + if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + return nil + } + + opts.GasFeeCap = maxFee + opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) + opts.GasLimit = gas.Uint64() + + // Penalise the megapool validator for failing to exit within the cooperative phase + hash, err := network.PenaliseMegapoolValidator(t.rp, request.MegapoolAddress, request.ValidatorId, opts) + if err != nil { + return err + } + + // Print TX info and wait for it to be included in a block + err = api.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) + if err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully penalised megapool %s (validator %d) for not exiting.", request.MegapoolAddress.Hex(), request.ValidatorId) + + // Return + return nil +} diff --git a/rocketpool/node/node.go b/rocketpool/node/node.go index c845e1a78..59197a8c3 100644 --- a/rocketpool/node/node.go +++ b/rocketpool/node/node.go @@ -54,6 +54,7 @@ const ( NotifyValidatorExitColor = color.FgHiYellow NotifyFinalBalanceColor = color.FgHiMagenta CheckMinipoolExitRequestsColor = color.FgHiCyan + CheckMegapoolExitRequestsColor = color.FgCyan DefendChallengeExitColor = color.FgHiGreen DefendChallengePerformanceColor = color.FgHiBlue ProvisionExpressTickets = color.FgMagenta @@ -237,6 +238,10 @@ func run(c *cli.Command) error { if err != nil { return err } + checkMegapoolExitRequests, err := newCheckMegapoolExitRequests(c, log.NewColorLogger(CheckMegapoolExitRequestsColor)) + if err != nil { + return err + } downloadRewardsTrees, err := newDownloadRewardsTrees(c, log.NewColorLogger(DownloadRewardsTreesColor)) if err != nil { return err @@ -451,6 +456,14 @@ func run(c *cli.Command) error { return } + // Run the megapool exit request check + if err := checkMegapoolExitRequests.run(state); err != nil { + errorLog.Println(err) + } + if !sleepWithContext(ctx, taskCooldown) { + return + } + // Run the megapool provision express ticket check if err := provisionExpressTickets.run(state); err != nil { errorLog.Println(err) From 062024e5448537bd34b79d96ae7a0197f8c3214e Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:22:59 -0300 Subject: [PATCH 27/35] Adapt to utils refactor --- bindings/megapool/megapool-contract-v2.go | 5 ++- bindings/minipool/minipool-contract-v4.go | 40 ++++++++++--------- bindings/network/exit.go | 31 +++++++------- .../node/check-megapool-exit-requests.go | 28 ++++++------- .../node/check-minipool-exit-requests.go | 26 ++++++------ 5 files changed, 67 insertions(+), 63 deletions(-) diff --git a/bindings/megapool/megapool-contract-v2.go b/bindings/megapool/megapool-contract-v2.go index fcafb156a..a1c900f39 100644 --- a/bindings/megapool/megapool-contract-v2.go +++ b/bindings/megapool/megapool-contract-v2.go @@ -9,11 +9,12 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" ) type MegapoolV2 interface { Megapool - EstimateForceExitGas(validatorIds []uint32, feeLimit *big.Int, opts *bind.TransactOpts) (rocketpool.GasInfo, error) + EstimateForceExitGas(validatorIds []uint32, feeLimit *big.Int, opts *bind.TransactOpts) (gaslimit.Limits, error) ForceExit(validatorIds []uint32, feeLimit *big.Int, opts *bind.TransactOpts) (common.Hash, error) } @@ -58,7 +59,7 @@ func NewMegaPoolV2(rp *rocketpool.RocketPool, address common.Address, opts *bind } // Estimate the gas of ForceExit -func (mp *megapoolV2) EstimateForceExitGas(validatorIds []uint32, feeLimit *big.Int, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func (mp *megapoolV2) EstimateForceExitGas(validatorIds []uint32, feeLimit *big.Int, opts *bind.TransactOpts) (gaslimit.Limits, error) { return mp.Contract.GetTransactionGasInfo(opts, "forceExit", validatorIds, feeLimit) } diff --git a/bindings/minipool/minipool-contract-v4.go b/bindings/minipool/minipool-contract-v4.go index 2274b5aad..0fbc5d63a 100644 --- a/bindings/minipool/minipool-contract-v4.go +++ b/bindings/minipool/minipool-contract-v4.go @@ -13,10 +13,12 @@ import ( "github.com/ethereum/go-ethereum/common" "golang.org/x/sync/errgroup" + "github.com/rocket-pool/smartnode/bindings/logs" "github.com/rocket-pool/smartnode/bindings/rocketpool" "github.com/rocket-pool/smartnode/bindings/storage" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" rptypes "github.com/rocket-pool/smartnode/bindings/types" - "github.com/rocket-pool/smartnode/bindings/utils/eth" + "github.com/rocket-pool/smartnode/shared/math" ) const ( @@ -25,17 +27,17 @@ const ( type MinipoolV4 interface { Minipool - EstimateReduceBondAmountGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) + EstimateReduceBondAmountGas(opts *bind.TransactOpts) (gaslimit.Limits, error) ReduceBondAmount(opts *bind.TransactOpts) (common.Hash, error) - EstimatePromoteGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) + EstimatePromoteGas(opts *bind.TransactOpts) (gaslimit.Limits, error) Promote(opts *bind.TransactOpts) (common.Hash, error) GetPreMigrationBalance(opts *bind.CallOpts) (*big.Int, error) GetUserDistributed(opts *bind.CallOpts) (bool, error) - EstimateDistributeBalanceGas(rewardsOnly bool, opts *bind.TransactOpts) (rocketpool.GasInfo, error) + EstimateDistributeBalanceGas(rewardsOnly bool, opts *bind.TransactOpts) (gaslimit.Limits, error) DistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (common.Hash, error) PrepareDistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (*types.Transaction, error) ForceExit(opts *bind.TransactOpts) (common.Hash, error) - EstimateForceExitGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) + EstimateForceExitGas(opts *bind.TransactOpts) (gaslimit.Limits, error) } // Minipool contract @@ -248,7 +250,7 @@ func (mp *minipool_v4) GetNodeFee(opts *bind.CallOpts) (float64, error) { if err := mp.Contract.Call(opts, nodeFee, "getNodeFee"); err != nil { return 0, fmt.Errorf("error getting minipool %s node fee: %w", mp.Address.Hex(), err) } - return eth.WeiToEth(*nodeFee), nil + return math.WeiToEth(*nodeFee), nil } func (mp *minipool_v4) GetNodeFeeRaw(opts *bind.CallOpts) (*big.Int, error) { nodeFee := new(*big.Int) @@ -355,7 +357,7 @@ func (mp *minipool_v4) GetUserDepositAssignedTime(opts *bind.CallOpts) (time.Tim } // Estimate the gas of Refund -func (mp *minipool_v4) EstimateRefundGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func (mp *minipool_v4) EstimateRefundGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { return mp.Contract.GetTransactionGasInfo(opts, "refund") } @@ -378,7 +380,7 @@ func (mp *minipool_v4) GetUserDistributed(opts *bind.CallOpts) (bool, error) { } // Estimate the gas of DistributeBalance -func (mp *minipool_v4) EstimateDistributeBalanceGas(rewardsOnly bool, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func (mp *minipool_v4) EstimateDistributeBalanceGas(rewardsOnly bool, opts *bind.TransactOpts) (gaslimit.Limits, error) { return mp.Contract.GetTransactionGasInfo(opts, "distributeBalance", rewardsOnly) } @@ -406,7 +408,7 @@ func (mp *minipool_v4) PrepareDistributeBalance(rewardsOnly bool, opts *bind.Tra } // Estimate the gas of Stake -func (mp *minipool_v4) EstimateStakeGas(validatorSignature rptypes.ValidatorSignature, depositDataRoot common.Hash, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func (mp *minipool_v4) EstimateStakeGas(validatorSignature rptypes.ValidatorSignature, depositDataRoot common.Hash, opts *bind.TransactOpts) (gaslimit.Limits, error) { return mp.Contract.GetTransactionGasInfo(opts, "stake", validatorSignature[:], depositDataRoot) } @@ -420,7 +422,7 @@ func (mp *minipool_v4) Stake(validatorSignature rptypes.ValidatorSignature, depo } // Estimate the gas of Dissolve -func (mp *minipool_v4) EstimateDissolveGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func (mp *minipool_v4) EstimateDissolveGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { return mp.Contract.GetTransactionGasInfo(opts, "dissolve") } @@ -434,7 +436,7 @@ func (mp *minipool_v4) Dissolve(opts *bind.TransactOpts) (common.Hash, error) { } // Estimate the gas of Close -func (mp *minipool_v4) EstimateCloseGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func (mp *minipool_v4) EstimateCloseGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { return mp.Contract.GetTransactionGasInfo(opts, "close") } @@ -448,7 +450,7 @@ func (mp *minipool_v4) Close(opts *bind.TransactOpts) (common.Hash, error) { } // Estimate the gas of Finalise -func (mp *minipool_v4) EstimateFinaliseGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func (mp *minipool_v4) EstimateFinaliseGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { return mp.Contract.GetTransactionGasInfo(opts, "finalise") } @@ -462,7 +464,7 @@ func (mp *minipool_v4) Finalise(opts *bind.TransactOpts) (common.Hash, error) { } // Estimate the gas of DelegateUpgrade -func (mp *minipool_v4) EstimateDelegateUpgradeGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func (mp *minipool_v4) EstimateDelegateUpgradeGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { return mp.Contract.GetTransactionGasInfo(opts, "delegateUpgrade") } @@ -476,7 +478,7 @@ func (mp *minipool_v4) DelegateUpgrade(opts *bind.TransactOpts) (common.Hash, er } // Estimate the gas of SetUseLatestDelegate -func (mp *minipool_v4) EstimateSetUseLatestDelegateGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func (mp *minipool_v4) EstimateSetUseLatestDelegateGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { return mp.Contract.GetTransactionGasInfo(opts, "setUseLatestDelegate", true) } @@ -526,7 +528,7 @@ func (mp *minipool_v4) GetEffectiveDelegate(opts *bind.CallOpts) (common.Address } // Estimate the gas required to reduce a minipool's bond -func (mp *minipool_v4) EstimateReduceBondAmountGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func (mp *minipool_v4) EstimateReduceBondAmountGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { return mp.Contract.GetTransactionGasInfo(opts, "reduceBondAmount") } @@ -558,7 +560,7 @@ func (mp *minipool_v4) CalculateUserShare(balance *big.Int, opts *bind.CallOpts) } // Estimate the gas required to vote to scrub a minipool -func (mp *minipool_v4) EstimateVoteScrubGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func (mp *minipool_v4) EstimateVoteScrubGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { return mp.Contract.GetTransactionGasInfo(opts, "voteScrub") } @@ -572,7 +574,7 @@ func (mp *minipool_v4) VoteScrub(opts *bind.TransactOpts) (common.Hash, error) { } // Estimate the gas required to promote a vacant minipool -func (mp *minipool_v4) EstimatePromoteGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func (mp *minipool_v4) EstimatePromoteGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { return mp.Contract.GetTransactionGasInfo(opts, "promote") } @@ -614,7 +616,7 @@ func (mp *minipool_v4) GetPrestakeEvent(intervalSize *big.Int, opts *bind.CallOp fromBig := big.NewInt(0).SetUint64(from) toBig := big.NewInt(0).SetUint64(i) - logs, err := eth.GetLogs(mp.RocketPool, addressFilter, topicFilter, intervalSize, fromBig, toBig, nil) + logs, err := logs.GetLogs(mp.RocketPool, addressFilter, topicFilter, intervalSize, fromBig, toBig, nil) if err != nil { return PrestakeData{}, fmt.Errorf("Error getting prestake logs for minipool %s: %w", mp.Address.Hex(), err) } @@ -651,7 +653,7 @@ func (mp *minipool_v4) GetPrestakeEvent(intervalSize *big.Int, opts *bind.CallOp } // Estimate the gas required to force exit a minipool -func (mp *minipool_v4) EstimateForceExitGas(opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func (mp *minipool_v4) EstimateForceExitGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { return mp.Contract.GetTransactionGasInfo(opts, "forceExit") } diff --git a/bindings/network/exit.go b/bindings/network/exit.go index 6727e89bb..72b4a7730 100644 --- a/bindings/network/exit.go +++ b/bindings/network/exit.go @@ -10,10 +10,11 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/bindings/logs" "github.com/rocket-pool/smartnode/bindings/megapool" "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" "github.com/rocket-pool/smartnode/bindings/types" - "github.com/rocket-pool/smartnode/bindings/utils/eth" ) // A MinipoolExitRequested event from rocketNetworkExit @@ -86,10 +87,10 @@ func GetMinipoolLastExit(rp *rocketpool.RocketPool, minipoolAddress common.Addre } // Estimate the gas of RequestMinipoolExit -func EstimateRequestMinipoolExitGas(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateRequestMinipoolExitGas(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkExit, err := getRocketNetworkExit(rp, nil) if err != nil { - return rocketpool.GasInfo{}, err + return gaslimit.Limits{}, err } return rocketNetworkExit.GetTransactionGasInfo(opts, "requestMinipoolExit", minipoolAddress) } @@ -108,10 +109,10 @@ func RequestMinipoolExit(rp *rocketpool.RocketPool, minipoolAddress common.Addre } // Estimate the gas of ForceMinipoolExit -func EstimateForceMinipoolExitGas(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateForceMinipoolExitGas(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkExit, err := getRocketNetworkExit(rp, nil) if err != nil { - return rocketpool.GasInfo{}, err + return gaslimit.Limits{}, err } return rocketNetworkExit.GetTransactionGasInfo(opts, "forceMinipoolExit", minipoolAddress) } @@ -130,10 +131,10 @@ func ForceMinipoolExit(rp *rocketpool.RocketPool, minipoolAddress common.Address } // Estimate the gas of PenaliseMinipool -func EstimatePenaliseMinipoolGas(rp *rocketpool.RocketPool, minipoolAddress common.Address, slotTimestamp uint64, validatorProof megapool.ValidatorProof, slotProof megapool.SlotProof, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimatePenaliseMinipoolGas(rp *rocketpool.RocketPool, minipoolAddress common.Address, slotTimestamp uint64, validatorProof megapool.ValidatorProof, slotProof megapool.SlotProof, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkExit, err := getRocketNetworkExit(rp, nil) if err != nil { - return rocketpool.GasInfo{}, err + return gaslimit.Limits{}, err } return rocketNetworkExit.GetTransactionGasInfo(opts, "penaliseMinipool", minipoolAddress, slotTimestamp, validatorProof, slotProof) } @@ -152,10 +153,10 @@ func PenaliseMinipool(rp *rocketpool.RocketPool, minipoolAddress common.Address, } // Estimate the gas of RequestMegapoolExit -func EstimateRequestMegapoolExitGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateRequestMegapoolExitGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkExit, err := getRocketNetworkExit(rp, nil) if err != nil { - return rocketpool.GasInfo{}, err + return gaslimit.Limits{}, err } return rocketNetworkExit.GetTransactionGasInfo(opts, "requestMegapoolExit", megapoolAddress, validatorId) } @@ -174,10 +175,10 @@ func RequestMegapoolExit(rp *rocketpool.RocketPool, megapoolAddress common.Addre } // Estimate the gas of ForceMegapoolExit -func EstimateForceMegapoolExitGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimateForceMegapoolExitGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkExit, err := getRocketNetworkExit(rp, nil) if err != nil { - return rocketpool.GasInfo{}, err + return gaslimit.Limits{}, err } return rocketNetworkExit.GetTransactionGasInfo(opts, "forceMegapoolExit", megapoolAddress, validatorId) } @@ -196,10 +197,10 @@ func ForceMegapoolExit(rp *rocketpool.RocketPool, megapoolAddress common.Address } // Estimate the gas of PenaliseMegapoolValidator -func EstimatePenaliseMegapoolValidatorGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { +func EstimatePenaliseMegapoolValidatorGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkExit, err := getRocketNetworkExit(rp, nil) if err != nil { - return rocketpool.GasInfo{}, err + return gaslimit.Limits{}, err } return rocketNetworkExit.GetTransactionGasInfo(opts, "penaliseMegapool", megapoolAddress, validatorId) } @@ -232,7 +233,7 @@ func GetMinipoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, f addressFilter := []common.Address{*rocketNetworkExit.Address} topicFilter := [][]common.Hash{{minipoolExitRequestedEvent.ID}} - logs, err := eth.GetLogs(rp, addressFilter, topicFilter, intervalSize, fromBlock, toBlock, nil) + logs, err := logs.GetLogs(rp, addressFilter, topicFilter, intervalSize, fromBlock, toBlock, nil) if err != nil { return nil, err } @@ -295,7 +296,7 @@ func GetMegapoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, f addressFilter := []common.Address{*rocketNetworkExit.Address} topicFilter := [][]common.Hash{{megapoolExitRequestedEvent.ID}} - logs, err := eth.GetLogs(rp, addressFilter, topicFilter, intervalSize, fromBlock, toBlock, nil) + logs, err := logs.GetLogs(rp, addressFilter, topicFilter, intervalSize, fromBlock, toBlock, nil) if err != nil { return nil, err } diff --git a/rocketpool/node/check-megapool-exit-requests.go b/rocketpool/node/check-megapool-exit-requests.go index 9e4cc9613..8dddf93fb 100644 --- a/rocketpool/node/check-megapool-exit-requests.go +++ b/rocketpool/node/check-megapool-exit-requests.go @@ -15,17 +15,17 @@ import ( "github.com/rocket-pool/smartnode/bindings/network" "github.com/rocket-pool/smartnode/bindings/rocketpool" "github.com/rocket-pool/smartnode/bindings/settings/protocol" - "github.com/rocket-pool/smartnode/bindings/utils/eth" + "github.com/rocket-pool/smartnode/bindings/transactions" + rpvalidator "github.com/rocket-pool/smartnode/rocketpool/validator" + log "github.com/rocket-pool/smartnode/shared/logger" + "github.com/rocket-pool/smartnode/shared/math" "github.com/rocket-pool/smartnode/shared/services" "github.com/rocket-pool/smartnode/shared/services/beacon" "github.com/rocket-pool/smartnode/shared/services/config" rpgas "github.com/rocket-pool/smartnode/shared/services/gas" "github.com/rocket-pool/smartnode/shared/services/state" "github.com/rocket-pool/smartnode/shared/services/wallet" - "github.com/rocket-pool/smartnode/shared/utils/api" - "github.com/rocket-pool/smartnode/shared/utils/log" - rpvalidator "github.com/rocket-pool/smartnode/shared/utils/validator" ) // Check megapool exit requests task @@ -77,7 +77,7 @@ func newCheckMegapoolExitRequests(c *cli.Command, logger log.ColorLogger) (*chec if maxFeeGwei == 0 { maxFee = nil } else { - maxFee = eth.GweiToWei(maxFeeGwei) + maxFee = math.GweiToWei(maxFeeGwei) } // Get the user-requested priority fee @@ -85,9 +85,9 @@ func newCheckMegapoolExitRequests(c *cli.Command, logger log.ColorLogger) (*chec var priorityFee *big.Int if priorityFeeGwei == 0 { logger.Printlnf("WARNING: priority fee was missing or 0, setting a default of %.2f.", rpgas.DefaultPriorityFeeGwei) - priorityFee = eth.GweiToWei(rpgas.DefaultPriorityFeeGwei) + priorityFee = math.GweiToWei(rpgas.DefaultPriorityFeeGwei) } else { - priorityFee = eth.GweiToWei(priorityFeeGwei) + priorityFee = math.GweiToWei(priorityFeeGwei) } // Get the event log interval @@ -240,7 +240,7 @@ func (t *checkMegapoolExitRequests) run(state *state.NetworkState) error { func (t *checkMegapoolExitRequests) exitOwnMegapoolValidator(state *state.NetworkState, request network.MegapoolExitRequest, status beacon.ValidatorStatus) error { // Check the validator status on the megapool - validatorInfo, exists := state.MegapoolValidatorInfo[request.Pubkey] + validatorInfo, exists := state.GetMegapoolValidatorInfo(request.MegapoolAddress, request.Pubkey) if !exists { return fmt.Errorf("validator %s not found in the megapool validator info map", request.Pubkey.Hex()) } @@ -301,7 +301,7 @@ func (t *checkMegapoolExitRequests) forceExitMegapoolValidator(mp megapool.Megap if t.gasLimit != 0 { gas = new(big.Int).SetUint64(t.gasLimit) } else { - gas = new(big.Int).SetUint64(gasInfo.SafeGasLimit) + gas = new(big.Int).SetUint64(gasInfo.Safe) } // Get the max fee @@ -314,7 +314,7 @@ func (t *checkMegapoolExitRequests) forceExitMegapoolValidator(mp megapool.Megap } // Print the gas info - if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + if !gasInfo.PrintAndCheck(true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { return nil } @@ -329,7 +329,7 @@ func (t *checkMegapoolExitRequests) forceExitMegapoolValidator(mp megapool.Megap } // Print TX info and wait for it to be included in a block - err = api.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) + err = transactions.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) if err != nil { return err } @@ -354,7 +354,7 @@ func (t *checkMegapoolExitRequests) penaliseMegapoolValidator(request network.Me if err != nil { return fmt.Errorf("could not estimate the gas required to penalise megapool %s validator %d: %w", request.MegapoolAddress.Hex(), request.ValidatorId, err) } - gas := big.NewInt(int64(gasInfo.SafeGasLimit)) + gas := big.NewInt(int64(gasInfo.Safe)) // Get the max fee maxFee := t.maxFee @@ -366,7 +366,7 @@ func (t *checkMegapoolExitRequests) penaliseMegapoolValidator(request network.Me } // Print the gas info - if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + if !gasInfo.PrintAndCheck(true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { return nil } @@ -381,7 +381,7 @@ func (t *checkMegapoolExitRequests) penaliseMegapoolValidator(request network.Me } // Print TX info and wait for it to be included in a block - err = api.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) + err = transactions.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) if err != nil { return err } diff --git a/rocketpool/node/check-minipool-exit-requests.go b/rocketpool/node/check-minipool-exit-requests.go index 1deeac188..e1b4e02a4 100644 --- a/rocketpool/node/check-minipool-exit-requests.go +++ b/rocketpool/node/check-minipool-exit-requests.go @@ -15,10 +15,13 @@ import ( "github.com/rocket-pool/smartnode/bindings/network" "github.com/rocket-pool/smartnode/bindings/rocketpool" "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/bindings/transactions" "github.com/rocket-pool/smartnode/bindings/types" - "github.com/rocket-pool/smartnode/bindings/utils/eth" rpstate "github.com/rocket-pool/smartnode/bindings/utils/state" + rpvalidator "github.com/rocket-pool/smartnode/rocketpool/validator" + log "github.com/rocket-pool/smartnode/shared/logger" + "github.com/rocket-pool/smartnode/shared/math" "github.com/rocket-pool/smartnode/shared/services" "github.com/rocket-pool/smartnode/shared/services/beacon" "github.com/rocket-pool/smartnode/shared/services/config" @@ -26,9 +29,6 @@ import ( "github.com/rocket-pool/smartnode/shared/services/state" "github.com/rocket-pool/smartnode/shared/services/wallet" "github.com/rocket-pool/smartnode/shared/types/eth2" - "github.com/rocket-pool/smartnode/shared/utils/api" - "github.com/rocket-pool/smartnode/shared/utils/log" - rpvalidator "github.com/rocket-pool/smartnode/shared/utils/validator" ) // A minipool validator that did not exit within the cooperative exit phase @@ -87,7 +87,7 @@ func newCheckMinipoolExitRequests(c *cli.Command, logger log.ColorLogger) (*chec if maxFeeGwei == 0 { maxFee = nil } else { - maxFee = eth.GweiToWei(maxFeeGwei) + maxFee = math.GweiToWei(maxFeeGwei) } // Get the user-requested priority fee @@ -95,9 +95,9 @@ func newCheckMinipoolExitRequests(c *cli.Command, logger log.ColorLogger) (*chec var priorityFee *big.Int if priorityFeeGwei == 0 { logger.Printlnf("WARNING: priority fee was missing or 0, setting a default of %.2f.", rpgas.DefaultPriorityFeeGwei) - priorityFee = eth.GweiToWei(rpgas.DefaultPriorityFeeGwei) + priorityFee = math.GweiToWei(rpgas.DefaultPriorityFeeGwei) } else { - priorityFee = eth.GweiToWei(priorityFeeGwei) + priorityFee = math.GweiToWei(priorityFeeGwei) } // Get the event log interval @@ -325,7 +325,7 @@ func (t *checkMinipoolExitRequests) forceExitMinipool(mpd *rpstate.NativeMinipoo if t.gasLimit != 0 { gas = new(big.Int).SetUint64(t.gasLimit) } else { - gas = new(big.Int).SetUint64(gasInfo.SafeGasLimit) + gas = new(big.Int).SetUint64(gasInfo.Safe) } // Get the max fee @@ -338,7 +338,7 @@ func (t *checkMinipoolExitRequests) forceExitMinipool(mpd *rpstate.NativeMinipoo } // Print the gas info - if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + if !gasInfo.PrintAndCheck(true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { return nil } @@ -353,7 +353,7 @@ func (t *checkMinipoolExitRequests) forceExitMinipool(mpd *rpstate.NativeMinipoo } // Print TX info and wait for it to be included in a block - err = api.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) + err = transactions.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) if err != nil { return err } @@ -390,7 +390,7 @@ func (t *checkMinipoolExitRequests) proveDidNotExit(beaconState eth2.BeaconState t.log.Printlnf("Could not estimate the gas required to penalise minipool %s: %s", validator.minipoolAddress.Hex(), err.Error()) return err } - gas := big.NewInt(int64(gasInfo.SafeGasLimit)) + gas := big.NewInt(int64(gasInfo.Safe)) // Get the max fee maxFee := t.maxFee if maxFee == nil || maxFee.Uint64() == 0 { @@ -401,7 +401,7 @@ func (t *checkMinipoolExitRequests) proveDidNotExit(beaconState eth2.BeaconState } // Print the gas info - if !api.PrintAndCheckGasInfo(gasInfo, true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + if !gasInfo.PrintAndCheck(true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { return nil } @@ -416,7 +416,7 @@ func (t *checkMinipoolExitRequests) proveDidNotExit(beaconState eth2.BeaconState } // Print TX info and wait for it to be included in a block - err = api.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) + err = transactions.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) if err != nil { return err } From e90e9323d1ca908d42ae3296ac18c62438bb39be Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:52:57 -0300 Subject: [PATCH 28/35] Review pdao params --- bindings/settings/protocol/exit.go | 61 ++++++++---- bindings/settings/protocol/megapool.go | 23 +++++ bindings/settings/protocol/performance.go | 20 ++-- rocketpool-cli/pdao/commands.go | 97 ++++++++++++++++--- rocketpool-cli/pdao/get-settings.go | 10 +- rocketpool-cli/pdao/propose-settings.go | 24 +++-- rocketpool/api/pdao/get-settings.go | 18 +++- rocketpool/api/pdao/propose-settings.go | 90 ++++++++++++----- .../performance/target-performance.go | 41 +++----- .../performance/target-performance_test.go | 32 +----- shared/types/api/pdao.go | 10 +- 11 files changed, 289 insertions(+), 137 deletions(-) diff --git a/bindings/settings/protocol/exit.go b/bindings/settings/protocol/exit.go index b546e4f2d..19b507797 100644 --- a/bindings/settings/protocol/exit.go +++ b/bindings/settings/protocol/exit.go @@ -17,10 +17,11 @@ import ( // Config const ( - ExitSettingsContractName string = "rocketDAOProtocolSettingsExit" - CooperativeExitPhaseSettingPath string = "cooperative.exit.phase" - DidNotExitPenaltySettingPath string = "did.not.exit.penalty" - DidNotExitCooldownSettingPath string = "did.not.exit.cooldown" + ExitSettingsContractName string = "rocketDAOProtocolSettingsExit" + CooperativeExitPhaseSettingPath string = "cooperative.exit.phase" + DidNotExitPenaltyBaseSettingPath string = "did.not.exit.penalty.base" + DidNotExitBaseSettingPath string = "did.not.exit.base" + DidNotExitBackoffSettingPath string = "did.not.exit.backoff" ) // Minimum time a validator must remain exit-requested before triggered exit or penalty (hours) @@ -42,42 +43,62 @@ func EstimateProposeCooperativeExitPhaseGas(rp *rocketpool.RocketPool, value *bi return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", CooperativeExitPhaseSettingPath), ExitSettingsContractName, CooperativeExitPhaseSettingPath, value, blockNumber, treeNodes, opts) } -// Penalty applied to a minipool that fails to exit when requested -func GetDidNotExitPenalty(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { +// Base penalty applied to a minipool that fails to exit when requested +func GetDidNotExitPenaltyBase(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { exitSettingsContract, err := getExitSettingsContract(rp, opts) if err != nil { return nil, err } value := new(*big.Int) - if err := exitSettingsContract.Call(opts, value, "getDidNotExitPenalty"); err != nil { - return nil, fmt.Errorf("error getting did not exit penalty: %w", err) + if err := exitSettingsContract.Call(opts, value, "getDidNotExitPenaltyBase"); err != nil { + return nil, fmt.Errorf("error getting did not exit penalty base: %w", err) } return *value, nil } -func ProposeDidNotExitPenalty(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { - return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", DidNotExitPenaltySettingPath), ExitSettingsContractName, DidNotExitPenaltySettingPath, value, blockNumber, treeNodes, opts) +func ProposeDidNotExitPenaltyBase(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", DidNotExitPenaltyBaseSettingPath), ExitSettingsContractName, DidNotExitPenaltyBaseSettingPath, value, blockNumber, treeNodes, opts) } -func EstimateProposeDidNotExitPenaltyGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { - return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", DidNotExitPenaltySettingPath), ExitSettingsContractName, DidNotExitPenaltySettingPath, value, blockNumber, treeNodes, opts) +func EstimateProposeDidNotExitPenaltyBaseGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", DidNotExitPenaltyBaseSettingPath), ExitSettingsContractName, DidNotExitPenaltyBaseSettingPath, value, blockNumber, treeNodes, opts) } -// Minimum time before a validator can be exit-requested again after a failed exit penalty (days) -func GetDidNotExitCooldown(rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { +// Initial window between consecutive did-not-exit penalties (days) +func GetDidNotExitBase(rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { exitSettingsContract, err := getExitSettingsContract(rp, opts) if err != nil { return 0, err } value := new(*big.Int) - if err := exitSettingsContract.Call(opts, value, "getDidNotExitCooldown"); err != nil { - return 0, fmt.Errorf("error getting did not exit cooldown: %w", err) + if err := exitSettingsContract.Call(opts, value, "getDidNotExitBase"); err != nil { + return 0, fmt.Errorf("error getting did not exit base: %w", err) } return time.Duration((*value).Int64()) * 24 * time.Hour, nil } -func ProposeDidNotExitCooldown(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { - return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", DidNotExitCooldownSettingPath), ExitSettingsContractName, DidNotExitCooldownSettingPath, value, blockNumber, treeNodes, opts) +func ProposeDidNotExitBase(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", DidNotExitBaseSettingPath), ExitSettingsContractName, DidNotExitBaseSettingPath, value, blockNumber, treeNodes, opts) } -func EstimateProposeDidNotExitCooldownGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { - return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", DidNotExitCooldownSettingPath), ExitSettingsContractName, DidNotExitCooldownSettingPath, value, blockNumber, treeNodes, opts) +func EstimateProposeDidNotExitBaseGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", DidNotExitBaseSettingPath), ExitSettingsContractName, DidNotExitBaseSettingPath, value, blockNumber, treeNodes, opts) +} + +// Multiplier applied to the penalty window on each iteration (18-decimal fixed point): +// the i-th window is did_not_exit_base * did_not_exit_backoff ** i +func GetDidNotExitBackoff(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { + exitSettingsContract, err := getExitSettingsContract(rp, opts) + if err != nil { + return nil, err + } + value := new(*big.Int) + if err := exitSettingsContract.Call(opts, value, "getDidNotExitBackoff"); err != nil { + return nil, fmt.Errorf("error getting did not exit backoff: %w", err) + } + return *value, nil +} +func ProposeDidNotExitBackoff(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", DidNotExitBackoffSettingPath), ExitSettingsContractName, DidNotExitBackoffSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposeDidNotExitBackoffGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", DidNotExitBackoffSettingPath), ExitSettingsContractName, DidNotExitBackoffSettingPath, value, blockNumber, treeNodes, opts) } // Get contracts diff --git a/bindings/settings/protocol/megapool.go b/bindings/settings/protocol/megapool.go index 7b0af9a54..4d4c37912 100644 --- a/bindings/settings/protocol/megapool.go +++ b/bindings/settings/protocol/megapool.go @@ -4,6 +4,7 @@ import ( "fmt" "math/big" "sync" + "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -25,6 +26,7 @@ const ( MegapoolUserDistributeDelayPath string = "user.distribute.delay" MegapoolUserDistributeDelayShortfallPath string = "user.distribute.delay.shortfall" MegapoolPenaltyThreshold string = "megapool.penalty.threshold" + MegapoolPrestakeChallengePeriodPath string = "prestake.challenge.period" ) // How long after an assignment a watcher must wait to dissolve a megapool validator @@ -187,6 +189,27 @@ func EstimateProposePenaltyThreshold(rp *rocketpool.RocketPool, value *big.Int, return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", MegapoolPenaltyThreshold), MegapoolSettingsContractName, MegapoolPenaltyThreshold, value, blockNumber, treeNodes, opts) } +// The window after a validator's prestake during which fraud proofs of invalid +// withdrawal credentials can be submitted, before staking without a state proof is allowed +func GetPrestakeChallengePeriod(rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { + megapoolSettingsContract, err := getMegapoolSettingsContract(rp, opts) + if err != nil { + return 0, err + } + value := new(*big.Int) + if err := megapoolSettingsContract.Call(opts, value, "getPrestakeChallengePeriod"); err != nil { + return 0, fmt.Errorf("error getting megapool prestake challenge period value: %w", err) + } + return time.Duration((*value).Int64()) * time.Hour, nil +} + +func ProposePrestakeChallengePeriod(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", MegapoolPrestakeChallengePeriodPath), MegapoolSettingsContractName, MegapoolPrestakeChallengePeriodPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposePrestakeChallengePeriod(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", MegapoolPrestakeChallengePeriodPath), MegapoolSettingsContractName, MegapoolPrestakeChallengePeriodPath, value, blockNumber, treeNodes, opts) +} + // Get contracts var megapoolSettingsContractLock sync.Mutex diff --git a/bindings/settings/protocol/performance.go b/bindings/settings/protocol/performance.go index ad9d36f60..fbd08a758 100644 --- a/bindings/settings/protocol/performance.go +++ b/bindings/settings/protocol/performance.go @@ -20,7 +20,7 @@ const ( PerformanceSettingsContractName string = "rocketDAOProtocolSettingsPerformance" PerformanceExitsEnabledSettingPath string = "performance.exits.enabled" PerformancePeriodSettingPath string = "performance.period" - PerformanceProofBufferSettingPath string = "performance.proof.buffer" + ProofBufferSettingPath string = "proof.buffer" PerformanceThresholdSettingPath string = "performance.threshold" PerformanceChallengePeriodSettingPath string = "performance.challenge.period" PerformanceChallengeBondSettingPath string = "performance.challenge.bond" @@ -64,23 +64,23 @@ func EstimateProposePerformancePeriodGas(rp *rocketpool.RocketPool, value *big.I return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformancePeriodSettingPath), PerformanceSettingsContractName, PerformancePeriodSettingPath, value, blockNumber, treeNodes, opts) } -// Time buffer to detect underperformance and generate proofs before a validator can be challenged -func GetPerformanceProofBuffer(rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { +// Buffer to detect underperformance and generate proofs before a validator can be challenged (epochs) +func GetProofBuffer(rp *rocketpool.RocketPool, opts *bind.CallOpts) (uint64, error) { performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) if err != nil { return 0, err } value := new(*big.Int) - if err := performanceSettingsContract.Call(opts, value, "getPerformanceProofBuffer"); err != nil { - return 0, fmt.Errorf("error getting performance proof buffer: %w", err) + if err := performanceSettingsContract.Call(opts, value, "getProofBuffer"); err != nil { + return 0, fmt.Errorf("error getting proof buffer: %w", err) } - return time.Duration((*value).Int64()) * time.Hour, nil + return (*value).Uint64(), nil } -func ProposePerformanceProofBuffer(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { - return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformanceProofBufferSettingPath), PerformanceSettingsContractName, PerformanceProofBufferSettingPath, value, blockNumber, treeNodes, opts) +func ProposeProofBuffer(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", ProofBufferSettingPath), PerformanceSettingsContractName, ProofBufferSettingPath, value, blockNumber, treeNodes, opts) } -func EstimateProposePerformanceProofBufferGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { - return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformanceProofBufferSettingPath), PerformanceSettingsContractName, PerformanceProofBufferSettingPath, value, blockNumber, treeNodes, opts) +func EstimateProposeProofBufferGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", ProofBufferSettingPath), PerformanceSettingsContractName, ProofBufferSettingPath, value, blockNumber, treeNodes, opts) } // Minimum target attestation timeliness percentage required to avoid exit diff --git a/rocketpool-cli/pdao/commands.go b/rocketpool-cli/pdao/commands.go index 29f098719..12656776e 100644 --- a/rocketpool-cli/pdao/commands.go +++ b/rocketpool-cli/pdao/commands.go @@ -18,6 +18,7 @@ const ( percentUsage string = "specify a percentage between 0 and 1 (e.g., '0.51' for 51%)" unboundedPercentUsage string = "specify a percentage that can go over 100% (e.g., '1.5' for 150%)" uintUsage string = "specify an integer (e.g., '50')" + floatMultiplierUsage string = "specify a multiplier (e.g., '1.5')" epochCountUsage string = "specify a number, in epochs (eg., '100')" hourCountUsage string = "specify a number, in hours (e.g., '72')" dayCountUsage string = "specify a number, in days (e.g., '28')" @@ -3311,6 +3312,39 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { }, }, + + { + Name: "prestake-challenge-period", + Aliases: []string{"pcp"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.MegapoolPrestakeChallengePeriodPath, hourCountUsage), + UsageText: "rocketpool pdao propose setting megapool prestake-challenge-period value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidatePositiveUint("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingMegapoolPrestakeChallengePeriod(value, c.Bool("yes"), c.String("to-json")) + + }, + }, }, }, @@ -3389,7 +3423,7 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { { Name: "proof-buffer", Aliases: []string{"pb"}, - Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformanceProofBufferSettingPath, durationUsage), + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.ProofBufferSettingPath, epochCountUsage), UsageText: "rocketpool pdao propose setting performance proof-buffer value", Flags: []cli.Flag{ &cli.BoolFlag{ @@ -3408,13 +3442,13 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { if err := cliutils.ValidateArgCount(c, 1); err != nil { return err } - value, err := cliutils.ValidateDuration("value", c.Args().Get(0)) + value, err := cliutils.ValidatePositiveUint("value", c.Args().Get(0)) if err != nil { return err } // Run - return proposeSettingPerformanceProofBuffer(value, c.Bool("yes"), c.String("to-json")) + return proposeSettingProofBuffer(value, c.Bool("yes"), c.String("to-json")) }, }, @@ -3568,10 +3602,10 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { }, { - Name: "did-not-exit-penalty", - Aliases: []string{"dnep"}, - Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.DidNotExitPenaltySettingPath, floatEthUsage), - UsageText: "rocketpool pdao propose setting exit did-not-exit-penalty value", + Name: "did-not-exit-penalty-base", + Aliases: []string{"dnepb"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.DidNotExitPenaltyBaseSettingPath, floatEthUsage), + UsageText: "rocketpool pdao propose setting exit did-not-exit-penalty-base value", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "raw", @@ -3599,16 +3633,16 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { } // Run - return proposeSettingDidNotExitPenalty(value, c.Bool("yes"), c.String("to-json")) + return proposeSettingDidNotExitPenaltyBase(value, c.Bool("yes"), c.String("to-json")) }, }, { - Name: "did-not-exit-cooldown", - Aliases: []string{"dnec"}, - Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.DidNotExitCooldownSettingPath, dayCountUsage), - UsageText: "rocketpool pdao propose setting exit did-not-exit-cooldown value", + Name: "did-not-exit-base", + Aliases: []string{"dneb"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.DidNotExitBaseSettingPath, dayCountUsage), + UsageText: "rocketpool pdao propose setting exit did-not-exit-base value", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "yes", @@ -3632,7 +3666,44 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { } // Run - return proposeSettingDidNotExitCooldown(value, c.Bool("yes"), c.String("to-json")) + return proposeSettingDidNotExitBase(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "did-not-exit-backoff", + Aliases: []string{"dnebo"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.DidNotExitBackoffSettingPath, floatMultiplierUsage), + UsageText: "rocketpool pdao propose setting exit did-not-exit-backoff value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "raw", + Usage: "Add this flag if your setting is an 18-decimal-fixed-point-integer (wei) value instead of a float", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateFloat(c.Bool("raw"), "value", c.Args().Get(0), false, c.Bool("yes")) + if err != nil { + return err + } + + // Run + return proposeSettingDidNotExitBackoff(value, c.Bool("yes"), c.String("to-json")) }, }, diff --git a/rocketpool-cli/pdao/get-settings.go b/rocketpool-cli/pdao/get-settings.go index c70b60a3b..30e8511e2 100644 --- a/rocketpool-cli/pdao/get-settings.go +++ b/rocketpool-cli/pdao/get-settings.go @@ -137,7 +137,7 @@ func getSettings() error { fmt.Println("== Performance Settings ==") fmt.Printf("\tPerformance Exits Enabled: %t\n", response.Performance.ExitsEnabled) fmt.Printf("\tPerformance Period: %d Epochs\n", response.Performance.Period) - fmt.Printf("\tProof Buffer: %s\n", response.Performance.ProofBuffer) + fmt.Printf("\tProof Buffer: %d Epochs\n", response.Performance.ProofBuffer) fmt.Printf("\tPerformance Threshold: %.2f%%\n", math.WeiToEth(response.Performance.Threshold)*100) fmt.Printf("\tChallenge Period: %s\n", response.Performance.ChallengePeriod) fmt.Printf("\tChallenge Bond: %.6f RPL\n", math.WeiToEth(response.Performance.ChallengeBond)) @@ -146,8 +146,9 @@ func getSettings() error { // Exit fmt.Println("== Exit Settings (RPIP-80) ==") fmt.Printf("\tCooperative Exit Phase: %.0f Hours\n", response.Exit.CooperativeExitPhase.Hours()) - fmt.Printf("\tDid Not Exit Penalty: %.6f ETH\n", math.WeiToEth(response.Exit.DidNotExitPenalty)) - fmt.Printf("\tDid Not Exit Cooldown: %s\n", response.Exit.DidNotExitCooldown) + fmt.Printf("\tDid Not Exit Penalty Base: %.6f ETH\n", math.WeiToEth(response.Exit.DidNotExitPenaltyBase)) + fmt.Printf("\tDid Not Exit Base: %s\n", response.Exit.DidNotExitBase) + fmt.Printf("\tDid Not Exit Backoff: %.2fx\n", math.WeiToEth(response.Exit.DidNotExitBackoff)) fmt.Println() } @@ -161,6 +162,9 @@ func getSettings() error { fmt.Printf("\tUser Distribute Delay: %d Epochs\n", response.Megapool.UserDistributeDelay) fmt.Printf("\tUser Distribute Delay with Shortfall: %d Epochs\n", response.Megapool.UserDistributeDelayWithShortfall) fmt.Printf("\tPenalty Threshold: %.2f%%\n", math.WeiToEth(response.Megapool.PenaltyThreshold)*100) + if response.Saturn2Deployed { + fmt.Printf("\tPrestake Challenge Period: %.0f Hours\n", response.Megapool.PrestakeChallengePeriod.Hours()) + } return nil } diff --git a/rocketpool-cli/pdao/propose-settings.go b/rocketpool-cli/pdao/propose-settings.go index 2eaeef484..2b0ae11b2 100644 --- a/rocketpool-cli/pdao/propose-settings.go +++ b/rocketpool-cli/pdao/propose-settings.go @@ -365,6 +365,11 @@ func proposeSettingPenaltyThreshold(value *big.Int, yes bool, toJson string) err return proposeSetting(protocol.MegapoolSettingsContractName, protocol.MegapoolPenaltyThreshold, trueValue, yes, toJson) } +func proposeSettingMegapoolPrestakeChallengePeriod(value uint64, yes bool, toJson string) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.MegapoolSettingsContractName, protocol.MegapoolPrestakeChallengePeriodPath, trueValue, yes, toJson) +} + func proposeSettingPerformanceExitsEnabled(value bool, yes bool, toJson string) error { trueValue := fmt.Sprint(value) return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceExitsEnabledSettingPath, trueValue, yes, toJson) @@ -375,9 +380,9 @@ func proposeSettingPerformancePeriod(value uint64, yes bool, toJson string) erro return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformancePeriodSettingPath, trueValue, yes, toJson) } -func proposeSettingPerformanceProofBuffer(value time.Duration, yes bool, toJson string) error { - trueValue := fmt.Sprint(uint64(value.Hours())) - return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceProofBufferSettingPath, trueValue, yes, toJson) +func proposeSettingProofBuffer(value uint64, yes bool, toJson string) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.ProofBufferSettingPath, trueValue, yes, toJson) } func proposeSettingPerformanceThreshold(value *big.Int, yes bool, toJson string) error { @@ -400,14 +405,19 @@ func proposeSettingCooperativeExitPhase(value uint64, yes bool, toJson string) e return proposeSetting(protocol.ExitSettingsContractName, protocol.CooperativeExitPhaseSettingPath, trueValue, yes, toJson) } -func proposeSettingDidNotExitPenalty(value *big.Int, yes bool, toJson string) error { +func proposeSettingDidNotExitPenaltyBase(value *big.Int, yes bool, toJson string) error { trueValue := value.String() - return proposeSetting(protocol.ExitSettingsContractName, protocol.DidNotExitPenaltySettingPath, trueValue, yes, toJson) + return proposeSetting(protocol.ExitSettingsContractName, protocol.DidNotExitPenaltyBaseSettingPath, trueValue, yes, toJson) } -func proposeSettingDidNotExitCooldown(value uint64, yes bool, toJson string) error { +func proposeSettingDidNotExitBase(value uint64, yes bool, toJson string) error { trueValue := fmt.Sprint(value) - return proposeSetting(protocol.ExitSettingsContractName, protocol.DidNotExitCooldownSettingPath, trueValue, yes, toJson) + return proposeSetting(protocol.ExitSettingsContractName, protocol.DidNotExitBaseSettingPath, trueValue, yes, toJson) +} + +func proposeSettingDidNotExitBackoff(value *big.Int, yes bool, toJson string) error { + trueValue := value.String() + return proposeSetting(protocol.ExitSettingsContractName, protocol.DidNotExitBackoffSettingPath, trueValue, yes, toJson) } func proposeSettingNodeCommissionShare(value *big.Int, yes bool, toJson string) error { diff --git a/rocketpool/api/pdao/get-settings.go b/rocketpool/api/pdao/get-settings.go index 3c27bf21c..4f311454b 100644 --- a/rocketpool/api/pdao/get-settings.go +++ b/rocketpool/api/pdao/get-settings.go @@ -209,7 +209,7 @@ func getSettings(c *cli.Command) (*api.GetPDAOSettingsResponse, error) { wg.Go(func() error { var err error - response.Performance.ProofBuffer, err = protocol.GetPerformanceProofBuffer(rp, nil) + response.Performance.ProofBuffer, err = protocol.GetProofBuffer(rp, nil) return err }) @@ -239,13 +239,25 @@ func getSettings(c *cli.Command) (*api.GetPDAOSettingsResponse, error) { wg.Go(func() error { var err error - response.Exit.DidNotExitPenalty, err = protocol.GetDidNotExitPenalty(rp, nil) + response.Exit.DidNotExitPenaltyBase, err = protocol.GetDidNotExitPenaltyBase(rp, nil) return err }) wg.Go(func() error { var err error - response.Exit.DidNotExitCooldown, err = protocol.GetDidNotExitCooldown(rp, nil) + response.Exit.DidNotExitBase, err = protocol.GetDidNotExitBase(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Exit.DidNotExitBackoff, err = protocol.GetDidNotExitBackoff(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Megapool.PrestakeChallengePeriod, err = protocol.GetPrestakeChallengePeriod(rp, nil) return err }) } diff --git a/rocketpool/api/pdao/propose-settings.go b/rocketpool/api/pdao/propose-settings.go index 5fce0ad5f..ee1c31e13 100644 --- a/rocketpool/api/pdao/propose-settings.go +++ b/rocketpool/api/pdao/propose-settings.go @@ -959,6 +959,16 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, if err != nil { return nil, fmt.Errorf("error estimating gas for proposing PenaltyThreshold: %w", err) } + // PrestakeChallengePeriod + case protocol.MegapoolPrestakeChallengePeriodPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = protocol.EstimateProposePrestakeChallengePeriod(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PrestakeChallengePeriod: %w", err) + } } case protocol.PerformanceSettingsContractName: @@ -985,15 +995,15 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, return nil, fmt.Errorf("error estimating gas for proposing PerformancePeriod: %w", err) } - // PerformanceProofBuffer - case protocol.PerformanceProofBufferSettingPath: + // ProofBuffer + case protocol.ProofBufferSettingPath: newValue, err := cliutils.ValidateBigInt(valueName, value) if err != nil { return nil, err } - response.GasLimits, err = protocol.EstimateProposePerformanceProofBufferGas(rp, newValue, blockNumber, pollard, opts) + response.GasLimits, err = protocol.EstimateProposeProofBufferGas(rp, newValue, blockNumber, pollard, opts) if err != nil { - return nil, fmt.Errorf("error estimating gas for proposing PerformanceProofBuffer: %w", err) + return nil, fmt.Errorf("error estimating gas for proposing ProofBuffer: %w", err) } // PerformanceThreshold @@ -1043,26 +1053,37 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, return nil, fmt.Errorf("error estimating gas for proposing CooperativeExitPhase: %w", err) } - // DidNotExitPenalty - case protocol.DidNotExitPenaltySettingPath: + // DidNotExitPenaltyBase + case protocol.DidNotExitPenaltyBaseSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = protocol.EstimateProposeDidNotExitPenaltyBaseGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing DidNotExitPenaltyBase: %w", err) + } + + // DidNotExitBase + case protocol.DidNotExitBaseSettingPath: newValue, err := cliutils.ValidateBigInt(valueName, value) if err != nil { return nil, err } - response.GasLimits, err = protocol.EstimateProposeDidNotExitPenaltyGas(rp, newValue, blockNumber, pollard, opts) + response.GasLimits, err = protocol.EstimateProposeDidNotExitBaseGas(rp, newValue, blockNumber, pollard, opts) if err != nil { - return nil, fmt.Errorf("error estimating gas for proposing DidNotExitPenalty: %w", err) + return nil, fmt.Errorf("error estimating gas for proposing DidNotExitBase: %w", err) } - // DidNotExitCooldown - case protocol.DidNotExitCooldownSettingPath: + // DidNotExitBackoff + case protocol.DidNotExitBackoffSettingPath: newValue, err := cliutils.ValidateBigInt(valueName, value) if err != nil { return nil, err } - response.GasLimits, err = protocol.EstimateProposeDidNotExitCooldownGas(rp, newValue, blockNumber, pollard, opts) + response.GasLimits, err = protocol.EstimateProposeDidNotExitBackoffGas(rp, newValue, blockNumber, pollard, opts) if err != nil { - return nil, fmt.Errorf("error estimating gas for proposing DidNotExitCooldown: %w", err) + return nil, fmt.Errorf("error estimating gas for proposing DidNotExitBackoff: %w", err) } } @@ -1951,6 +1972,16 @@ func proposeSetting(c *cli.Command, contractName string, settingName string, val if err != nil { return nil, fmt.Errorf("error proposing PenaltyThreshold: %w", err) } + // PrestakeChallengePeriod + case protocol.MegapoolPrestakeChallengePeriodPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposePrestakeChallengePeriod(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PrestakeChallengePeriod: %w", err) + } } @@ -1978,15 +2009,15 @@ func proposeSetting(c *cli.Command, contractName string, settingName string, val return nil, fmt.Errorf("error proposing PerformancePeriod: %w", err) } - // PerformanceProofBuffer - case protocol.PerformanceProofBufferSettingPath: + // ProofBuffer + case protocol.ProofBufferSettingPath: newValue, err := cliutils.ValidateBigInt(valueName, value) if err != nil { return nil, err } - proposalID, hash, err = protocol.ProposePerformanceProofBuffer(rp, newValue, blockNumber, pollard, opts) + proposalID, hash, err = protocol.ProposeProofBuffer(rp, newValue, blockNumber, pollard, opts) if err != nil { - return nil, fmt.Errorf("error proposing PerformanceProofBuffer: %w", err) + return nil, fmt.Errorf("error proposing ProofBuffer: %w", err) } // PerformanceThreshold @@ -2036,26 +2067,37 @@ func proposeSetting(c *cli.Command, contractName string, settingName string, val return nil, fmt.Errorf("error proposing CooperativeExitPhase: %w", err) } - // DidNotExitPenalty - case protocol.DidNotExitPenaltySettingPath: + // DidNotExitPenaltyBase + case protocol.DidNotExitPenaltyBaseSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposeDidNotExitPenaltyBase(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing DidNotExitPenaltyBase: %w", err) + } + + // DidNotExitBase + case protocol.DidNotExitBaseSettingPath: newValue, err := cliutils.ValidateBigInt(valueName, value) if err != nil { return nil, err } - proposalID, hash, err = protocol.ProposeDidNotExitPenalty(rp, newValue, blockNumber, pollard, opts) + proposalID, hash, err = protocol.ProposeDidNotExitBase(rp, newValue, blockNumber, pollard, opts) if err != nil { - return nil, fmt.Errorf("error proposing DidNotExitPenalty: %w", err) + return nil, fmt.Errorf("error proposing DidNotExitBase: %w", err) } - // DidNotExitCooldown - case protocol.DidNotExitCooldownSettingPath: + // DidNotExitBackoff + case protocol.DidNotExitBackoffSettingPath: newValue, err := cliutils.ValidateBigInt(valueName, value) if err != nil { return nil, err } - proposalID, hash, err = protocol.ProposeDidNotExitCooldown(rp, newValue, blockNumber, pollard, opts) + proposalID, hash, err = protocol.ProposeDidNotExitBackoff(rp, newValue, blockNumber, pollard, opts) if err != nil { - return nil, fmt.Errorf("error proposing DidNotExitCooldown: %w", err) + return nil, fmt.Errorf("error proposing DidNotExitBackoff: %w", err) } } diff --git a/shared/services/performance/target-performance.go b/shared/services/performance/target-performance.go index c6722f451..d64450360 100644 --- a/shared/services/performance/target-performance.go +++ b/shared/services/performance/target-performance.go @@ -15,7 +15,6 @@ import ( "fmt" "math/big" "strconv" - "time" "github.com/ethereum/go-ethereum/common" @@ -58,13 +57,13 @@ func GetPerformanceThresholdPct(rp *rocketpool.RocketPool) (float64, error) { // Defaults used before Saturn 2 deploys. const DefaultPerformancePeriodEpochs uint64 = 44032 -const defaultProofBuffer = 24 * time.Hour +const defaultProofBufferEpochs uint64 = 225 // ChallengeParams are the pDAO settings governing performance challenges. type ChallengeParams struct { - ExitsEnabled bool - PeriodEpochs uint64 - ProofBuffer time.Duration + ExitsEnabled bool + PeriodEpochs uint64 + ProofBufferEpochs uint64 } // GetChallengeParams fetches the pDAO performance-challenge settings, using @@ -76,9 +75,9 @@ func GetChallengeParams(rp *rocketpool.RocketPool) (ChallengeParams, error) { } if !saturn2Deployed { return ChallengeParams{ - ExitsEnabled: true, - PeriodEpochs: DefaultPerformancePeriodEpochs, - ProofBuffer: defaultProofBuffer, + ExitsEnabled: true, + PeriodEpochs: DefaultPerformancePeriodEpochs, + ProofBufferEpochs: defaultProofBufferEpochs, }, nil } exitsEnabled, err := protocol.GetPerformanceExitsEnabled(rp, nil) @@ -89,21 +88,20 @@ func GetChallengeParams(rp *rocketpool.RocketPool) (ChallengeParams, error) { if err != nil { return ChallengeParams{}, err } - proofBuffer, err := protocol.GetPerformanceProofBuffer(rp, nil) + proofBufferEpochs, err := protocol.GetProofBuffer(rp, nil) if err != nil { return ChallengeParams{}, err } return ChallengeParams{ - ExitsEnabled: exitsEnabled, - PeriodEpochs: periodEpochs, - ProofBuffer: proofBuffer, + ExitsEnabled: exitsEnabled, + PeriodEpochs: periodEpochs, + ProofBufferEpochs: proofBufferEpochs, }, nil } // challengeBeaconClient is the beacon client surface needed to evaluate the // challengeability of an epoch range. type challengeBeaconClient interface { - GetEth2Config() (beacon.Eth2Config, error) GetBeaconHead() (beacon.BeaconHead, error) } @@ -116,15 +114,11 @@ func IsRangeChallengeable(rp *rocketpool.RocketPool, bc challengeBeaconClient, s if err != nil { return false, err } - cfg, err := bc.GetEth2Config() - if err != nil { - return false, fmt.Errorf("error getting beacon config: %w", err) - } head, err := bc.GetBeaconHead() if err != nil { return false, fmt.Errorf("error getting beacon head: %w", err) } - return IsChallengeable(params, cfg, head.Epoch, startEpoch, endEpoch), nil + return IsChallengeable(params, head.Epoch, startEpoch, endEpoch), nil } // ExceedsChallengeThreshold reports whether the validator missed enough @@ -142,20 +136,15 @@ func ExceedsChallengeThreshold(resp *api.VerifyPerformanceResponse) bool { // range [startEpoch, endEpoch] could back an on-chain challenge: performance // exits must be enabled, the range must cover exactly one performance period, // and it must be recent enough that the proof buffer has not elapsed -// (startEpoch > currentEpoch - period - proofBuffer, with the buffer -// converted to epochs). -func IsChallengeable(params ChallengeParams, cfg beacon.Eth2Config, currentEpoch, startEpoch, endEpoch uint64) bool { +// (startEpoch > currentEpoch - period - proofBuffer). +func IsChallengeable(params ChallengeParams, currentEpoch, startEpoch, endEpoch uint64) bool { if !params.ExitsEnabled { return false } if endEpoch != startEpoch+params.PeriodEpochs-1 { return false } - if cfg.SecondsPerEpoch == 0 { - return false - } - proofBufferEpochs := uint64(params.ProofBuffer.Seconds()) / cfg.SecondsPerEpoch - window := params.PeriodEpochs + proofBufferEpochs + window := params.PeriodEpochs + params.ProofBufferEpochs if currentEpoch <= window { // The whole chain history is still within the challenge window. return true diff --git a/shared/services/performance/target-performance_test.go b/shared/services/performance/target-performance_test.go index 3363b8f92..3a524c8bd 100644 --- a/shared/services/performance/target-performance_test.go +++ b/shared/services/performance/target-performance_test.go @@ -4,20 +4,15 @@ import ( "math/big" "reflect" "testing" - "time" - "github.com/rocket-pool/smartnode/shared/services/beacon" "github.com/rocket-pool/smartnode/shared/types/api" ) func TestIsChallengeable(t *testing.T) { - // Mainnet timing: 32 slots * 12s = 384s per epoch, so a 24h proof buffer - // spans 86400 / 384 = 225 epochs. - cfg := beacon.Eth2Config{SecondsPerEpoch: 384} params := ChallengeParams{ - ExitsEnabled: true, - PeriodEpochs: 1000, - ProofBuffer: 24 * time.Hour, + ExitsEnabled: true, + PeriodEpochs: 1000, + ProofBufferEpochs: 225, } // The challenge window is period + proofBufferEpochs = 1225 epochs, so with // currentEpoch = 10000 the oldest challengeable start epoch is 8776. @@ -25,7 +20,6 @@ func TestIsChallengeable(t *testing.T) { tests := []struct { name string params ChallengeParams - cfg beacon.Eth2Config currentEpoch uint64 startEpoch uint64 endEpoch uint64 @@ -34,7 +28,6 @@ func TestIsChallengeable(t *testing.T) { { name: "recent full period", params: params, - cfg: cfg, currentEpoch: 10000, startEpoch: 9000, endEpoch: 9999, @@ -42,8 +35,7 @@ func TestIsChallengeable(t *testing.T) { }, { name: "exits disabled", - params: ChallengeParams{ExitsEnabled: false, PeriodEpochs: 1000, ProofBuffer: 24 * time.Hour}, - cfg: cfg, + params: ChallengeParams{ExitsEnabled: false, PeriodEpochs: 1000, ProofBufferEpochs: 225}, currentEpoch: 10000, startEpoch: 9000, endEpoch: 9999, @@ -52,7 +44,6 @@ func TestIsChallengeable(t *testing.T) { { name: "range one epoch too long", params: params, - cfg: cfg, currentEpoch: 10000, startEpoch: 9000, endEpoch: 10000, @@ -61,7 +52,6 @@ func TestIsChallengeable(t *testing.T) { { name: "range one epoch too short", params: params, - cfg: cfg, currentEpoch: 10000, startEpoch: 9000, endEpoch: 9998, @@ -70,7 +60,6 @@ func TestIsChallengeable(t *testing.T) { { name: "start epoch just inside the window", params: params, - cfg: cfg, currentEpoch: 10000, startEpoch: 8776, endEpoch: 9775, @@ -79,7 +68,6 @@ func TestIsChallengeable(t *testing.T) { { name: "start epoch at the window boundary", params: params, - cfg: cfg, currentEpoch: 10000, startEpoch: 8775, endEpoch: 9774, @@ -88,26 +76,16 @@ func TestIsChallengeable(t *testing.T) { { name: "current epoch smaller than the window", params: params, - cfg: cfg, currentEpoch: 1000, startEpoch: 0, endEpoch: 999, want: true, }, - { - name: "invalid beacon config", - params: params, - cfg: beacon.Eth2Config{}, - currentEpoch: 10000, - startEpoch: 9000, - endEpoch: 9999, - want: false, - }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := IsChallengeable(tc.params, tc.cfg, tc.currentEpoch, tc.startEpoch, tc.endEpoch) + got := IsChallengeable(tc.params, tc.currentEpoch, tc.startEpoch, tc.endEpoch) if got != tc.want { t.Errorf("IsChallengeable(%+v, current %d, [%d, %d]) = %v, want %v", tc.params, tc.currentEpoch, tc.startEpoch, tc.endEpoch, got, tc.want) diff --git a/shared/types/api/pdao.go b/shared/types/api/pdao.go index ad55074a0..2d362270d 100644 --- a/shared/types/api/pdao.go +++ b/shared/types/api/pdao.go @@ -179,21 +179,23 @@ type GetPDAOSettingsResponse struct { UserDistributeDelay uint64 `json:"userDistributeDelay"` UserDistributeDelayWithShortfall uint64 `json:"userDistributeDelayWithShortfall"` PenaltyThreshold *big.Int `json:"penaltyThreshold"` + PrestakeChallengePeriod time.Duration `json:"prestakeChallengePeriod"` } `json:"megapool"` Performance struct { ExitsEnabled bool `json:"exitsEnabled"` Period uint64 `json:"period"` - ProofBuffer time.Duration `json:"proofBuffer"` + ProofBuffer uint64 `json:"proofBuffer"` Threshold *big.Int `json:"threshold"` ChallengePeriod time.Duration `json:"challengePeriod"` ChallengeBond *big.Int `json:"challengeBond"` } `json:"performance"` Exit struct { - CooperativeExitPhase time.Duration `json:"cooperativeExitPhase"` - DidNotExitPenalty *big.Int `json:"didNotExitPenalty"` - DidNotExitCooldown time.Duration `json:"didNotExitCooldown"` + CooperativeExitPhase time.Duration `json:"cooperativeExitPhase"` + DidNotExitPenaltyBase *big.Int `json:"didNotExitPenaltyBase"` + DidNotExitBase time.Duration `json:"didNotExitBase"` + DidNotExitBackoff *big.Int `json:"didNotExitBackoff"` } `json:"exit"` } From 0d0a85656acb15e231a5463c73350f29a0bc8200 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:05:54 -0300 Subject: [PATCH 29/35] Add to-json to new settings --- bindings/settings/protocol/setting-types.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/bindings/settings/protocol/setting-types.go b/bindings/settings/protocol/setting-types.go index cd086fe48..136ab5986 100644 --- a/bindings/settings/protocol/setting-types.go +++ b/bindings/settings/protocol/setting-types.go @@ -128,6 +128,21 @@ var pdaoSettingKinds = map[string]map[string]settingKind{ MegapoolUserDistributeDelayPath: settingKindUint256, MegapoolUserDistributeDelayShortfallPath: settingKindUint256, MegapoolPenaltyThreshold: settingKindUint256, + MegapoolPrestakeChallengePeriodPath: settingKindUint256, + }, + PerformanceSettingsContractName: { + PerformanceExitsEnabledSettingPath: settingKindBool, + PerformancePeriodSettingPath: settingKindUint256, + ProofBufferSettingPath: settingKindUint256, + PerformanceThresholdSettingPath: settingKindUint256, + PerformanceChallengePeriodSettingPath: settingKindUint256, + PerformanceChallengeBondSettingPath: settingKindUint256, + }, + ExitSettingsContractName: { + CooperativeExitPhaseSettingPath: settingKindUint256, + DidNotExitPenaltyBaseSettingPath: settingKindUint256, + DidNotExitBaseSettingPath: settingKindUint256, + DidNotExitBackoffSettingPath: settingKindUint256, }, } From 2bb42679fb1bab8c4114606b343d81135c71a41b Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:16:16 -0300 Subject: [PATCH 30/35] check if saturn2 is deployed on tasks --- rocketpool/node/check-megapool-exit-requests.go | 6 ++++++ rocketpool/node/check-minipool-exit-requests.go | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/rocketpool/node/check-megapool-exit-requests.go b/rocketpool/node/check-megapool-exit-requests.go index 8dddf93fb..b89dfec79 100644 --- a/rocketpool/node/check-megapool-exit-requests.go +++ b/rocketpool/node/check-megapool-exit-requests.go @@ -116,6 +116,12 @@ func newCheckMegapoolExitRequests(c *cli.Command, logger log.ColorLogger) (*chec // Check for megapool validators that did not respond to an exit request func (t *checkMegapoolExitRequests) run(state *state.NetworkState) error { + // Check if Saturn 2 is deployed + if !state.Saturn2Deployed { + t.log.Println("Saturn 2 is not deployed, skipping megapool exit requests check.") + return nil + } + // Log t.log.Println("Checking for megapool validators that did not respond to an exit request...") diff --git a/rocketpool/node/check-minipool-exit-requests.go b/rocketpool/node/check-minipool-exit-requests.go index e1b4e02a4..000144c87 100644 --- a/rocketpool/node/check-minipool-exit-requests.go +++ b/rocketpool/node/check-minipool-exit-requests.go @@ -126,6 +126,12 @@ func newCheckMinipoolExitRequests(c *cli.Command, logger log.ColorLogger) (*chec // Check for minipool validators that did not respond to an exit request func (t *checkMinipoolExitRequests) run(state *state.NetworkState) error { + // Check if Saturn 2 is deployed + if !state.Saturn2Deployed { + t.log.Println("Saturn 2 is not deployed, skipping minipool exit requests check.") + return nil + } + // Log t.log.Println("Checking for minipool validators that did not respond to an exit request...") From a76a8e0c4f784ac63b4d3b7a817796fe2e3c8df8 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:11:28 -0300 Subject: [PATCH 31/35] Don't use challenge params pre-saturn2 --- shared/services/performance/target-performance.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/shared/services/performance/target-performance.go b/shared/services/performance/target-performance.go index d64450360..6e3da65ae 100644 --- a/shared/services/performance/target-performance.go +++ b/shared/services/performance/target-performance.go @@ -55,9 +55,9 @@ func GetPerformanceThresholdPct(rp *rocketpool.RocketPool) (float64, error) { return math.WeiToEth(thresholdWei) * 100.0, nil } -// Defaults used before Saturn 2 deploys. +// DefaultPerformancePeriodEpochs is used by verify-performance when Saturn 2 +// is not deployed and there is no on-chain performance_period to read. const DefaultPerformancePeriodEpochs uint64 = 44032 -const defaultProofBufferEpochs uint64 = 225 // ChallengeParams are the pDAO settings governing performance challenges. type ChallengeParams struct { @@ -66,19 +66,15 @@ type ChallengeParams struct { ProofBufferEpochs uint64 } -// GetChallengeParams fetches the pDAO performance-challenge settings, using -// the pre-Saturn-2 defaults when Saturn 2 is not deployed yet. +// GetChallengeParams fetches the pDAO performance-challenge settings. +// Before Saturn 2 the contracts do not exist, so challenges are disabled. func GetChallengeParams(rp *rocketpool.RocketPool) (ChallengeParams, error) { saturn2Deployed, err := state.IsSaturn2Deployed(rp, nil) if err != nil { return ChallengeParams{}, fmt.Errorf("error checking if Saturn 2 is deployed: %w", err) } if !saturn2Deployed { - return ChallengeParams{ - ExitsEnabled: true, - PeriodEpochs: DefaultPerformancePeriodEpochs, - ProofBufferEpochs: defaultProofBufferEpochs, - }, nil + return ChallengeParams{}, nil } exitsEnabled, err := protocol.GetPerformanceExitsEnabled(rp, nil) if err != nil { From a51868b332dff3cb5135f4dee8e371a017282d91 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:50:32 -0300 Subject: [PATCH 32/35] Avoid changing the flow used to calculate rewards --- .../submit-network-balances_test.go | 3 ++ shared/services/bc-manager.go | 10 +++++++ shared/services/beacon/client.go | 1 + .../services/beacon/client/std-http-client.go | 30 ++++++++++--------- .../performance/target-performance.go | 4 +-- shared/services/state/static_bc.go | 4 +++ 6 files changed, 36 insertions(+), 16 deletions(-) diff --git a/rocketpool/watchtower/submit-network-balances_test.go b/rocketpool/watchtower/submit-network-balances_test.go index 59b837d64..d6c9bc335 100644 --- a/rocketpool/watchtower/submit-network-balances_test.go +++ b/rocketpool/watchtower/submit-network-balances_test.go @@ -530,6 +530,9 @@ func (s *stubBeaconClient) GetEth1DataForEth2Block(blockId string) (beacon.Eth1D func (s *stubBeaconClient) GetCommitteesForEpoch(epoch *uint64) (beacon.Committees, error) { return nil, nil } +func (s *stubBeaconClient) GetHistoricalCommitteesForEpoch(epoch uint64) (beacon.Committees, error) { + return nil, nil +} func (s *stubBeaconClient) ChangeWithdrawalCredentials(validatorIndex string, fromBlsPubkey rptypes.ValidatorPubkey, toExecutionAddress common.Address, signature rptypes.ValidatorSignature) error { return nil } diff --git a/shared/services/bc-manager.go b/shared/services/bc-manager.go index 8a25a89c6..bb34baa84 100644 --- a/shared/services/bc-manager.go +++ b/shared/services/bc-manager.go @@ -357,6 +357,16 @@ func (m *BeaconClientManager) GetCommitteesForEpoch(epoch *uint64) (beacon.Commi return result.(beacon.Committees), nil } +func (m *BeaconClientManager) GetHistoricalCommitteesForEpoch(epoch uint64) (beacon.Committees, error) { + result, err := m.runFunction1(func(client beacon.Client) (interface{}, error) { + return client.GetHistoricalCommitteesForEpoch(epoch) + }) + if err != nil { + return nil, err + } + return result.(beacon.Committees), nil +} + // Change the withdrawal credentials for a validator func (m *BeaconClientManager) ChangeWithdrawalCredentials(validatorIndex string, fromBlsPubkey types.ValidatorPubkey, toExecutionAddress common.Address, signature types.ValidatorSignature) error { err := m.runFunction0(func(client beacon.Client) error { diff --git a/shared/services/beacon/client.go b/shared/services/beacon/client.go index d5265a08c..4e6b8a374 100644 --- a/shared/services/beacon/client.go +++ b/shared/services/beacon/client.go @@ -206,6 +206,7 @@ type Client interface { Close() error GetEth1DataForEth2Block(blockId string) (Eth1Data, bool, error) GetCommitteesForEpoch(epoch *uint64) (Committees, error) + GetHistoricalCommitteesForEpoch(epoch uint64) (Committees, error) ChangeWithdrawalCredentials(validatorIndex string, fromBlsPubkey types.ValidatorPubkey, toExecutionAddress common.Address, signature types.ValidatorSignature) error GetBeaconStateSSZ(slot uint64) (*BeaconStateSSZ, error) diff --git a/shared/services/beacon/client/std-http-client.go b/shared/services/beacon/client/std-http-client.go index 2d8fec936..f501d841a 100644 --- a/shared/services/beacon/client/std-http-client.go +++ b/shared/services/beacon/client/std-http-client.go @@ -755,33 +755,35 @@ func (c *StandardHttpClient) GetBeaconBlockHeader(blockId string) (beacon.Beacon return beaconBlock, true, nil } -// Get the attestation committees for the given epoch, or the current epoch if nil. -// For historical epochs the request uses the beacon state at the epoch's first -// slot so archival nodes return the correct shuffling. If that state is -// unavailable, head is tried as a fallback. +// Get the attestation committees for the given epoch, or the current epoch if nil func (c *StandardHttpClient) GetCommitteesForEpoch(epoch *uint64) (beacon.Committees, error) { - if epoch == nil { - response, err := c.getCommittees("head", nil) - if err != nil { - return nil, err - } - return &response, nil + response, err := c.getCommittees("head", epoch) + if err != nil { + return nil, err } + return &response, nil +} + +// GetHistoricalCommitteesForEpoch returns committees for a past epoch. The +// request uses the beacon state at the epoch's first slot so archival nodes +// return the correct shuffling. If that state is unavailable, head is tried +// as a fallback +func (c *StandardHttpClient) GetHistoricalCommitteesForEpoch(epoch uint64) (beacon.Committees, error) { eth2Config, err := c.getEth2Config() if err != nil { return nil, err } - stateSlot := *epoch * uint64(eth2Config.Data.SlotsPerEpoch) - response, err := c.getCommittees(strconv.FormatUint(stateSlot, 10), epoch) + stateSlot := epoch * uint64(eth2Config.Data.SlotsPerEpoch) + response, err := c.getCommittees(strconv.FormatUint(stateSlot, 10), &epoch) if err == nil && len(response.Data) > 0 { return &response, nil } // Some clients can resolve historical shuffling from head; others only // serve epoch E from a state at or after epoch E. - headResponse, headErr := c.getCommittees("head", epoch) + headResponse, headErr := c.getCommittees("head", &epoch) if headErr == nil && len(headResponse.Data) > 0 { return &headResponse, nil } @@ -792,7 +794,7 @@ func (c *StandardHttpClient) GetCommitteesForEpoch(epoch *uint64) (beacon.Commit if headErr != nil { return nil, headErr } - return nil, fmt.Errorf("Could not get committees for epoch %d: no committee data returned (archival beacon node may be required)", *epoch) + return nil, fmt.Errorf("Could not get committees for epoch %d: no committee data returned (archival beacon node may be required)", epoch) } // Perform a withdrawal credentials change on a validator diff --git a/shared/services/performance/target-performance.go b/shared/services/performance/target-performance.go index 6e3da65ae..f36121b9b 100644 --- a/shared/services/performance/target-performance.go +++ b/shared/services/performance/target-performance.go @@ -184,7 +184,7 @@ type PerformanceSummary struct { // block-based target-vote engine. type PerformanceBeaconClient interface { GetEth2Config() (beacon.Eth2Config, error) - GetCommitteesForEpoch(epoch *uint64) (beacon.Committees, error) + GetHistoricalCommitteesForEpoch(epoch uint64) (beacon.Committees, error) GetBeaconBlock(blockId string) (beacon.BeaconBlock, bool, error) GetBeaconBlockHeader(blockId string) (beacon.BeaconBlockHeader, bool, error) GetValidatorStatusByIndex(index string, opts *beacon.ValidatorStatusOptions) (beacon.ValidatorStatus, error) @@ -684,7 +684,7 @@ func (c *epochCache) ensureEpochDuties(epoch uint64) error { return nil } - committees, err := c.bc.GetCommitteesForEpoch(&epoch) + committees, err := c.bc.GetHistoricalCommitteesForEpoch(epoch) if err != nil { return fmt.Errorf("error getting committees for epoch %d: %w", epoch, err) } diff --git a/shared/services/state/static_bc.go b/shared/services/state/static_bc.go index 1a3bdfbb7..65eefef5a 100644 --- a/shared/services/state/static_bc.go +++ b/shared/services/state/static_bc.go @@ -210,6 +210,10 @@ func (c *StaticBeaconClient) GetCommitteesForEpoch(_ *uint64) (beacon.Committees return nil, ErrStaticMode } +func (c *StaticBeaconClient) GetHistoricalCommitteesForEpoch(_ uint64) (beacon.Committees, error) { + return nil, ErrStaticMode +} + func (c *StaticBeaconClient) ChangeWithdrawalCredentials(_ string, _ types.ValidatorPubkey, _ common.Address, _ types.ValidatorSignature) error { return ErrStaticMode } From 338757faf5eae0e58d5b171e1f12b418e7cd3961 Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:33:57 -0300 Subject: [PATCH 33/35] Adapt new node tasks to NetworkStateIndex Master split reconstructed indexes off NetworkState. Pass NetworkStateIndex into the new defend/exit-request tasks so they compile against the refactored state. --- rocketpool/node/check-megapool-exit-requests.go | 4 ++-- rocketpool/node/check-minipool-exit-requests.go | 4 ++-- rocketpool/node/defend-challenge-performance.go | 4 ++-- shared/services/performance/target-performance.go | 1 - 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/rocketpool/node/check-megapool-exit-requests.go b/rocketpool/node/check-megapool-exit-requests.go index b89dfec79..682df2ef5 100644 --- a/rocketpool/node/check-megapool-exit-requests.go +++ b/rocketpool/node/check-megapool-exit-requests.go @@ -115,7 +115,7 @@ func newCheckMegapoolExitRequests(c *cli.Command, logger log.ColorLogger) (*chec } // Check for megapool validators that did not respond to an exit request -func (t *checkMegapoolExitRequests) run(state *state.NetworkState) error { +func (t *checkMegapoolExitRequests) run(state *state.NetworkStateIndex) error { // Check if Saturn 2 is deployed if !state.Saturn2Deployed { t.log.Println("Saturn 2 is not deployed, skipping megapool exit requests check.") @@ -243,7 +243,7 @@ func (t *checkMegapoolExitRequests) run(state *state.NetworkState) error { } // Sign and broadcast the voluntary exit for a megapool validator belonging to this node -func (t *checkMegapoolExitRequests) exitOwnMegapoolValidator(state *state.NetworkState, request network.MegapoolExitRequest, status beacon.ValidatorStatus) error { +func (t *checkMegapoolExitRequests) exitOwnMegapoolValidator(state *state.NetworkStateIndex, request network.MegapoolExitRequest, status beacon.ValidatorStatus) error { // Check the validator status on the megapool validatorInfo, exists := state.GetMegapoolValidatorInfo(request.MegapoolAddress, request.Pubkey) diff --git a/rocketpool/node/check-minipool-exit-requests.go b/rocketpool/node/check-minipool-exit-requests.go index 000144c87..189fa80ad 100644 --- a/rocketpool/node/check-minipool-exit-requests.go +++ b/rocketpool/node/check-minipool-exit-requests.go @@ -125,7 +125,7 @@ func newCheckMinipoolExitRequests(c *cli.Command, logger log.ColorLogger) (*chec } // Check for minipool validators that did not respond to an exit request -func (t *checkMinipoolExitRequests) run(state *state.NetworkState) error { +func (t *checkMinipoolExitRequests) run(state *state.NetworkStateIndex) error { // Check if Saturn 2 is deployed if !state.Saturn2Deployed { t.log.Println("Saturn 2 is not deployed, skipping minipool exit requests check.") @@ -371,7 +371,7 @@ func (t *checkMinipoolExitRequests) forceExitMinipool(mpd *rpstate.NativeMinipoo return nil } -func (t *checkMinipoolExitRequests) proveDidNotExit(beaconState eth2.BeaconState, state *state.NetworkState, validator didNotExitValidator) error { +func (t *checkMinipoolExitRequests) proveDidNotExit(beaconState eth2.BeaconState, state *state.NetworkStateIndex, validator didNotExitValidator) error { t.log.Printlnf("[STARTED] Crafting a did-not-exit proof. This process can take several seconds and is CPU and memory intensive. If you don't see a [FINISHED] log entry your system may not have enough resources to perform this operation.") diff --git a/rocketpool/node/defend-challenge-performance.go b/rocketpool/node/defend-challenge-performance.go index 0704fd07e..f5149fc0e 100644 --- a/rocketpool/node/defend-challenge-performance.go +++ b/rocketpool/node/defend-challenge-performance.go @@ -144,7 +144,7 @@ func newDefendChallengePerformance(c *cli.Command, logger log.ColorLogger) (*def } // Check for performance challenges -func (t *defendChallengePerformance) run(state *state.NetworkState) error { +func (t *defendChallengePerformance) run(state *state.NetworkStateIndex) error { // Check if Saturn 2 is deployed if !state.Saturn2Deployed { t.log.Println("Saturn 2 is not deployed, skipping performance challenges check.") @@ -346,7 +346,7 @@ func (t *defendChallengePerformance) finaliseChallenge(challenge megapoolPerform // respondWithValidator responds to a performance challenge with a validator // proof showing the defender was not staking during the challenge window. -func (t *defendChallengePerformance) respondWithValidator(challenge megapoolPerformanceChallenge, defender challengedValidator, state *state.NetworkState) error { +func (t *defendChallengePerformance) respondWithValidator(challenge megapoolPerformanceChallenge, defender challengedValidator, state *state.NetworkStateIndex) error { // Get transactor opts, err := t.w.GetNodeAccountTransactor() diff --git a/shared/services/performance/target-performance.go b/shared/services/performance/target-performance.go index f36121b9b..0841f0ddc 100644 --- a/shared/services/performance/target-performance.go +++ b/shared/services/performance/target-performance.go @@ -67,7 +67,6 @@ type ChallengeParams struct { } // GetChallengeParams fetches the pDAO performance-challenge settings. -// Before Saturn 2 the contracts do not exist, so challenges are disabled. func GetChallengeParams(rp *rocketpool.RocketPool) (ChallengeParams, error) { saturn2Deployed, err := state.IsSaturn2Deployed(rp, nil) if err != nil { From a3c890c018cf5e8eeebef442fa7f94c40037fa2d Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:53:36 -0300 Subject: [PATCH 34/35] Fix order of version checks --- bindings/utils/version-checker.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bindings/utils/version-checker.go b/bindings/utils/version-checker.go index b357c5477..cee4f81f7 100644 --- a/bindings/utils/version-checker.go +++ b/bindings/utils/version-checker.go @@ -14,14 +14,6 @@ import ( ) func GetCurrentVersion(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*version.Version, error) { - beaconStateVerifierVersion, err := megapool.GetBeaconStateVerifierVersion(rp, opts) - if err != nil { - return nil, fmt.Errorf("error checking beacon state verifier version: %w", err) - } - if beaconStateVerifierVersion == 2 { - return version.NewSemver("1.4.1") - } - depositPoolVersion, err := deposit.GetRocketDepositPoolVersion(rp, opts) if err != nil { return nil, fmt.Errorf("error checking deposit pool version: %w", err) @@ -32,6 +24,14 @@ func GetCurrentVersion(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*version return version.NewSemver("1.5.0") } + beaconStateVerifierVersion, err := megapool.GetBeaconStateVerifierVersion(rp, opts) + if err != nil { + return nil, fmt.Errorf("error checking beacon state verifier version: %w", err) + } + if beaconStateVerifierVersion == 2 { + return version.NewSemver("1.4.1") + } + // Check for v1.4 (Saturn 1) if depositPoolVersion > 3 { From 02abc6f00153bb16cd1f0c68eaec3046001b2caf Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:02:28 -0300 Subject: [PATCH 35/35] Add IsSaturn2OnlySetting --- rocketpool-cli/cli/saturn2-check.go | 35 +++++++++++++++++++++ rocketpool-cli/pdao/propose-settings.go | 7 +++++ rocketpool-cli/pdao/submit-batch.go | 10 ++++++ rocketpool-cli/security/propose-settings.go | 7 +++++ 4 files changed, 59 insertions(+) create mode 100644 rocketpool-cli/cli/saturn2-check.go diff --git a/rocketpool-cli/cli/saturn2-check.go b/rocketpool-cli/cli/saturn2-check.go new file mode 100644 index 000000000..aff18525d --- /dev/null +++ b/rocketpool-cli/cli/saturn2-check.go @@ -0,0 +1,35 @@ +package cli + +import ( + "fmt" + + "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/shared/services/rocketpool" +) + +const Saturn2NotDeployedMessage = "This command is not available until Saturn 2 is deployed." + +// IsSaturn2OnlySetting reports whether a protocol setting exists only after Saturn 2. +func IsSaturn2OnlySetting(contract, setting string) bool { + switch contract { + case protocol.PerformanceSettingsContractName, protocol.ExitSettingsContractName: + return true + case protocol.MegapoolSettingsContractName: + return setting == protocol.MegapoolPrestakeChallengePeriodPath + default: + return false + } +} + +// RequireSaturn2 prints Saturn2NotDeployedMessage and returns false when Saturn 2 is not deployed +func RequireSaturn2(rp *rocketpool.Client) (ok bool, err error) { + settings, err := rp.PDAOGetSettings() + if err != nil { + return false, fmt.Errorf("error checking if Saturn 2 is deployed: %w", err) + } + if !settings.Saturn2Deployed { + fmt.Println(Saturn2NotDeployedMessage) + return false, nil + } + return true, nil +} diff --git a/rocketpool-cli/pdao/propose-settings.go b/rocketpool-cli/pdao/propose-settings.go index 2b0ae11b2..86874d9fe 100644 --- a/rocketpool-cli/pdao/propose-settings.go +++ b/rocketpool-cli/pdao/propose-settings.go @@ -468,6 +468,13 @@ func proposeSetting(contract string, setting string, value string, yes bool, toJ } defer rp.Close() + if cliutils.IsSaturn2OnlySetting(contract, setting) { + ok, err := cliutils.RequireSaturn2(rp) + if err != nil || !ok { + return err + } + } + // Check if proposal can be made canPropose, err := rp.PDAOCanProposeSetting(contract, setting, value) if err != nil { diff --git a/rocketpool-cli/pdao/submit-batch.go b/rocketpool-cli/pdao/submit-batch.go index 4010196a4..960976e15 100644 --- a/rocketpool-cli/pdao/submit-batch.go +++ b/rocketpool-cli/pdao/submit-batch.go @@ -80,6 +80,16 @@ func submitBatch(file string, message string, yes bool) error { } defer rp.Close() + for _, setting := range settings { + if cliutils.IsSaturn2OnlySetting(setting.Contract, setting.Setting) { + ok, err := cliutils.RequireSaturn2(rp) + if err != nil || !ok { + return err + } + break + } + } + canPropose, err := rp.PDAOCanProposeSettingMulti(settings, message) if err != nil { return err diff --git a/rocketpool-cli/security/propose-settings.go b/rocketpool-cli/security/propose-settings.go index 1c7fdf665..6d759026e 100644 --- a/rocketpool-cli/security/propose-settings.go +++ b/rocketpool-cli/security/propose-settings.go @@ -96,6 +96,13 @@ func proposeSetting(contract string, setting string, value string, yes bool) err } defer rp.Close() + if cliutils.IsSaturn2OnlySetting(contract, setting) { + ok, err := cliutils.RequireSaturn2(rp) + if err != nil || !ok { + return err + } + } + // Check if proposal can be made canPropose, err := rp.SecurityCanProposeSetting(contract, setting, value) if err != nil {