diff --git a/rocketpool/node/node.go b/rocketpool/node/node.go index 73dc544f8..8b8b69e31 100644 --- a/rocketpool/node/node.go +++ b/rocketpool/node/node.go @@ -117,10 +117,7 @@ func run(c *cli.Context) error { updateLog := log.NewColorLogger(UpdateColor) // Create the state manager - m, err := state.NewNetworkStateManager(rp, cfg, rp.Client, bc, &updateLog) - if err != nil { - return err - } + m := state.NewNetworkStateManager(rp, cfg.Smartnode.GetStateManagerContracts(), bc, &updateLog) stateLocker := collectors.NewStateLocker() // Initialize tasks diff --git a/rocketpool/watchtower/generate-rewards-tree.go b/rocketpool/watchtower/generate-rewards-tree.go index 640f66bfc..7c4c0a965 100644 --- a/rocketpool/watchtower/generate-rewards-tree.go +++ b/rocketpool/watchtower/generate-rewards-tree.go @@ -139,8 +139,13 @@ func (t *generateRewardsTree) generateRewardsTree(index uint64) { generationPrefix := fmt.Sprintf("[Interval %d Tree]", index) t.log.Printlnf("%s Starting generation of Merkle rewards tree for interval %d.", generationPrefix, index) + // Get previous rewards pool addresses + previousRewardsPoolAddresses := t.cfg.Smartnode.GetPreviousRewardsPoolAddresses() + + rewardsClient := rprewards.NewRewardsExecutionClient(t.rp) + // Find the event for this interval - rewardsEvent, err := rprewards.GetRewardSnapshotEvent(t.rp, t.cfg, index, nil) + rewardsEvent, err := rewardsClient.GetRewardSnapshotEvent(previousRewardsPoolAddresses, index, nil) if err != nil { t.handleError(fmt.Errorf("%s Error getting event for interval %d: %w", generationPrefix, index, err)) return @@ -164,11 +169,7 @@ func (t *generateRewardsTree) generateRewardsTree(index uint64) { address, err := client.RocketStorage.GetAddress(opts, crypto.Keccak256Hash([]byte("contract.addressrocketTokenRETH"))) if err == nil { // Create the state manager with using the primary or fallback (not necessarily archive) EC - stateManager, err = state.NewNetworkStateManager(client, t.cfg, t.rp.Client, t.bc, &t.log) - if err != nil { - t.handleError(fmt.Errorf("error creating new NetworkStateManager with Archive EC: %w", err)) - return - } + stateManager = state.NewNetworkStateManager(client, t.cfg.Smartnode.GetStateManagerContracts(), t.bc, &t.log) } else { // Check if an Archive EC is provided, and if using it would potentially resolve the error errMessage := err.Error() @@ -199,12 +200,16 @@ func (t *generateRewardsTree) generateRewardsTree(index uint64) { t.handleError(fmt.Errorf("Error verifying rETH address with Archive EC: %w", err)) return } - // Create the state manager with the archive EC - stateManager, err = state.NewNetworkStateManager(client, t.cfg, ec, t.bc, &t.log) + + // Create a new rocketpool-go instance + archiveRP, err := rocketpool.NewRocketPool(ec, *t.rp.RocketStorageContract.Address) if err != nil { - t.handleError(fmt.Errorf("Error creating new NetworkStateManager with ARchive EC: %w", err)) + t.handleError(fmt.Errorf("Error instantiating client with Archive EC: %w", err)) return } + + // Create the state manager with the archive EC + stateManager = state.NewNetworkStateManager(archiveRP, t.cfg.Smartnode.GetStateManagerContracts(), t.bc, &t.log) } else { // No archive node specified t.handleError(fmt.Errorf("***ERROR*** Primary EC cannot retrieve state for historical block %d and the Archive EC is not specified.", elBlockHeader.Number.Uint64())) diff --git a/rocketpool/watchtower/submit-network-balances.go b/rocketpool/watchtower/submit-network-balances.go index bb28668c2..d8dc1cd7d 100644 --- a/rocketpool/watchtower/submit-network-balances.go +++ b/rocketpool/watchtower/submit-network-balances.go @@ -297,10 +297,7 @@ func (t *submitNetworkBalances) getNetworkBalances(elBlockHeader *types.Header, } // Create a new state gen manager - mgr, err := state.NewNetworkStateManager(client, t.cfg, client.Client, t.bc, t.log) - if err != nil { - return networkBalances{}, fmt.Errorf("error creating network state manager for EL block %s, Beacon slot %d: %w", elBlock, beaconBlock, err) - } + mgr := state.NewNetworkStateManager(client, t.cfg.Smartnode.GetStateManagerContracts(), t.bc, t.log) // Create a new state for the target block state, err := mgr.GetStateForSlot(beaconBlock) diff --git a/rocketpool/watchtower/submit-rewards-tree-rolling.go b/rocketpool/watchtower/submit-rewards-tree-rolling.go index f4bc59ccb..8e8ff3a61 100644 --- a/rocketpool/watchtower/submit-rewards-tree-rolling.go +++ b/rocketpool/watchtower/submit-rewards-tree-rolling.go @@ -282,11 +282,7 @@ func (t *submitRewardsTree_Rolling) run(headState *state.NetworkState) error { } // Generate the rewards state - stateMgr, err := state.NewNetworkStateManager(client, t.cfg, client.Client, t.bc, &t.log) - if err != nil { - t.handleError(fmt.Errorf("error creating state manager for rewards slot: %w", err)) - return - } + stateMgr := state.NewNetworkStateManager(client, t.cfg.Smartnode.GetStateManagerContracts(), t.bc, &t.log) state, err := stateMgr.GetStateForSlot(snapshotEnd.ConsensusBlock) if err != nil { t.handleError(fmt.Errorf("error getting state for rewards slot: %w", err)) diff --git a/rocketpool/watchtower/submit-rewards-tree-stateless.go b/rocketpool/watchtower/submit-rewards-tree-stateless.go index f4be723a5..72d8ad385 100644 --- a/rocketpool/watchtower/submit-rewards-tree-stateless.go +++ b/rocketpool/watchtower/submit-rewards-tree-stateless.go @@ -321,10 +321,7 @@ func (t *submitRewardsTree_Stateless) generateTreeImpl(rp *rocketpool.RocketPool t.log.Printlnf("Rewards checkpoint has passed, starting Merkle tree generation for interval %d in the background.\n%s Snapshot Beacon block = %d, EL block = %d, running from %s to %s", currentIndex, t.generationPrefix, snapshotBeaconBlock, elBlockIndex, startTime, endTime) // Create a new state gen manager - mgr, err := state.NewNetworkStateManager(rp, t.cfg, rp.Client, t.bc, t.log) - if err != nil { - return fmt.Errorf("error creating network state manager for EL block %d, Beacon slot %d: %w", elBlockIndex, snapshotBeaconBlock, err) - } + mgr := state.NewNetworkStateManager(rp, t.cfg.Smartnode.GetStateManagerContracts(), t.bc, t.log) // Create a new state for the target block state, err := mgr.GetStateForSlot(snapshotBeaconBlock) diff --git a/rocketpool/watchtower/watchtower.go b/rocketpool/watchtower/watchtower.go index ae5739385..29bf7314c 100644 --- a/rocketpool/watchtower/watchtower.go +++ b/rocketpool/watchtower/watchtower.go @@ -111,10 +111,7 @@ func run(c *cli.Context) error { updateLog := log.NewColorLogger(UpdateColor) // Create the state manager - m, err := state.NewNetworkStateManager(rp, cfg, rp.Client, bc, &updateLog) - if err != nil { - return err - } + m := state.NewNetworkStateManager(rp, cfg.Smartnode.GetStateManagerContracts(), bc, &updateLog) // Get the node address nodeAccount, err := w.GetNodeAccount() diff --git a/shared/services/beacon/client.go b/shared/services/beacon/client.go index df9f977cb..79f28a947 100644 --- a/shared/services/beacon/client.go +++ b/shared/services/beacon/client.go @@ -28,18 +28,18 @@ type BeaconHead struct { PreviousJustifiedEpoch uint64 } type ValidatorStatus struct { - Pubkey types.ValidatorPubkey - Index string - WithdrawalCredentials common.Hash - Balance uint64 - Status ValidatorState - EffectiveBalance uint64 - Slashed bool - ActivationEligibilityEpoch uint64 - ActivationEpoch uint64 - ExitEpoch uint64 - WithdrawableEpoch uint64 - Exists bool + Pubkey types.ValidatorPubkey `json:"pubkey"` + Index string `json:"index"` + WithdrawalCredentials common.Hash `json:"withdrawal_credentials"` + Balance uint64 `json:"balance"` + Status ValidatorState `json:"status"` + EffectiveBalance uint64 `json:"effective_balance"` + Slashed bool `json:"slashed"` + ActivationEligibilityEpoch uint64 `json:"activation_eligibility_epoch"` + ActivationEpoch uint64 `json:"activation_epoch"` + ExitEpoch uint64 `json:"exit_epoch"` + WithdrawableEpoch uint64 `json:"withdrawable_epoch"` + Exists bool `json:"exists"` } type Eth1Data struct { DepositRoot common.Hash diff --git a/shared/services/beacon/config.go b/shared/services/beacon/config.go index 275e13986..fd0299fa5 100644 --- a/shared/services/beacon/config.go +++ b/shared/services/beacon/config.go @@ -1,18 +1,63 @@ package beacon import ( + "encoding/json" + "fmt" "time" + + "github.com/ethereum/go-ethereum/common/hexutil" ) type Eth2Config struct { - GenesisForkVersion []byte - GenesisValidatorsRoot []byte - GenesisEpoch uint64 - GenesisTime uint64 - SecondsPerSlot uint64 - SlotsPerEpoch uint64 - SecondsPerEpoch uint64 - EpochsPerSyncCommitteePeriod uint64 + GenesisForkVersion []byte `json:"genesis_fork_version"` + GenesisValidatorsRoot []byte `json:"genesis_validators_root"` + GenesisEpoch uint64 `json:"genesis_epoch"` + GenesisTime uint64 `json:"genesis_time"` + SecondsPerSlot uint64 `json:"seconds_per_slot"` + SlotsPerEpoch uint64 `json:"slots_per_epoch"` + SecondsPerEpoch uint64 `json:"seconds_per_epoch"` + EpochsPerSyncCommitteePeriod uint64 `json:"epochs_per_sync_committee_period"` +} + +func (c *Eth2Config) MarshalJSON() ([]byte, error) { + // GenesisForkVersion and GenesisValidatorsRoot are returned as hex strings with 0x prefixes. + // The other fields are returned as uint64s. + type Alias Eth2Config + return json.Marshal(&struct { + GenesisForkVersion string `json:"genesis_fork_version"` + GenesisValidatorsRoot string `json:"genesis_validators_root"` + *Alias + }{ + GenesisForkVersion: hexutil.Encode(c.GenesisForkVersion), + GenesisValidatorsRoot: hexutil.Encode(c.GenesisValidatorsRoot), + Alias: (*Alias)(c), + }) +} + +func (c *Eth2Config) UnmarshalJSON(data []byte) error { + type Alias Eth2Config + aux := &struct { + GenesisForkVersion string `json:"genesis_fork_version"` + GenesisValidatorsRoot string `json:"genesis_validators_root"` + *Alias + }{ + Alias: (*Alias)(c), + } + + err := json.Unmarshal(data, &aux) + if err != nil { + return err + } + + c.GenesisForkVersion, err = hexutil.Decode(aux.GenesisForkVersion) + if err != nil { + return err + } + c.GenesisValidatorsRoot, err = hexutil.Decode(aux.GenesisValidatorsRoot) + if err != nil { + return err + } + return nil } // GetSlotTime returns the time of a given slot for the network described by Eth2Config. @@ -48,3 +93,34 @@ func (c *Eth2Config) FirstSlotAtLeast(t int64) uint64 { } return c.GenesisEpoch*c.SlotsPerEpoch + slotsSinceGenesis } + +func (c *Eth2Config) SlotToEpoch(slot uint64) uint64 { + return slot / c.SlotsPerEpoch +} + +func (c *Eth2Config) EpochToSlot(epoch uint64) uint64 { + return epoch * c.SlotsPerEpoch +} + +func (c *Eth2Config) SlotOfEpoch(epoch uint64, slot uint64) (uint64, error) { + if slot > c.SlotsPerEpoch-1 { + return 0, fmt.Errorf("slot %d is not in range 0 - %d", slot, c.SlotsPerEpoch-1) + } + return epoch*c.SlotsPerEpoch + slot, nil +} + +func (c *Eth2Config) LastSlotOfEpoch(epoch uint64) uint64 { + out, err := c.SlotOfEpoch(epoch, c.SlotsPerEpoch-1) + if err != nil { + panic("SlotOfEpoch should never return an error when passed SlotsPerEpoch - 1") + } + return out +} + +func (c *Eth2Config) FirstSlotOfEpoch(epoch uint64) uint64 { + out, err := c.SlotOfEpoch(epoch, 0) + if err != nil { + panic("SlotOfEpoch should never return an error when passed 0") + } + return out +} diff --git a/shared/services/beacon/config_test.go b/shared/services/beacon/config_test.go index 459b9c80e..e0af148de 100644 --- a/shared/services/beacon/config_test.go +++ b/shared/services/beacon/config_test.go @@ -1,6 +1,7 @@ package beacon import ( + "slices" "testing" "time" ) @@ -55,3 +56,55 @@ func TestFirstSlotAtLeast(t *testing.T) { t.Fatal("Whole number seconds shouldn't round up") } } + +func TestMarshalJSON(t *testing.T) { + config := &Eth2Config{ + GenesisForkVersion: []byte{0x00, 0x00, 0x00, 0x08}, + GenesisValidatorsRoot: []byte{0xfe, 0x44, 0x33, 0x22}, + GenesisEpoch: 10, + GenesisTime: 10000, + SecondsPerSlot: 4, + SlotsPerEpoch: 32, + SecondsPerEpoch: 32 * 4, + EpochsPerSyncCommitteePeriod: 256, + } + + json, err := config.MarshalJSON() + if err != nil { + t.Fatalf("error marshalling config: %v", err) + } + + unmarshalled := &Eth2Config{} + err = unmarshalled.UnmarshalJSON(json) + if err != nil { + t.Fatalf("error unmarshalling config: %v", err) + } + + if !slices.Equal(unmarshalled.GenesisForkVersion, config.GenesisForkVersion) { + t.Fatalf("genesis fork version should be %v, instead got %v", config.GenesisForkVersion, unmarshalled.GenesisForkVersion) + } + + if !slices.Equal(unmarshalled.GenesisValidatorsRoot, config.GenesisValidatorsRoot) { + t.Fatalf("genesis validators root should be %v, instead got %v", config.GenesisValidatorsRoot, unmarshalled.GenesisValidatorsRoot) + } + + if unmarshalled.GenesisEpoch != config.GenesisEpoch { + t.Fatalf("genesis epoch should be %v, instead got %v", config.GenesisEpoch, unmarshalled.GenesisEpoch) + } + + if unmarshalled.GenesisTime != config.GenesisTime { + t.Fatalf("genesis time should be %v, instead got %v", config.GenesisTime, unmarshalled.GenesisTime) + } + + if unmarshalled.SecondsPerSlot != config.SecondsPerSlot { + t.Fatalf("seconds per slot should be %v, instead got %v", config.SecondsPerSlot, unmarshalled.SecondsPerSlot) + } + + if unmarshalled.SlotsPerEpoch != config.SlotsPerEpoch { + t.Fatalf("slots per epoch should be %v, instead got %v", config.SlotsPerEpoch, unmarshalled.SlotsPerEpoch) + } + + if unmarshalled.EpochsPerSyncCommitteePeriod != config.EpochsPerSyncCommitteePeriod { + t.Fatalf("epochs per sync committee period should be %v, instead got %v", config.EpochsPerSyncCommitteePeriod, unmarshalled.EpochsPerSyncCommitteePeriod) + } +} diff --git a/shared/services/config/smartnode-config.go b/shared/services/config/smartnode-config.go index 70a03f307..99aaebcb7 100644 --- a/shared/services/config/smartnode-config.go +++ b/shared/services/config/smartnode-config.go @@ -49,6 +49,12 @@ const ( RewardsExtensionSSZ RewardsExtension = ".ssz" ) +// Contract addresses for multicall / network state manager +type StateManagerContracts struct { + Multicaller common.Address + BalanceBatcher common.Address +} + // Configuration for the Smartnode type SmartnodeConfig struct { Title string `yaml:"-"` @@ -851,6 +857,10 @@ func (cfg *SmartnodeConfig) GetRethAddress() common.Address { } func getDefaultDataDir(config *RocketPoolConfig) string { + if config == nil { + // Handle tests. Eventually we'll refactor so this isn't necessary. + return "" + } return filepath.Join(config.RocketPoolDirectory, "data") } @@ -1012,6 +1022,14 @@ func (cfg *SmartnodeConfig) GetBalanceBatcherAddress() string { return cfg.balancebatcherAddress[cfg.Network.Value.(config.Network)] } +// Utility function to get the state manager contracts +func (cfg *SmartnodeConfig) GetStateManagerContracts() StateManagerContracts { + return StateManagerContracts{ + Multicaller: common.HexToAddress(cfg.GetMulticallAddress()), + BalanceBatcher: common.HexToAddress(cfg.GetBalanceBatcherAddress()), + } +} + func (cfg *SmartnodeConfig) GetFlashbotsProtectUrl() string { return cfg.flashbotsProtectUrl[cfg.Network.Value.(config.Network)] } diff --git a/shared/services/proposals/proposal-manager.go b/shared/services/proposals/proposal-manager.go index 13ab78429..8fe4f4d57 100644 --- a/shared/services/proposals/proposal-manager.go +++ b/shared/services/proposals/proposal-manager.go @@ -43,10 +43,7 @@ func NewProposalManager(log *log.ColorLogger, cfg *config.RocketPoolConfig, rp * return nil, fmt.Errorf("error creating node tree manager: %w", err) } - stateMgr, err := state.NewNetworkStateManager(rp, cfg, rp.Client, bc, log) - if err != nil { - return nil, fmt.Errorf("error creating network state manager: %w", err) - } + stateMgr := state.NewNetworkStateManager(rp, cfg.Smartnode.GetStateManagerContracts(), bc, log) logPrefix := "[PDAO Proposals]" return &ProposalManager{ diff --git a/shared/services/rewards/execution-client.go b/shared/services/rewards/execution-client.go new file mode 100644 index 000000000..306d07e5f --- /dev/null +++ b/shared/services/rewards/execution-client.go @@ -0,0 +1,56 @@ +package rewards + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/rocket-pool/rocketpool-go/rewards" + "github.com/rocket-pool/rocketpool-go/rocketpool" + "github.com/rocket-pool/rocketpool-go/settings/trustednode" +) + +// An implementation of RewardsExecutionClient that uses +// rocketpool-go to access chain data. +// +// Importantly, this struct instantiates rocketpool.RocketPool and passes it +// to the old fashioned rocketpool-go getters that take it as an argument +// but it also fulfills the requirements of an interface used for dependency injection +// in tests. +type defaultRewardsExecutionClient struct { + *rocketpool.RocketPool +} + +func NewRewardsExecutionClient(rp *rocketpool.RocketPool) (out *defaultRewardsExecutionClient) { + out.RocketPool = rp + return +} + +func (client *defaultRewardsExecutionClient) GetNetworkEnabled(networkId *big.Int, opts *bind.CallOpts) (bool, error) { + return trustednode.GetNetworkEnabled(client.RocketPool, networkId, opts) +} + +func (client *defaultRewardsExecutionClient) HeaderByNumber(ctx context.Context, block *big.Int) (*ethtypes.Header, error) { + return client.RocketPool.Client.HeaderByNumber(ctx, block) +} + +func (client *defaultRewardsExecutionClient) GetRewardsEvent(index uint64, rocketRewardsPoolAddresses []common.Address, opts *bind.CallOpts) (bool, rewards.RewardsEvent, error) { + return rewards.GetRewardsEvent(client.RocketPool, index, rocketRewardsPoolAddresses, opts) +} + +func (client *defaultRewardsExecutionClient) GetRewardSnapshotEvent(previousRewardsPoolAddresses []common.Address, interval uint64, opts *bind.CallOpts) (rewards.RewardsEvent, error) { + + found, event, err := client.GetRewardsEvent(interval, previousRewardsPoolAddresses, opts) + if err != nil { + return rewards.RewardsEvent{}, fmt.Errorf("error getting rewards event for interval %d: %w", interval, err) + } + if !found { + return rewards.RewardsEvent{}, fmt.Errorf("interval %d event not found", interval) + } + + return event, nil + +} diff --git a/shared/services/rewards/generator-impl-v8-rolling.go b/shared/services/rewards/generator-impl-v8-rolling.go index 8190c19bf..aa4659363 100644 --- a/shared/services/rewards/generator-impl-v8-rolling.go +++ b/shared/services/rewards/generator-impl-v8-rolling.go @@ -12,8 +12,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ipfs/go-cid" - "github.com/rocket-pool/rocketpool-go/rocketpool" - tnsettings "github.com/rocket-pool/rocketpool-go/settings/trustednode" "github.com/rocket-pool/rocketpool-go/utils/eth" "github.com/rocket-pool/smartnode/shared/services/beacon" "github.com/rocket-pool/smartnode/shared/services/config" @@ -25,28 +23,28 @@ import ( // Implementation for tree generator ruleset v8 with rolling record support type treeGeneratorImpl_v8_rolling struct { - networkState *state.NetworkState - rewardsFile *RewardsFile_v3 - elSnapshotHeader *types.Header - log *log.ColorLogger - logPrefix string - rp *rocketpool.RocketPool - cfg *config.RocketPoolConfig - bc beacon.Client - opts *bind.CallOpts - smoothingPoolBalance *big.Int - intervalDutiesInfo *IntervalDutiesInfo - slotsPerEpoch uint64 - validatorIndexMap map[string]*MinipoolInfo - elStartTime time.Time - elEndTime time.Time - validNetworkCache map[uint64]bool - epsilon *big.Int - intervalSeconds *big.Int - beaconConfig beacon.Eth2Config - rollingRecord *RollingRecord - nodeDetails map[common.Address]*NodeSmoothingDetails - invalidNetworkNodes map[common.Address]uint64 + networkState *state.NetworkState + rewardsFile *RewardsFile_v3 + elSnapshotHeader *types.Header + log *log.ColorLogger + logPrefix string + rp RewardsExecutionClient + previousRewardsPoolAddresses []common.Address + bc RewardsBeaconClient + opts *bind.CallOpts + smoothingPoolBalance *big.Int + intervalDutiesInfo *IntervalDutiesInfo + slotsPerEpoch uint64 + validatorIndexMap map[string]*MinipoolInfo + elStartTime time.Time + elEndTime time.Time + validNetworkCache map[uint64]bool + epsilon *big.Int + intervalSeconds *big.Int + beaconConfig beacon.Eth2Config + rollingRecord *RollingRecord + nodeDetails map[common.Address]*NodeSmoothingDetails + invalidNetworkNodes map[common.Address]uint64 } // Create a new tree generator @@ -97,20 +95,20 @@ func (r *treeGeneratorImpl_v8_rolling) getRulesetVersion() uint64 { return r.rewardsFile.RulesetVersion } -func (r *treeGeneratorImpl_v8_rolling) generateTree(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, bc beacon.Client) (*GenerateTreeResult, error) { +func (r *treeGeneratorImpl_v8_rolling) generateTree(rp RewardsExecutionClient, networkName string, previousRewardsPoolAddresses []common.Address, bc RewardsBeaconClient) (*GenerateTreeResult, error) { r.log.Printlnf("%s Generating tree using Ruleset v%d.", r.logPrefix, r.rewardsFile.RulesetVersion) // Provision some struct params r.rp = rp - r.cfg = cfg + r.previousRewardsPoolAddresses = previousRewardsPoolAddresses r.bc = bc r.validNetworkCache = map[uint64]bool{ 0: true, } // Set the network name - r.rewardsFile.Network = fmt.Sprint(cfg.Smartnode.Network.Value) + r.rewardsFile.Network = networkName r.rewardsFile.MinipoolPerformanceFile.Network = r.rewardsFile.Network r.rewardsFile.MinipoolPerformanceFile.RewardsFileVersion = r.rewardsFile.RewardsFileVersion r.rewardsFile.MinipoolPerformanceFile.RulesetVersion = r.rewardsFile.RulesetVersion @@ -173,18 +171,17 @@ func (r *treeGeneratorImpl_v8_rolling) generateTree(rp *rocketpool.RocketPool, c // Quickly calculates an approximate of the staker's share of the smoothing pool balance without processing Beacon performance // Used for approximate returns in the rETH ratio update -func (r *treeGeneratorImpl_v8_rolling) approximateStakerShareOfSmoothingPool(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, bc beacon.Client) (*big.Int, error) { +func (r *treeGeneratorImpl_v8_rolling) approximateStakerShareOfSmoothingPool(rp RewardsExecutionClient, networkName string, bc RewardsBeaconClient) (*big.Int, error) { r.log.Printlnf("%s Approximating tree using Ruleset v%d.", r.logPrefix, r.rewardsFile.RulesetVersion) r.rp = rp - r.cfg = cfg r.bc = bc r.validNetworkCache = map[uint64]bool{ 0: true, } // Set the network name - r.rewardsFile.Network = fmt.Sprint(cfg.Smartnode.Network.Value) + r.rewardsFile.Network = networkName r.rewardsFile.MinipoolPerformanceFile.Network = r.rewardsFile.Network r.rewardsFile.MinipoolPerformanceFile.RewardsFileVersion = r.rewardsFile.RewardsFileVersion r.rewardsFile.MinipoolPerformanceFile.RulesetVersion = r.rewardsFile.RulesetVersion @@ -759,7 +756,7 @@ func (r *treeGeneratorImpl_v8_rolling) validateNetwork(network uint64) (bool, er valid, exists := r.validNetworkCache[network] if !exists { var err error - valid, err = tnsettings.GetNetworkEnabled(r.rp, big.NewInt(int64(network)), r.opts) + valid, err = r.rp.GetNetworkEnabled(big.NewInt(int64(network)), r.opts) if err != nil { return false, err } @@ -786,7 +783,7 @@ func (r *treeGeneratorImpl_v8_rolling) getStartBlocksForInterval() (*types.Heade elBlockNumber := beaconBlock.ExecutionBlockNumber r.rewardsFile.ExecutionStartBlock = elBlockNumber r.rewardsFile.MinipoolPerformanceFile.ExecutionStartBlock = r.rewardsFile.ExecutionStartBlock - startElHeader, err := r.rp.Client.HeaderByNumber(context.Background(), big.NewInt(int64(elBlockNumber))) + startElHeader, err := r.rp.HeaderByNumber(context.Background(), big.NewInt(int64(elBlockNumber))) if err != nil { return nil, fmt.Errorf("error getting EL header for block %d: %w", elBlockNumber, err) } @@ -812,6 +809,6 @@ func (r *treeGeneratorImpl_v8_rolling) getCheaters() map[common.Address]bool { return cheatingNodes } -func (r *treeGeneratorImpl_v8_rolling) saveFiles(treeResult *GenerateTreeResult, nodeTrusted bool) (cid.Cid, map[string]cid.Cid, error) { - return saveJSONArtifacts(r.cfg.Smartnode, treeResult, nodeTrusted) +func (r *treeGeneratorImpl_v8_rolling) saveFiles(smartnode *config.SmartnodeConfig, treeResult *GenerateTreeResult, nodeTrusted bool) (cid.Cid, map[string]cid.Cid, error) { + return saveJSONArtifacts(smartnode, treeResult, nodeTrusted) } diff --git a/shared/services/rewards/generator-impl-v8.go b/shared/services/rewards/generator-impl-v8.go index 0dab12bad..b59a00502 100644 --- a/shared/services/rewards/generator-impl-v8.go +++ b/shared/services/rewards/generator-impl-v8.go @@ -12,8 +12,6 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ipfs/go-cid" "github.com/rocket-pool/rocketpool-go/rewards" - "github.com/rocket-pool/rocketpool-go/rocketpool" - tnsettings "github.com/rocket-pool/rocketpool-go/settings/trustednode" rptypes "github.com/rocket-pool/rocketpool-go/types" "github.com/rocket-pool/rocketpool-go/utils/eth" rpstate "github.com/rocket-pool/rocketpool-go/utils/state" @@ -28,31 +26,31 @@ var six = big.NewInt(6) // Implementation for tree generator ruleset v8 type treeGeneratorImpl_v8 struct { - networkState *state.NetworkState - rewardsFile *RewardsFile_v3 - elSnapshotHeader *types.Header - log *log.ColorLogger - logPrefix string - rp *rocketpool.RocketPool - cfg *config.RocketPoolConfig - bc beacon.Client - opts *bind.CallOpts - nodeDetails []*NodeSmoothingDetails - smoothingPoolBalance *big.Int - intervalDutiesInfo *IntervalDutiesInfo - slotsPerEpoch uint64 - validatorIndexMap map[string]*MinipoolInfo - elStartTime time.Time - elEndTime time.Time - validNetworkCache map[uint64]bool - epsilon *big.Int - intervalSeconds *big.Int - beaconConfig beacon.Eth2Config - validatorStatusMap map[rptypes.ValidatorPubkey]beacon.ValidatorStatus - totalAttestationScore *big.Int - successfulAttestations uint64 - genesisTime time.Time - invalidNetworkNodes map[common.Address]uint64 + networkState *state.NetworkState + rewardsFile *RewardsFile_v3 + elSnapshotHeader *types.Header + log *log.ColorLogger + logPrefix string + rp RewardsExecutionClient + previousRewardsPoolAddresses []common.Address + bc RewardsBeaconClient + opts *bind.CallOpts + nodeDetails []*NodeSmoothingDetails + smoothingPoolBalance *big.Int + intervalDutiesInfo *IntervalDutiesInfo + slotsPerEpoch uint64 + validatorIndexMap map[string]*MinipoolInfo + elStartTime time.Time + elEndTime time.Time + validNetworkCache map[uint64]bool + epsilon *big.Int + intervalSeconds *big.Int + beaconConfig beacon.Eth2Config + validatorStatusMap map[rptypes.ValidatorPubkey]beacon.ValidatorStatus + totalAttestationScore *big.Int + successfulAttestations uint64 + genesisTime time.Time + invalidNetworkNodes map[common.Address]uint64 } // Create a new tree generator @@ -104,20 +102,20 @@ func (r *treeGeneratorImpl_v8) getRulesetVersion() uint64 { return r.rewardsFile.RulesetVersion } -func (r *treeGeneratorImpl_v8) generateTree(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, bc beacon.Client) (*GenerateTreeResult, error) { +func (r *treeGeneratorImpl_v8) generateTree(rp RewardsExecutionClient, networkName string, previousRewardsPoolAddresses []common.Address, bc RewardsBeaconClient) (*GenerateTreeResult, error) { r.log.Printlnf("%s Generating tree using Ruleset v%d.", r.logPrefix, r.rewardsFile.RulesetVersion) // Provision some struct params r.rp = rp - r.cfg = cfg + r.previousRewardsPoolAddresses = previousRewardsPoolAddresses r.bc = bc r.validNetworkCache = map[uint64]bool{ 0: true, } // Set the network name - r.rewardsFile.Network = fmt.Sprint(cfg.Smartnode.Network.Value) + r.rewardsFile.Network = networkName r.rewardsFile.MinipoolPerformanceFile.Network = r.rewardsFile.Network r.rewardsFile.MinipoolPerformanceFile.RewardsFileVersion = r.rewardsFile.RewardsFileVersion r.rewardsFile.MinipoolPerformanceFile.RulesetVersion = r.rewardsFile.RulesetVersion @@ -181,18 +179,17 @@ func (r *treeGeneratorImpl_v8) generateTree(rp *rocketpool.RocketPool, cfg *conf // Quickly calculates an approximate of the staker's share of the smoothing pool balance without processing Beacon performance // Used for approximate returns in the rETH ratio update -func (r *treeGeneratorImpl_v8) approximateStakerShareOfSmoothingPool(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, bc beacon.Client) (*big.Int, error) { +func (r *treeGeneratorImpl_v8) approximateStakerShareOfSmoothingPool(rp RewardsExecutionClient, networkName string, bc RewardsBeaconClient) (*big.Int, error) { r.log.Printlnf("%s Approximating tree using Ruleset v%d.", r.logPrefix, r.rewardsFile.RulesetVersion) r.rp = rp - r.cfg = cfg r.bc = bc r.validNetworkCache = map[uint64]bool{ 0: true, } // Set the network name - r.rewardsFile.Network = fmt.Sprint(cfg.Smartnode.Network.Value) + r.rewardsFile.Network = networkName r.rewardsFile.MinipoolPerformanceFile.Network = r.rewardsFile.Network r.rewardsFile.MinipoolPerformanceFile.RewardsFileVersion = r.rewardsFile.RewardsFileVersion r.rewardsFile.MinipoolPerformanceFile.RulesetVersion = r.rewardsFile.RulesetVersion @@ -536,7 +533,7 @@ func (r *treeGeneratorImpl_v8) calculateEthRewards(checkBeaconPerformance bool) // Get the start time of this interval based on the event from the previous one //previousIntervalEvent, err := GetRewardSnapshotEvent(r.rp, r.cfg, r.rewardsFile.Index-1, r.opts) // This is immutable so querying at the head is fine and mitigates issues around calls for pruned EL state - previousIntervalEvent, err := GetRewardSnapshotEvent(r.rp, r.cfg, r.rewardsFile.Index-1, nil) + previousIntervalEvent, err := r.rp.GetRewardSnapshotEvent(r.previousRewardsPoolAddresses, r.rewardsFile.Index-1, nil) if err != nil { return err } @@ -1117,7 +1114,7 @@ func (r *treeGeneratorImpl_v8) validateNetwork(network uint64) (bool, error) { valid, exists := r.validNetworkCache[network] if !exists { var err error - valid, err = tnsettings.GetNetworkEnabled(r.rp, big.NewInt(int64(network)), r.opts) + valid, err = r.rp.GetNetworkEnabled(big.NewInt(int64(network)), r.opts) if err != nil { return false, err } @@ -1164,7 +1161,7 @@ func (r *treeGeneratorImpl_v8) getStartBlocksForInterval(previousIntervalEvent r // We are pre-merge, so get the first block after the one from the previous interval r.rewardsFile.ExecutionStartBlock = previousIntervalEvent.ExecutionBlock.Uint64() + 1 r.rewardsFile.MinipoolPerformanceFile.ExecutionStartBlock = r.rewardsFile.ExecutionStartBlock - startElHeader, err = r.rp.Client.HeaderByNumber(context.Background(), big.NewInt(int64(r.rewardsFile.ExecutionStartBlock))) + startElHeader, err = r.rp.HeaderByNumber(context.Background(), big.NewInt(int64(r.rewardsFile.ExecutionStartBlock))) if err != nil { return nil, fmt.Errorf("error getting EL start block %d: %w", r.rewardsFile.ExecutionStartBlock, err) } @@ -1172,7 +1169,7 @@ func (r *treeGeneratorImpl_v8) getStartBlocksForInterval(previousIntervalEvent r // We are post-merge, so get the EL block corresponding to the BC block r.rewardsFile.ExecutionStartBlock = elBlockNumber r.rewardsFile.MinipoolPerformanceFile.ExecutionStartBlock = r.rewardsFile.ExecutionStartBlock - startElHeader, err = r.rp.Client.HeaderByNumber(context.Background(), big.NewInt(int64(elBlockNumber))) + startElHeader, err = r.rp.HeaderByNumber(context.Background(), big.NewInt(int64(elBlockNumber))) if err != nil { return nil, fmt.Errorf("error getting EL header for block %d: %w", elBlockNumber, err) } @@ -1207,6 +1204,6 @@ func (r *treeGeneratorImpl_v8) getMinipoolBondAndNodeFee(details *rpstate.Native return currentBond, currentFee } -func (r *treeGeneratorImpl_v8) saveFiles(treeResult *GenerateTreeResult, nodeTrusted bool) (cid.Cid, map[string]cid.Cid, error) { - return saveJSONArtifacts(r.cfg.Smartnode, treeResult, nodeTrusted) +func (r *treeGeneratorImpl_v8) saveFiles(smartnode *config.SmartnodeConfig, treeResult *GenerateTreeResult, nodeTrusted bool) (cid.Cid, map[string]cid.Cid, error) { + return saveJSONArtifacts(smartnode, treeResult, nodeTrusted) } diff --git a/shared/services/rewards/generator-impl-v9-rolling.go b/shared/services/rewards/generator-impl-v9-rolling.go index a1d2b00a8..8058f3559 100644 --- a/shared/services/rewards/generator-impl-v9-rolling.go +++ b/shared/services/rewards/generator-impl-v9-rolling.go @@ -11,8 +11,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ipfs/go-cid" - "github.com/rocket-pool/rocketpool-go/rocketpool" - tnsettings "github.com/rocket-pool/rocketpool-go/settings/trustednode" "github.com/rocket-pool/rocketpool-go/utils/eth" "github.com/rocket-pool/smartnode/shared/services/beacon" "github.com/rocket-pool/smartnode/shared/services/config" @@ -24,32 +22,32 @@ import ( // Implementation for tree generator ruleset v9 with rolling record support type treeGeneratorImpl_v9_rolling struct { - networkState *state.NetworkState - rewardsFile *ssz_types.SSZFile_v1 - elSnapshotHeader *types.Header - snapshotEnd *SnapshotEnd - log *log.ColorLogger - logPrefix string - rp *rocketpool.RocketPool - cfg *config.RocketPoolConfig - bc beacon.Client - opts *bind.CallOpts - smoothingPoolBalance *big.Int - intervalDutiesInfo *IntervalDutiesInfo - slotsPerEpoch uint64 - validatorIndexMap map[string]*MinipoolInfo - elStartTime time.Time - elEndTime time.Time - validNetworkCache map[uint64]bool - epsilon *big.Int - intervalSeconds *big.Int - beaconConfig beacon.Eth2Config - rollingRecord *RollingRecord - nodeDetails map[common.Address]*NodeSmoothingDetails - invalidNetworkNodes map[common.Address]uint64 - minipoolPerformanceFile *MinipoolPerformanceFile_v2 - nodeRewards map[common.Address]*ssz_types.NodeReward - networkRewards map[ssz_types.Layer]*ssz_types.NetworkReward + networkState *state.NetworkState + rewardsFile *ssz_types.SSZFile_v1 + elSnapshotHeader *types.Header + snapshotEnd *SnapshotEnd + log *log.ColorLogger + logPrefix string + rp RewardsExecutionClient + previousRewardsPoolAddresses []common.Address + bc RewardsBeaconClient + opts *bind.CallOpts + smoothingPoolBalance *big.Int + intervalDutiesInfo *IntervalDutiesInfo + slotsPerEpoch uint64 + validatorIndexMap map[string]*MinipoolInfo + elStartTime time.Time + elEndTime time.Time + validNetworkCache map[uint64]bool + epsilon *big.Int + intervalSeconds *big.Int + beaconConfig beacon.Eth2Config + rollingRecord *RollingRecord + nodeDetails map[common.Address]*NodeSmoothingDetails + invalidNetworkNodes map[common.Address]uint64 + minipoolPerformanceFile *MinipoolPerformanceFile_v2 + nodeRewards map[common.Address]*ssz_types.NodeReward + networkRewards map[ssz_types.Layer]*ssz_types.NetworkReward } // Create a new tree generator @@ -94,21 +92,21 @@ func (r *treeGeneratorImpl_v9_rolling) getRulesetVersion() uint64 { return r.rewardsFile.RulesetVersion } -func (r *treeGeneratorImpl_v9_rolling) generateTree(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, bc beacon.Client) (*GenerateTreeResult, error) { +func (r *treeGeneratorImpl_v9_rolling) generateTree(rp RewardsExecutionClient, networkName string, previousRewardsPoolAddresses []common.Address, bc RewardsBeaconClient) (*GenerateTreeResult, error) { r.log.Printlnf("%s Generating tree using Ruleset v%d.", r.logPrefix, r.rewardsFile.RulesetVersion) // Provision some struct params r.rp = rp - r.cfg = cfg + r.previousRewardsPoolAddresses = previousRewardsPoolAddresses r.bc = bc r.validNetworkCache = map[uint64]bool{ 0: true, } // Set the network name - r.rewardsFile.Network, _ = ssz_types.NetworkFromString(fmt.Sprint(cfg.Smartnode.Network.Value)) - r.minipoolPerformanceFile.Network = fmt.Sprint(cfg.Smartnode.Network.Value) + r.rewardsFile.Network, _ = ssz_types.NetworkFromString(networkName) + r.minipoolPerformanceFile.Network = networkName r.minipoolPerformanceFile.RewardsFileVersion = r.rewardsFile.RewardsFileVersion r.minipoolPerformanceFile.RulesetVersion = r.rewardsFile.RulesetVersion @@ -178,19 +176,18 @@ func (r *treeGeneratorImpl_v9_rolling) generateTree(rp *rocketpool.RocketPool, c // Quickly calculates an approximate of the staker's share of the smoothing pool balance without processing Beacon performance // Used for approximate returns in the rETH ratio update -func (r *treeGeneratorImpl_v9_rolling) approximateStakerShareOfSmoothingPool(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, bc beacon.Client) (*big.Int, error) { +func (r *treeGeneratorImpl_v9_rolling) approximateStakerShareOfSmoothingPool(rp RewardsExecutionClient, networkName string, bc RewardsBeaconClient) (*big.Int, error) { r.log.Printlnf("%s Approximating tree using Ruleset v%d.", r.logPrefix, r.rewardsFile.RulesetVersion) r.rp = rp - r.cfg = cfg r.bc = bc r.validNetworkCache = map[uint64]bool{ 0: true, } // Set the network name - r.rewardsFile.Network, _ = ssz_types.NetworkFromString(fmt.Sprint(cfg.Smartnode.Network.Value)) - r.minipoolPerformanceFile.Network = fmt.Sprint(cfg.Smartnode.Network.Value) + r.rewardsFile.Network, _ = ssz_types.NetworkFromString(networkName) + r.minipoolPerformanceFile.Network = networkName r.minipoolPerformanceFile.RewardsFileVersion = r.rewardsFile.RewardsFileVersion r.minipoolPerformanceFile.RulesetVersion = r.rewardsFile.RulesetVersion @@ -647,7 +644,7 @@ func (r *treeGeneratorImpl_v9_rolling) validateNetwork(network uint64) (bool, er valid, exists := r.validNetworkCache[network] if !exists { var err error - valid, err = tnsettings.GetNetworkEnabled(r.rp, big.NewInt(int64(network)), r.opts) + valid, err = r.rp.GetNetworkEnabled(big.NewInt(int64(network)), r.opts) if err != nil { return false, err } @@ -675,7 +672,7 @@ func (r *treeGeneratorImpl_v9_rolling) getBlocksAndTimesForInterval() (*types.He elBlockNumber := beaconBlock.ExecutionBlockNumber r.rewardsFile.ExecutionStartBlock = elBlockNumber r.minipoolPerformanceFile.ExecutionStartBlock = r.rewardsFile.ExecutionStartBlock - startElHeader, err := r.rp.Client.HeaderByNumber(context.Background(), big.NewInt(int64(elBlockNumber))) + startElHeader, err := r.rp.HeaderByNumber(context.Background(), big.NewInt(int64(elBlockNumber))) if err != nil { return nil, fmt.Errorf("error getting EL header for block %d: %w", elBlockNumber, err) } @@ -719,6 +716,6 @@ func (r *treeGeneratorImpl_v9_rolling) getCheaters() map[common.Address]bool { return cheatingNodes } -func (r *treeGeneratorImpl_v9_rolling) saveFiles(treeResult *GenerateTreeResult, nodeTrusted bool) (cid.Cid, map[string]cid.Cid, error) { - return saveRewardsArtifacts(r.cfg.Smartnode, treeResult, nodeTrusted) +func (r *treeGeneratorImpl_v9_rolling) saveFiles(smartnode *config.SmartnodeConfig, treeResult *GenerateTreeResult, nodeTrusted bool) (cid.Cid, map[string]cid.Cid, error) { + return saveRewardsArtifacts(smartnode, treeResult, nodeTrusted) } diff --git a/shared/services/rewards/generator-impl-v9.go b/shared/services/rewards/generator-impl-v9.go index 6d7d1e9b1..59af5b793 100644 --- a/shared/services/rewards/generator-impl-v9.go +++ b/shared/services/rewards/generator-impl-v9.go @@ -12,8 +12,6 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ipfs/go-cid" "github.com/rocket-pool/rocketpool-go/rewards" - "github.com/rocket-pool/rocketpool-go/rocketpool" - tnsettings "github.com/rocket-pool/rocketpool-go/settings/trustednode" rptypes "github.com/rocket-pool/rocketpool-go/types" "github.com/rocket-pool/rocketpool-go/utils/eth" rpstate "github.com/rocket-pool/rocketpool-go/utils/state" @@ -31,35 +29,35 @@ var _ IRewardsFile = (*ssz_types.SSZFile_v1)(nil) // Implementation for tree generator ruleset v9 type treeGeneratorImpl_v9 struct { - networkState *state.NetworkState - rewardsFile *ssz_types.SSZFile_v1 - elSnapshotHeader *types.Header - snapshotEnd *SnapshotEnd - log *log.ColorLogger - logPrefix string - rp *rocketpool.RocketPool - cfg *config.RocketPoolConfig - bc beacon.Client - opts *bind.CallOpts - nodeDetails []*NodeSmoothingDetails - smoothingPoolBalance *big.Int - intervalDutiesInfo *IntervalDutiesInfo - slotsPerEpoch uint64 - validatorIndexMap map[string]*MinipoolInfo - elStartTime time.Time - elEndTime time.Time - validNetworkCache map[uint64]bool - epsilon *big.Int - intervalSeconds *big.Int - beaconConfig beacon.Eth2Config - validatorStatusMap map[rptypes.ValidatorPubkey]beacon.ValidatorStatus - totalAttestationScore *big.Int - successfulAttestations uint64 - genesisTime time.Time - invalidNetworkNodes map[common.Address]uint64 - minipoolPerformanceFile *MinipoolPerformanceFile_v2 - nodeRewards map[common.Address]*ssz_types.NodeReward - networkRewards map[ssz_types.Layer]*ssz_types.NetworkReward + networkState *state.NetworkState + rewardsFile *ssz_types.SSZFile_v1 + elSnapshotHeader *types.Header + snapshotEnd *SnapshotEnd + log *log.ColorLogger + logPrefix string + rp RewardsExecutionClient + previousRewardsPoolAddresses []common.Address + bc RewardsBeaconClient + opts *bind.CallOpts + nodeDetails []*NodeSmoothingDetails + smoothingPoolBalance *big.Int + intervalDutiesInfo *IntervalDutiesInfo + slotsPerEpoch uint64 + validatorIndexMap map[string]*MinipoolInfo + elStartTime time.Time + elEndTime time.Time + validNetworkCache map[uint64]bool + epsilon *big.Int + intervalSeconds *big.Int + beaconConfig beacon.Eth2Config + validatorStatusMap map[rptypes.ValidatorPubkey]beacon.ValidatorStatus + totalAttestationScore *big.Int + successfulAttestations uint64 + genesisTime time.Time + invalidNetworkNodes map[common.Address]uint64 + minipoolPerformanceFile *MinipoolPerformanceFile_v2 + nodeRewards map[common.Address]*ssz_types.NodeReward + networkRewards map[ssz_types.Layer]*ssz_types.NetworkReward } // Create a new tree generator @@ -105,21 +103,21 @@ func (r *treeGeneratorImpl_v9) getRulesetVersion() uint64 { return r.rewardsFile.RulesetVersion } -func (r *treeGeneratorImpl_v9) generateTree(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, bc beacon.Client) (*GenerateTreeResult, error) { +func (r *treeGeneratorImpl_v9) generateTree(rp RewardsExecutionClient, networkName string, previousRewardsPoolAddresses []common.Address, bc RewardsBeaconClient) (*GenerateTreeResult, error) { r.log.Printlnf("%s Generating tree using Ruleset v%d.", r.logPrefix, r.rewardsFile.RulesetVersion) // Provision some struct params r.rp = rp - r.cfg = cfg + r.previousRewardsPoolAddresses = previousRewardsPoolAddresses r.bc = bc r.validNetworkCache = map[uint64]bool{ 0: true, } // Set the network name - r.rewardsFile.Network, _ = ssz_types.NetworkFromString(fmt.Sprint(cfg.Smartnode.Network.Value)) - r.minipoolPerformanceFile.Network = fmt.Sprint(cfg.Smartnode.Network.Value) + r.rewardsFile.Network, _ = ssz_types.NetworkFromString(networkName) + r.minipoolPerformanceFile.Network = networkName r.minipoolPerformanceFile.RewardsFileVersion = r.rewardsFile.RewardsFileVersion r.minipoolPerformanceFile.RulesetVersion = r.rewardsFile.RulesetVersion @@ -190,19 +188,18 @@ func (r *treeGeneratorImpl_v9) generateTree(rp *rocketpool.RocketPool, cfg *conf // Quickly calculates an approximate of the staker's share of the smoothing pool balance without processing Beacon performance // Used for approximate returns in the rETH ratio update -func (r *treeGeneratorImpl_v9) approximateStakerShareOfSmoothingPool(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, bc beacon.Client) (*big.Int, error) { +func (r *treeGeneratorImpl_v9) approximateStakerShareOfSmoothingPool(rp RewardsExecutionClient, networkName string, bc RewardsBeaconClient) (*big.Int, error) { r.log.Printlnf("%s Approximating tree using Ruleset v%d.", r.logPrefix, r.rewardsFile.RulesetVersion) r.rp = rp - r.cfg = cfg r.bc = bc r.validNetworkCache = map[uint64]bool{ 0: true, } // Set the network name - r.rewardsFile.Network, _ = ssz_types.NetworkFromString(fmt.Sprint(cfg.Smartnode.Network.Value)) - r.minipoolPerformanceFile.Network = fmt.Sprint(cfg.Smartnode.Network.Value) + r.rewardsFile.Network, _ = ssz_types.NetworkFromString(networkName) + r.minipoolPerformanceFile.Network = networkName r.minipoolPerformanceFile.RewardsFileVersion = r.rewardsFile.RewardsFileVersion r.minipoolPerformanceFile.RulesetVersion = r.rewardsFile.RulesetVersion @@ -505,7 +502,7 @@ func (r *treeGeneratorImpl_v9) calculateEthRewards(checkBeaconPerformance bool) // Get the start time of this interval based on the event from the previous one //previousIntervalEvent, err := GetRewardSnapshotEvent(r.rp, r.cfg, r.rewardsFile.Index-1, r.opts) // This is immutable so querying at the head is fine and mitigates issues around calls for pruned EL state - previousIntervalEvent, err := GetRewardSnapshotEvent(r.rp, r.cfg, r.rewardsFile.Index-1, nil) + previousIntervalEvent, err := r.rp.GetRewardSnapshotEvent(r.previousRewardsPoolAddresses, r.rewardsFile.Index-1, r.opts) if err != nil { return err } @@ -1076,7 +1073,7 @@ func (r *treeGeneratorImpl_v9) validateNetwork(network uint64) (bool, error) { valid, exists := r.validNetworkCache[network] if !exists { var err error - valid, err = tnsettings.GetNetworkEnabled(r.rp, big.NewInt(int64(network)), r.opts) + valid, err = r.rp.GetNetworkEnabled(big.NewInt(int64(network)), r.opts) if err != nil { return false, err } @@ -1140,7 +1137,7 @@ func (r *treeGeneratorImpl_v9) getBlocksAndTimesForInterval(previousIntervalEven // We are pre-merge, so get the first block after the one from the previous interval r.rewardsFile.ExecutionStartBlock = previousIntervalEvent.ExecutionBlock.Uint64() + 1 r.minipoolPerformanceFile.ExecutionStartBlock = r.rewardsFile.ExecutionStartBlock - startElHeader, err = r.rp.Client.HeaderByNumber(context.Background(), big.NewInt(int64(r.rewardsFile.ExecutionStartBlock))) + startElHeader, err = r.rp.HeaderByNumber(context.Background(), big.NewInt(int64(r.rewardsFile.ExecutionStartBlock))) if err != nil { return nil, fmt.Errorf("error getting EL start block %d: %w", r.rewardsFile.ExecutionStartBlock, err) } @@ -1148,7 +1145,7 @@ func (r *treeGeneratorImpl_v9) getBlocksAndTimesForInterval(previousIntervalEven // We are post-merge, so get the EL block corresponding to the BC block r.rewardsFile.ExecutionStartBlock = elBlockNumber r.minipoolPerformanceFile.ExecutionStartBlock = r.rewardsFile.ExecutionStartBlock - startElHeader, err = r.rp.Client.HeaderByNumber(context.Background(), big.NewInt(int64(elBlockNumber))) + startElHeader, err = r.rp.HeaderByNumber(context.Background(), big.NewInt(int64(elBlockNumber))) if err != nil { return nil, fmt.Errorf("error getting EL header for block %d: %w", elBlockNumber, err) } @@ -1183,6 +1180,6 @@ func (r *treeGeneratorImpl_v9) getMinipoolBondAndNodeFee(details *rpstate.Native return currentBond, currentFee } -func (r *treeGeneratorImpl_v9) saveFiles(treeResult *GenerateTreeResult, nodeTrusted bool) (cid.Cid, map[string]cid.Cid, error) { - return saveRewardsArtifacts(r.cfg.Smartnode, treeResult, nodeTrusted) +func (r *treeGeneratorImpl_v9) saveFiles(smartnode *config.SmartnodeConfig, treeResult *GenerateTreeResult, nodeTrusted bool) (cid.Cid, map[string]cid.Cid, error) { + return saveRewardsArtifacts(smartnode, treeResult, nodeTrusted) } diff --git a/shared/services/rewards/generator-v8_test.go b/shared/services/rewards/generator-v8_test.go new file mode 100644 index 000000000..01703c0ed --- /dev/null +++ b/shared/services/rewards/generator-v8_test.go @@ -0,0 +1,152 @@ +package rewards + +import ( + "fmt" + "math/big" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/fatih/color" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/rewards/test" + "github.com/rocket-pool/smartnode/shared/services/rewards/test/assets" + "github.com/rocket-pool/smartnode/shared/services/state" + "github.com/rocket-pool/smartnode/shared/utils/log" +) + +type v8Test struct { + *testing.T + rp *test.MockRocketPool + bc *test.MockBeaconClient +} + +func (t *v8Test) saveArtifacts(prefix string, result *GenerateTreeResult) { + tmpDir, err := os.MkdirTemp("", fmt.Sprintf("artifacts-%s", t.Name())) + t.failIf(err) + rewardsLocalFile := LocalFile[IRewardsFile]{ + fullPath: filepath.Join(tmpDir, fmt.Sprintf("%s-rewards.json", prefix)), + f: result.RewardsFile, + } + performanceLocalFile := LocalFile[IMinipoolPerformanceFile]{ + fullPath: filepath.Join(tmpDir, fmt.Sprintf("%s-minipool-performance.json", prefix)), + f: result.MinipoolPerformanceFile, + } + _, err = rewardsLocalFile.Write() + t.failIf(err) + _, err = performanceLocalFile.Write() + t.failIf(err) + + t.Logf("wrote artifacts to %s\n", tmpDir) +} + +func newV8Test(t *testing.T) *v8Test { + rp := test.NewMockRocketPool(t) + out := &v8Test{ + T: t, + rp: rp, + bc: test.NewMockBeaconClient(t), + } + return out +} + +func (t *v8Test) failIf(err error) { + if err != nil { + t.Fatalf(err.Error()) + } +} + +func (t *v8Test) SetMinipoolPerformance(canonicalMinipoolPerformance IMinipoolPerformanceFile, networkState *state.NetworkState) { + addresses := canonicalMinipoolPerformance.GetMinipoolAddresses() + for _, address := range addresses { + + // Get the minipool's performance + perf, ok := canonicalMinipoolPerformance.GetSmoothingPoolPerformance(address) + if !ok { + t.Fatalf("Minipool %s not found in canonical minipool performance, despite being listed as present", address.Hex()) + } + missedSlots := perf.GetMissingAttestationSlots() + pubkey, err := perf.GetPubkey() + + // Get the minipool's validator index + validatorStatus := networkState.ValidatorDetails[pubkey] + + if err != nil { + t.Fatalf("Minipool %s pubkey could not be parsed: %s", address.Hex(), err.Error()) + } + t.bc.SetMinipoolPerformance(validatorStatus.Index, missedSlots) + } +} + +// TestV8Mainnet builds a tree using serialized state for a mainnet interval that used v8 +// and checks that the resulting artifacts match their canonical values. +func TestV8Mainnet(tt *testing.T) { + t := newV8Test(tt) + + canonical, err := DeserializeRewardsFile(assets.GetMainnet20RewardsJSON()) + t.failIf(err) + + canonicalPerformance, err := DeserializeMinipoolPerformanceFile(assets.GetMainnet20MinipoolPerformanceJSON()) + t.failIf(err) + + state := assets.GetMainnet20RewardsState() + t.Logf("pending rpl rewards: %s", state.NetworkDetails.PendingRPLRewards.String()) + + t.bc.SetState(state) + + // Some interval info needed for mocks + consensusStartBlock := canonical.GetConsensusStartBlock() + executionStartBlock := canonical.GetExecutionStartBlock() + consensusEndBlock := canonical.GetConsensusEndBlock() + + // Create a new treeGeneratorImpl_v8 + logger := log.NewColorLogger(color.Faint) + generator := newTreeGeneratorImpl_v8( + &logger, + t.Name(), + state.NetworkDetails.RewardIndex, + canonical.GetStartTime(), + canonical.GetEndTime(), + consensusEndBlock, + &types.Header{ + Number: big.NewInt(int64(canonical.GetExecutionEndBlock())), + Time: assets.Mainnet20ELHeaderTime, + }, + canonical.GetIntervalsPassed(), + state, + ) + + // Load the mock up + t.rp.SetRewardSnapshotEvent(assets.GetRewardSnapshotEventInterval19()) + t.bc.SetBeaconBlock(fmt.Sprint(consensusStartBlock-1), beacon.BeaconBlock{ExecutionBlockNumber: executionStartBlock - 1}) + t.bc.SetBeaconBlock(fmt.Sprint(consensusStartBlock), beacon.BeaconBlock{ExecutionBlockNumber: executionStartBlock}) + t.rp.SetHeaderByNumber(big.NewInt(int64(executionStartBlock)), &types.Header{Time: uint64(canonical.GetStartTime().Unix())}) + + // Set the critical duties slots + t.bc.SetCriticalDutiesSlots(assets.GetMainnet20CriticalDutiesSlots()) + + // Set the minipool performance + t.SetMinipoolPerformance(canonicalPerformance, state) + + artifacts, err := generator.generateTree( + t.rp, + "mainnet", + make([]common.Address, 0), + t.bc, + ) + t.failIf(err) + + // Save the artifacts if verbose mode is enabled + if testing.Verbose() { + t.saveArtifacts("", artifacts) + } + + t.Logf("merkle root: %s\n", artifacts.RewardsFile.GetMerkleRoot()) + if artifacts.RewardsFile.GetMerkleRoot() != canonical.GetMerkleRoot() { + t.Fatalf("Merkle root does not match %s", canonical.GetMerkleRoot()) + } else { + t.Logf("merkle root matches %s", canonical.GetMerkleRoot()) + } +} diff --git a/shared/services/rewards/generator.go b/shared/services/rewards/generator.go index e7919e5a2..de6a83fc9 100644 --- a/shared/services/rewards/generator.go +++ b/shared/services/rewards/generator.go @@ -61,7 +61,7 @@ type TreeGenerator struct { rewardsIntervalInfos map[uint64]rewardsIntervalInfo logger *log.ColorLogger logPrefix string - rp *rocketpool.RocketPool + rp *defaultRewardsExecutionClient cfg *config.RocketPoolConfig bc beacon.Client index uint64 @@ -84,18 +84,18 @@ type SnapshotEnd struct { } type treeGeneratorImpl interface { - generateTree(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, bc beacon.Client) (*GenerateTreeResult, error) - approximateStakerShareOfSmoothingPool(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, bc beacon.Client) (*big.Int, error) + generateTree(rp RewardsExecutionClient, networkName string, previousRewardsPoolAddresses []common.Address, bc RewardsBeaconClient) (*GenerateTreeResult, error) + approximateStakerShareOfSmoothingPool(rp RewardsExecutionClient, networkName string, bc RewardsBeaconClient) (*big.Int, error) getRulesetVersion() uint64 // Returns the primary artifact cid for consensus, all cids of all files in a map, and any potential errors - saveFiles(treeResult *GenerateTreeResult, nodeTrusted bool) (cid.Cid, map[string]cid.Cid, error) + saveFiles(smartnode *config.SmartnodeConfig, treeResult *GenerateTreeResult, nodeTrusted bool) (cid.Cid, map[string]cid.Cid, error) } func NewTreeGenerator(logger *log.ColorLogger, logPrefix string, rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, bc beacon.Client, index uint64, startTime time.Time, endTime time.Time, snapshotEnd *SnapshotEnd, elSnapshotHeader *types.Header, intervalsPassed uint64, state *state.NetworkState, rollingRecord *RollingRecord) (*TreeGenerator, error) { t := &TreeGenerator{ logger: logger, logPrefix: logPrefix, - rp: rp, + rp: &defaultRewardsExecutionClient{rp}, cfg: cfg, bc: bc, index: index, @@ -192,11 +192,11 @@ type GenerateTreeResult struct { } func (t *TreeGenerator) GenerateTree() (*GenerateTreeResult, error) { - return t.generatorImpl.generateTree(t.rp, t.cfg, t.bc) + return t.generatorImpl.generateTree(t.rp, fmt.Sprint(t.cfg.Smartnode.Network.Value), t.cfg.Smartnode.GetPreviousRewardsPoolAddresses(), t.bc) } func (t *TreeGenerator) ApproximateStakerShareOfSmoothingPool() (*big.Int, error) { - return t.approximatorImpl.approximateStakerShareOfSmoothingPool(t.rp, t.cfg, t.bc) + return t.approximatorImpl.approximateStakerShareOfSmoothingPool(t.rp, fmt.Sprint(t.cfg.Smartnode.Network.Value), t.bc) } func (t *TreeGenerator) GetGeneratorRulesetVersion() uint64 { @@ -213,7 +213,12 @@ func (t *TreeGenerator) GenerateTreeWithRuleset(ruleset uint64) (*GenerateTreeRe return nil, fmt.Errorf("ruleset v%d does not exist", ruleset) } - return info.generator.generateTree(t.rp, t.cfg, t.bc) + return info.generator.generateTree( + t.rp, + fmt.Sprint(t.cfg.Smartnode.Network.Value), + t.cfg.Smartnode.GetPreviousRewardsPoolAddresses(), + t.bc, + ) } func (t *TreeGenerator) ApproximateStakerShareOfSmoothingPoolWithRuleset(ruleset uint64) (*big.Int, error) { @@ -222,9 +227,9 @@ func (t *TreeGenerator) ApproximateStakerShareOfSmoothingPoolWithRuleset(ruleset return nil, fmt.Errorf("ruleset v%d does not exist", ruleset) } - return info.generator.approximateStakerShareOfSmoothingPool(t.rp, t.cfg, t.bc) + return info.generator.approximateStakerShareOfSmoothingPool(t.rp, fmt.Sprint(t.cfg.Smartnode.Network.Value), t.bc) } func (t *TreeGenerator) SaveFiles(treeResult *GenerateTreeResult, nodeTrusted bool) (cid.Cid, map[string]cid.Cid, error) { - return t.generatorImpl.saveFiles(treeResult, nodeTrusted) + return t.generatorImpl.saveFiles(t.cfg.Smartnode, treeResult, nodeTrusted) } diff --git a/shared/services/rewards/mock_test.go b/shared/services/rewards/mock_test.go new file mode 100644 index 000000000..e6f27b691 --- /dev/null +++ b/shared/services/rewards/mock_test.go @@ -0,0 +1,316 @@ +package rewards + +// This file contains treegen tests which use mock history. +// These mocks are faster to process than real history, and are useful for +// testing new features and refactoring. + +import ( + "fmt" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/fatih/color" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/rewards/test" + "github.com/rocket-pool/smartnode/shared/services/rewards/test/assets" + "github.com/rocket-pool/smartnode/shared/utils/log" +) + +func TestMockIntervalDefaultsTreegenv8v9(tt *testing.T) { + t := newV8Test(tt) + + history := test.NewDefaultMockHistory() + state := history.GetEndNetworkState() + + t.bc.SetState(state) + + consensusStartBlock := history.GetConsensusStartBlock() + executionStartBlock := history.GetExecutionStartBlock() + consensusEndBlock := history.GetConsensusEndBlock() + executionEndBlock := history.GetExecutionEndBlock() + + logger := log.NewColorLogger(color.Faint) + generator := newTreeGeneratorImpl_v8( + &logger, + t.Name(), + state.NetworkDetails.RewardIndex, + history.GetStartTime(), + history.GetEndTime(), + consensusEndBlock, + &types.Header{ + Number: big.NewInt(int64(history.GetExecutionEndBlock())), + Time: assets.Mainnet20ELHeaderTime, + }, + /* intervalsPassed= */ 1, + state, + ) + + t.rp.SetRewardSnapshotEvent(history.GetPreviousRewardSnapshotEvent()) + t.bc.SetBeaconBlock(fmt.Sprint(consensusStartBlock-1), beacon.BeaconBlock{ExecutionBlockNumber: executionStartBlock - 1}) + t.bc.SetBeaconBlock(fmt.Sprint(consensusStartBlock), beacon.BeaconBlock{ExecutionBlockNumber: executionStartBlock}) + t.rp.SetHeaderByNumber(big.NewInt(int64(executionStartBlock)), &types.Header{Time: uint64(history.GetStartTime().Unix())}) + + for _, validator := range state.ValidatorDetails { + t.bc.SetMinipoolPerformance(validator.Index, make([]uint64, 0)) + } + + v8Artifacts, err := generator.generateTree( + t.rp, + "mainnet", + make([]common.Address, 0), + t.bc, + ) + t.failIf(err) + + if testing.Verbose() { + t.saveArtifacts("v8", v8Artifacts) + } + generatorv9 := newTreeGeneratorImpl_v9( + &logger, + t.Name(), + state.NetworkDetails.RewardIndex, + &SnapshotEnd{ + Slot: consensusEndBlock, + ConsensusBlock: consensusEndBlock, + ExecutionBlock: executionEndBlock, + }, + &types.Header{ + Number: big.NewInt(int64(history.GetExecutionEndBlock())), + Time: assets.Mainnet20ELHeaderTime, + }, + /* intervalsPassed= */ 1, + state, + ) + + v9Artifacts, err := generatorv9.generateTree( + t.rp, + "mainnet", + make([]common.Address, 0), + t.bc, + ) + t.failIf(err) + + if testing.Verbose() { + t.saveArtifacts("v9", v9Artifacts) + } + + // Validate individual node details in the rewards file + rewardsFile := v8Artifacts.RewardsFile + nodeSummary := history.GetNodeSummary() + + singleEightEthNodes := nodeSummary["single_eight_eth"] + singleSixteenEthNodes := nodeSummary["single_sixteen_eth"] + for _, node := range append(singleEightEthNodes, singleSixteenEthNodes...) { + // Check the rewards amount in the rewards file + rewardsAmount := rewardsFile.GetNodeCollateralRpl(node.Address) + + expectedRewardsAmount, _ := big.NewInt(0).SetString("1019308880071990649542", 10) + + if rewardsAmount.Cmp(expectedRewardsAmount) != 0 { + t.Fatalf("Rewards amount does not match expected value for node %s: %s != %s", node.Notes, rewardsAmount.String(), expectedRewardsAmount.String()) + } + + // Make sure it got 0 ETH + ethAmount := rewardsFile.GetNodeSmoothingPoolEth(node.Address) + if ethAmount.Sign() != 0 { + t.Fatalf("ETH amount does not match expected value for node %s: %s != %s", node.Notes, ethAmount.String(), "0") + } + + // Make sure it got 0 oDAO rpl + oDaoRplAmount := rewardsFile.GetNodeOracleDaoRpl(node.Address) + if oDaoRplAmount.Sign() != 0 { + t.Fatalf("oDAO rpl amount does not match expected value for node %s: %s != %s", node.Notes, oDaoRplAmount.String(), "0") + } + } + singleEightEthNodesSP := nodeSummary["single_eight_eth_sp"] + singleSixteenEthNodesSP := nodeSummary["single_sixteen_eth_sp"] + for _, node := range append(singleEightEthNodesSP, singleSixteenEthNodesSP...) { + // Check the rewards amount in the rewards file + rewardsAmount := rewardsFile.GetNodeCollateralRpl(node.Address) + + expectedRewardsAmount, _ := big.NewInt(0).SetString("1019308880071990649542", 10) + + if rewardsAmount.Cmp(expectedRewardsAmount) != 0 { + t.Fatalf("Rewards amount does not match expected value for node %s: %s != %s", node.Notes, rewardsAmount.String(), expectedRewardsAmount.String()) + } + + // Make sure it got ETH + ethAmount := rewardsFile.GetNodeSmoothingPoolEth(node.Address) + expectedEthAmount := big.NewInt(0) + if node.SmoothingPoolRegistrationState { + if node.Class == "single_eight_eth_sp" { + expectedEthAmount.SetString("1354725546842756912", 10) + } else { + // 16-eth minipools earn more eth! A bit less than double. + expectedEthAmount.SetString("2292612463887742467", 10) + } + } + if ethAmount.Cmp(expectedEthAmount) != 0 { + t.Fatalf("ETH amount does not match expected value for node %s: %s != %s", node.Notes, ethAmount.String(), expectedEthAmount.String()) + } + + // Make sure it got 0 oDAO rpl + oDaoRplAmount := rewardsFile.GetNodeOracleDaoRpl(node.Address) + if oDaoRplAmount.Sign() != 0 { + t.Fatalf("oDAO rpl amount does not match expected value for node %s: %s != %s", node.Notes, oDaoRplAmount.String(), "0") + } + } + + optingInNodesSP := append( + nodeSummary["single_eight_eth_opted_in_quarter"], + nodeSummary["single_sixteen_eth_opted_in_quarter"]..., + ) + for _, node := range optingInNodesSP { + // Check the rewards amount in the rewards file + rewardsAmount := rewardsFile.GetNodeCollateralRpl(node.Address) + + // Node has 20 RPL and only 1 8-eth minpool which puts it above the linear curve + expectedRewardsAmount := big.NewInt(0) + if node.Class == "single_eight_eth_opted_in_quarter" { + expectedRewardsAmount.SetString("1784353229014464268647", 10) + } else { + // 16-eth minipools earn less for the same RPL stake, due to RPIP-30 + expectedRewardsAmount.SetString("1310160289473732090952", 10) + } + + if rewardsAmount.Cmp(expectedRewardsAmount) != 0 { + t.Fatalf("Rewards amount does not match expected value for node %s: %s != %s", node.Notes, rewardsAmount.String(), expectedRewardsAmount.String()) + } + + // Make sure it got ETH + ethAmount := rewardsFile.GetNodeSmoothingPoolEth(node.Address) + expectedEthAmount := big.NewInt(0) + if node.Class == "single_eight_eth_opted_in_quarter" { + // About 3/4 what the full nodes got + expectedEthAmount.SetString("1019397441188609162", 10) + } else { + // 16-eth minipools earn more eth! A bit less than double. + expectedEthAmount.SetString("1725134131242261659", 10) + } + if ethAmount.Cmp(expectedEthAmount) != 0 { + t.Fatalf("ETH amount does not match expected value for node %s: %s != %s", node.Notes, ethAmount.String(), expectedEthAmount.String()) + } + + // Make sure it got 0 oDAO rpl + oDaoRplAmount := rewardsFile.GetNodeOracleDaoRpl(node.Address) + if oDaoRplAmount.Sign() != 0 { + t.Fatalf("oDAO rpl amount does not match expected value for node %s: %s != %s", node.Notes, oDaoRplAmount.String(), "0") + } + } + + optingOutNodesSP := append( + nodeSummary["single_eight_eth_opted_out_three_quarters"], + nodeSummary["single_sixteen_eth_opted_out_three_quarters"]..., + ) + for _, node := range optingOutNodesSP { + // Check the rewards amount in the rewards file + rewardsAmount := rewardsFile.GetNodeCollateralRpl(node.Address) + + // Node has 20 RPL and only 1 8-eth minpool which puts it above the linear curve + expectedRewardsAmount := big.NewInt(0) + if node.Class == "single_eight_eth_opted_out_three_quarters" { + expectedRewardsAmount.SetString("1784353229014464268647", 10) + } else { + // 16-eth minipools earn less for the same RPL stake, due to RPIP-30 + expectedRewardsAmount.SetString("1310160289473732090952", 10) + } + + if rewardsAmount.Cmp(expectedRewardsAmount) != 0 { + t.Fatalf("Rewards amount does not match expected value for node %s: %s != %s", node.Notes, rewardsAmount.String(), expectedRewardsAmount.String()) + } + + // Make sure it got ETH + ethAmount := rewardsFile.GetNodeSmoothingPoolEth(node.Address) + expectedEthAmount := big.NewInt(0) + if node.Class == "single_eight_eth_opted_out_three_quarters" { + // About 3/4 what the full nodes got + expectedEthAmount.SetString("1005984316962443252", 10) + } else { + // 16-eth minipools earn more eth! A bit less than double. + expectedEthAmount.SetString("1702434997936442426", 10) + } + if ethAmount.Cmp(expectedEthAmount) != 0 { + t.Fatalf("ETH amount does not match expected value for node %s: %s != %s", node.Notes, ethAmount.String(), expectedEthAmount.String()) + } + + // Make sure it got 0 oDAO rpl + oDaoRplAmount := rewardsFile.GetNodeOracleDaoRpl(node.Address) + if oDaoRplAmount.Sign() != 0 { + t.Fatalf("oDAO rpl amount does not match expected value for node %s: %s != %s", node.Notes, oDaoRplAmount.String(), "0") + } + } + + bondReductionNode := nodeSummary["single_bond_reduction"] + for _, node := range bondReductionNode { + // Check the rewards amount in the rewards file + rewardsAmount := rewardsFile.GetNodeCollateralRpl(node.Address) + + // Nodes that bond reduce are treated as having their new bond for the full interval, + // when it comes to RPL rewards. + expectedRewardsAmount, _ := big.NewInt(0).SetString("1019308880071990649542", 10) + + if rewardsAmount.Cmp(expectedRewardsAmount) != 0 { + t.Fatalf("Rewards amount does not match expected value for node %s: %s != %s", node.Notes, rewardsAmount.String(), expectedRewardsAmount.String()) + } + + // Make sure it got reduced ETH + ethAmount := rewardsFile.GetNodeSmoothingPoolEth(node.Address) + expectedEthAmount, _ := big.NewInt(0).SetString("1922203879488237721", 10) + if ethAmount.Cmp(expectedEthAmount) != 0 { + t.Fatalf("ETH amount does not match expected value for node %s: %s != %s", node.Notes, ethAmount.String(), expectedEthAmount.String()) + } + + // Make sure it got 0 oDAO rpl + oDaoRplAmount := rewardsFile.GetNodeOracleDaoRpl(node.Address) + if oDaoRplAmount.Sign() != 0 { + t.Fatalf("oDAO rpl amount does not match expected value for node %s: %s != %s", node.Notes, oDaoRplAmount.String(), "0") + } + } + + noMinipoolsNodes := nodeSummary["no_minipools"] + for _, node := range noMinipoolsNodes { + // Check the rewards amount in the rewards file + rewardsAmount := rewardsFile.GetNodeCollateralRpl(node.Address) + if rewardsAmount.Sign() != 0 { + t.Fatalf("Rewards amount does not match expected value for node %s: %s != %s", node.Notes, rewardsAmount.String(), "0") + } + + // Make sure it got ETH + ethAmount := rewardsFile.GetNodeSmoothingPoolEth(node.Address) + if ethAmount.Sign() != 0 { + t.Fatalf("ETH amount does not match expected value for node %s: %s != %s", node.Notes, ethAmount.String(), "0") + } + + // Make sure it got 0 oDAO rpl + oDaoRplAmount := rewardsFile.GetNodeOracleDaoRpl(node.Address) + if oDaoRplAmount.Sign() != 0 { + t.Fatalf("oDAO rpl amount does not match expected value for node %s: %s != %s", node.Notes, oDaoRplAmount.String(), "0") + } + } + + // Validate merkle root + v8MerkleRoot := v8Artifacts.RewardsFile.GetMerkleRoot() + v9MerkleRoot := v9Artifacts.RewardsFile.GetMerkleRoot() + + if !strings.EqualFold(v8MerkleRoot, v9MerkleRoot) { + t.Fatalf("Merkle root does not match %s != %s", v8MerkleRoot, v9MerkleRoot) + } else { + t.Logf("v8/v9 Merkle root matches %s", v8MerkleRoot) + } + + // Expected merkle root: + // 0x9915d949936995f9045d26c3ef919194445377e83f1be2da47d181ee9ce705d8 + // + // If this does not match, it implies either you updated the set of default mock nodes, + // or you introduced a regression in treegen. + // DO NOT update this value unless you know what you are doing. + expectedMerkleRoot := "0x9915d949936995f9045d26c3ef919194445377e83f1be2da47d181ee9ce705d8" + if !strings.EqualFold(v8MerkleRoot, expectedMerkleRoot) { + t.Fatalf("Merkle root does not match expected value %s != %s", v8MerkleRoot, expectedMerkleRoot) + } else { + t.Logf("Merkle root matches expected value %s", expectedMerkleRoot) + } +} diff --git a/shared/services/rewards/rewards-file-v1.go b/shared/services/rewards/rewards-file-v1.go index 452f012fc..0446d994c 100644 --- a/shared/services/rewards/rewards-file-v1.go +++ b/shared/services/rewards/rewards-file-v1.go @@ -191,6 +191,16 @@ func (f *RewardsFile_v1) GetTotalNodeOperatorSmoothingPoolEth() *big.Int { return &f.RewardsFileHeader.TotalRewards.NodeOperatorSmoothingPoolEth.Int } +// Get the the execution start block +func (f *RewardsFile_v1) GetExecutionStartBlock() uint64 { + return f.RewardsFileHeader.ExecutionStartBlock +} + +// Get the the consensus start block +func (f *RewardsFile_v1) GetConsensusStartBlock() uint64 { + return f.RewardsFileHeader.ConsensusStartBlock +} + // Get the the execution end block func (f *RewardsFile_v1) GetExecutionEndBlock() uint64 { return f.RewardsFileHeader.ExecutionEndBlock @@ -201,6 +211,16 @@ func (f *RewardsFile_v1) GetConsensusEndBlock() uint64 { return f.RewardsFileHeader.ConsensusEndBlock } +// Get the start time +func (f *RewardsFile_v1) GetStartTime() time.Time { + return f.RewardsFileHeader.StartTime +} + +// Get the end time +func (f *RewardsFile_v1) GetEndTime() time.Time { + return f.RewardsFileHeader.EndTime +} + // Get all of the node addresses with rewards in this file // NOTE: the order of node addresses is not guaranteed to be stable, so don't rely on it func (f *RewardsFile_v1) GetNodeAddresses() []common.Address { diff --git a/shared/services/rewards/rewards-file-v2.go b/shared/services/rewards/rewards-file-v2.go index 27b02e97c..0f43cb827 100644 --- a/shared/services/rewards/rewards-file-v2.go +++ b/shared/services/rewards/rewards-file-v2.go @@ -188,6 +188,16 @@ func (f *RewardsFile_v2) GetTotalNodeOperatorSmoothingPoolEth() *big.Int { return &f.RewardsFileHeader.TotalRewards.NodeOperatorSmoothingPoolEth.Int } +// Get the the execution start block +func (f *RewardsFile_v2) GetExecutionStartBlock() uint64 { + return f.RewardsFileHeader.ExecutionStartBlock +} + +// Get the the consensus start block +func (f *RewardsFile_v2) GetConsensusStartBlock() uint64 { + return f.RewardsFileHeader.ConsensusStartBlock +} + // Get the the execution end block func (f *RewardsFile_v2) GetExecutionEndBlock() uint64 { return f.RewardsFileHeader.ExecutionEndBlock @@ -198,6 +208,16 @@ func (f *RewardsFile_v2) GetConsensusEndBlock() uint64 { return f.RewardsFileHeader.ConsensusEndBlock } +// Get the start time +func (f *RewardsFile_v2) GetStartTime() time.Time { + return f.RewardsFileHeader.StartTime +} + +// Get the end time +func (f *RewardsFile_v2) GetEndTime() time.Time { + return f.RewardsFileHeader.EndTime +} + // Get all of the node addresses with rewards in this file // NOTE: the order of node addresses is not guaranteed to be stable, so don't rely on it func (f *RewardsFile_v2) GetNodeAddresses() []common.Address { diff --git a/shared/services/rewards/rewards-file-v3.go b/shared/services/rewards/rewards-file-v3.go index 374ab6b99..231badc45 100644 --- a/shared/services/rewards/rewards-file-v3.go +++ b/shared/services/rewards/rewards-file-v3.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "fmt" "math/big" + "time" "github.com/ethereum/go-ethereum/common" "github.com/goccy/go-json" @@ -102,16 +103,36 @@ func (f *RewardsFile_v3) GetTotalNodeOperatorSmoothingPoolEth() *big.Int { return &f.RewardsFileHeader.TotalRewards.NodeOperatorSmoothingPoolEth.Int } -// Get the the execution end block +// Get the execution end block func (f *RewardsFile_v3) GetExecutionEndBlock() uint64 { return f.RewardsFileHeader.ExecutionEndBlock } -// Get the the consensus end block +// Get the consensus end block func (f *RewardsFile_v3) GetConsensusEndBlock() uint64 { return f.RewardsFileHeader.ConsensusEndBlock } +// Get the execution start block +func (f *RewardsFile_v3) GetExecutionStartBlock() uint64 { + return f.RewardsFileHeader.ExecutionStartBlock +} + +// Get the consensus start block +func (f *RewardsFile_v3) GetConsensusStartBlock() uint64 { + return f.RewardsFileHeader.ConsensusStartBlock +} + +// Get the start time +func (f *RewardsFile_v3) GetStartTime() time.Time { + return f.RewardsFileHeader.StartTime +} + +// Get the end time +func (f *RewardsFile_v3) GetEndTime() time.Time { + return f.RewardsFileHeader.EndTime +} + // Get all of the node addresses with rewards in this file // NOTE: the order of node addresses is not guaranteed to be stable, so don't rely on it func (f *RewardsFile_v3) GetNodeAddresses() []common.Address { diff --git a/shared/services/rewards/ssz_types/rewards-file-v4.go b/shared/services/rewards/ssz_types/rewards-file-v4.go index 78f7d361e..2f4e7c63c 100644 --- a/shared/services/rewards/ssz_types/rewards-file-v4.go +++ b/shared/services/rewards/ssz_types/rewards-file-v4.go @@ -438,6 +438,14 @@ func (f *SSZFile_v1) GetNodeAddresses() []common.Address { return out } +func (f *SSZFile_v1) GetConsensusStartBlock() uint64 { + return f.ConsensusStartBlock +} + +func (f *SSZFile_v1) GetExecutionStartBlock() uint64 { + return f.ExecutionStartBlock +} + func (f *SSZFile_v1) GetConsensusEndBlock() uint64 { return f.ConsensusEndBlock } @@ -446,6 +454,14 @@ func (f *SSZFile_v1) GetExecutionEndBlock() uint64 { return f.ExecutionEndBlock } +func (f *SSZFile_v1) GetStartTime() time.Time { + return f.StartTime +} + +func (f *SSZFile_v1) GetEndTime() time.Time { + return f.EndTime +} + func (f *SSZFile_v1) GetIntervalsPassed() uint64 { return f.IntervalsPassed } diff --git a/shared/services/rewards/test/assets/assets.go b/shared/services/rewards/test/assets/assets.go new file mode 100644 index 000000000..7faf79d73 --- /dev/null +++ b/shared/services/rewards/test/assets/assets.go @@ -0,0 +1,142 @@ +package assets + +import ( + "bytes" + "compress/gzip" + _ "embed" + "encoding/json" + "io" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/rocketpool-go/rewards" + "github.com/rocket-pool/smartnode/shared/services/state" +) + +const Mainnet20ELHeaderTime = 1710394571 + +//go:embed rp-rewards-mainnet-20.json.gz +var mainnet20RewardsJSONGZ []byte +var mainnet20RewardsJSON []byte + +func GetMainnet20RewardsJSON() []byte { + if mainnet20RewardsJSON != nil { + return mainnet20RewardsJSON + } + + gz, err := gzip.NewReader(bytes.NewBuffer(mainnet20RewardsJSONGZ)) + if err != nil { + panic(err) + } + defer gz.Close() + mainnet20RewardsJSON, err = io.ReadAll(gz) + if err != nil { + panic(err) + } + return mainnet20RewardsJSON +} + +//go:embed rp-minipool-performance-mainnet-20.json.gz +var Mainnet20MinipoolPerformanceJSONGZ []byte +var Mainnet20MinipoolPerformanceJSON []byte + +func GetMainnet20MinipoolPerformanceJSON() []byte { + if Mainnet20MinipoolPerformanceJSON != nil { + return Mainnet20MinipoolPerformanceJSON + } + + gz, err := gzip.NewReader(bytes.NewBuffer(Mainnet20MinipoolPerformanceJSONGZ)) + if err != nil { + panic(err) + } + defer gz.Close() + Mainnet20MinipoolPerformanceJSON, err = io.ReadAll(gz) + if err != nil { + panic(err) + } + return Mainnet20MinipoolPerformanceJSON +} + +//go:embed rp-network-state-mainnet-20.json.gz +var Mainnet20NetworkStateJSONGZ []byte + +var mainnet20RewardsState *state.NetworkState + +func GetMainnet20RewardsState() *state.NetworkState { + if mainnet20RewardsState != nil { + return mainnet20RewardsState + } + + // GUnzip the embedded bytes + gz, err := gzip.NewReader(bytes.NewBuffer(Mainnet20NetworkStateJSONGZ)) + if err != nil { + panic(err) + } + defer gz.Close() + + // Create a JSON decoder + dec := json.NewDecoder(gz) + + // Decode the JSON + result := state.NetworkState{} + err = dec.Decode(&result) + if err != nil { + panic(err) + } + + // Memoize the result + mainnet20RewardsState = &result + + return mainnet20RewardsState +} + +func GetRewardSnapshotEventInterval19() rewards.RewardsEvent { + var rewardSnapshotEventInterval19 = rewards.RewardsEvent{ + Index: big.NewInt(19), + ExecutionBlock: big.NewInt(19231284), + ConsensusBlock: big.NewInt(8429279), + MerkleRoot: common.HexToHash("0x35d1be64d49aa71dc5b5ea13dd6f91d8613c81aef2593796d6dee599cd228aea"), + MerkleTreeCID: "bafybeiazkzsqe7molppbhbxg2khdgocrip36eoezroa7anbe53za7mxjpq", + IntervalsPassed: big.NewInt(1), + TreasuryRPL: big.NewInt(0), // Set below + TrustedNodeRPL: []*big.Int{}, // XXX Not set, but probably not needed + NodeRPL: []*big.Int{}, // XXX Not set, but probably not needed + NodeETH: []*big.Int{}, // XXX Not set, but probably not needed + UserETH: big.NewInt(0), // XXX Not set, but probably not needed + IntervalStartTime: time.Unix(1705556139, 0), + IntervalEndTime: time.Unix(1707975339, 0), + SubmissionTime: time.Unix(1707976475, 0), + } + rewardSnapshotEventInterval19.TreasuryRPL.SetString("0x0000000000000000000000000000000000000000000000f0a1e7585cd758ffe2", 16) + return rewardSnapshotEventInterval19 +} + +//go:embed rp-network-critical-duties-mainnet-20.json.gz +var mainnet20CriticalDutiesSlotsGZ []byte +var mainnet20CriticalDutiesSlots *state.CriticalDutiesSlots + +func GetMainnet20CriticalDutiesSlots() *state.CriticalDutiesSlots { + if mainnet20CriticalDutiesSlots != nil { + return mainnet20CriticalDutiesSlots + } + + jsonReader, err := gzip.NewReader(bytes.NewBuffer(mainnet20CriticalDutiesSlotsGZ)) + if err != nil { + panic(err) + } + defer jsonReader.Close() + + // Create a JSON decoder + dec := json.NewDecoder(jsonReader) + + // Decode the JSON + result := state.CriticalDutiesSlots{} + err = dec.Decode(&result) + if err != nil { + panic(err) + } + + mainnet20CriticalDutiesSlots = &result + return mainnet20CriticalDutiesSlots +} diff --git a/shared/services/rewards/test/assets/rp-minipool-performance-mainnet-20.json.gz b/shared/services/rewards/test/assets/rp-minipool-performance-mainnet-20.json.gz new file mode 100644 index 000000000..1497ec0cf Binary files /dev/null and b/shared/services/rewards/test/assets/rp-minipool-performance-mainnet-20.json.gz differ diff --git a/shared/services/rewards/test/assets/rp-network-critical-duties-mainnet-20.json.gz b/shared/services/rewards/test/assets/rp-network-critical-duties-mainnet-20.json.gz new file mode 100644 index 000000000..dbb233c4a Binary files /dev/null and b/shared/services/rewards/test/assets/rp-network-critical-duties-mainnet-20.json.gz differ diff --git a/shared/services/rewards/test/assets/rp-network-state-mainnet-20.json.gz b/shared/services/rewards/test/assets/rp-network-state-mainnet-20.json.gz new file mode 100644 index 000000000..ce385c4a9 Binary files /dev/null and b/shared/services/rewards/test/assets/rp-network-state-mainnet-20.json.gz differ diff --git a/shared/services/rewards/test/assets/rp-rewards-mainnet-20.json.gz b/shared/services/rewards/test/assets/rp-rewards-mainnet-20.json.gz new file mode 100644 index 000000000..16004330a Binary files /dev/null and b/shared/services/rewards/test/assets/rp-rewards-mainnet-20.json.gz differ diff --git a/shared/services/rewards/test/beacon.go b/shared/services/rewards/test/beacon.go new file mode 100644 index 000000000..ea8b148c8 --- /dev/null +++ b/shared/services/rewards/test/beacon.go @@ -0,0 +1,385 @@ +package test + +import ( + "fmt" + "strconv" + "testing" + + "github.com/prysmaticlabs/go-bitfield" + "github.com/rocket-pool/rocketpool-go/types" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/state" +) + +type epoch uint64 +type slot uint64 +type validatorIndex string +type validatorIndexToCommitteeIndexMap map[validatorIndex]uint +type criticalDutiesSlotMap map[validatorIndex]map[slot]interface{} + +func (v *validatorIndexToCommitteeIndexMap) set(vI validatorIndex, i uint) { + if *v == nil { + *v = make(validatorIndexToCommitteeIndexMap) + } + (*v)[vI] = i +} + +type missedDutiesMap map[epoch]map[slot][]validatorIndex + +func (missedDuties *missedDutiesMap) add(s slot, validator validatorIndex) { + if *missedDuties == nil { + *missedDuties = make(missedDutiesMap) + } + e := epoch(s / 32) + _, ok := (*missedDuties)[e] + + if !ok { + (*missedDuties)[e] = make(map[slot][]validatorIndex) + } + _, ok = (*missedDuties)[e][s] + if !ok { + (*missedDuties)[e][s] = make([]validatorIndex, 0) + } + (*missedDuties)[e][s] = append((*missedDuties)[e][s], validator) +} + +func (missedDuties *missedDutiesMap) getCount(s slot) uint { + e := epoch(s / 32) + if _, ok := (*missedDuties)[e]; !ok { + return 0 + } + if _, ok := (*missedDuties)[e][s]; !ok { + return 0 + } + return uint(len((*missedDuties)[e][s])) +} + +type missedEpochsMap map[validatorIndex]map[epoch]interface{} + +func (missedEpochs *missedEpochsMap) set(v validatorIndex, s slot) { + e := epoch(s / 32) + if *missedEpochs == nil { + *missedEpochs = make(missedEpochsMap) + } + _, ok := (*missedEpochs)[v] + if !ok { + (*missedEpochs)[v] = make(map[epoch]interface{}) + } + (*missedEpochs)[v][e] = struct{}{} +} + +func (missedEpochs *missedEpochsMap) validatorMissedEpoch(v validatorIndex, e epoch) bool { + if _, ok := (*missedEpochs)[v]; !ok { + return false + } + _, ok := (*missedEpochs)[v][e] + return ok +} + +type MockBeaconClient struct { + state *state.NetworkState + + t *testing.T + blocks map[string]beacon.BeaconBlock + + // A map of epoch -> slot -> validator indices for missed duties + missedDuties missedDutiesMap + + // A map of validator -> epoch -> {} + // that tracks which epochs a validator has missed duties in + missedEpochs missedEpochsMap + + // Count of validators + validatorCount uint + + // A map of validator index -> order in the list + validatorIndices validatorIndexToCommitteeIndexMap + + // A map of validator index to pubkey + validatorPubkeys map[validatorIndex]types.ValidatorPubkey + + // A map of validator index to critical duties slots + criticalDutiesSlots criticalDutiesSlotMap +} + +func (m *MockBeaconClient) SetState(state *state.NetworkState) { + m.state = state + if m.validatorPubkeys == nil { + m.validatorPubkeys = make(map[validatorIndex]types.ValidatorPubkey) + } + for _, v := range state.ValidatorDetails { + if _, ok := m.validatorPubkeys[validatorIndex(v.Index)]; ok { + m.t.Fatalf("Validator %s already set", v.Index) + } + m.validatorPubkeys[validatorIndex(v.Index)] = v.Pubkey + } +} + +type mockBeaconCommitteeSlot struct { + validators []string +} + +type MockBeaconCommittees struct { + slots []mockBeaconCommitteeSlot + epoch epoch +} + +func NewMockBeaconClient(t *testing.T) *MockBeaconClient { + return &MockBeaconClient{t: t} +} + +func (bc *MockBeaconClient) GetBeaconBlock(slot string) (beacon.BeaconBlock, bool, error) { + if block, ok := bc.blocks[slot]; ok { + return block, true, nil + } + return beacon.BeaconBlock{}, false, nil +} + +func (bc *MockBeaconClient) SetBeaconBlock(slot string, block beacon.BeaconBlock) { + if bc.blocks == nil { + bc.blocks = make(map[string]beacon.BeaconBlock) + } + bc.blocks[slot] = block +} + +func (bc *MockBeaconClient) SetCriticalDutiesSlots(criticalDutiesSlots *state.CriticalDutiesSlots) { + if bc.criticalDutiesSlots == nil { + bc.criticalDutiesSlots = make(criticalDutiesSlotMap) + } + for _validator, slots := range criticalDutiesSlots.CriticalDuties { + validator := validatorIndex(_validator) + if bc.criticalDutiesSlots[validator] == nil { + bc.criticalDutiesSlots[validator] = make(map[slot]interface{}) + } + for _, _slot := range slots { + s := slot(_slot) + bc.criticalDutiesSlots[validator][s] = struct{}{} + } + } +} + +func (bc *MockBeaconClient) isValidatorActive(validator validatorIndex, e epoch) (bool, error) { + // Get the pubkey + validatorPubkey, ok := bc.validatorPubkeys[validator] + if !ok { + return false, fmt.Errorf("validator %s not found", validator) + } + validatorDetails, ok := bc.state.ValidatorDetails[validatorPubkey] + if !ok { + return false, fmt.Errorf("validator %s not found", validatorPubkey) + } + // Validators are assigned duties in the epoch they are activated + // but not in the epoch they exit + return validatorDetails.ActivationEpoch <= uint64(e) && (validatorDetails.ExitEpoch == 0 || uint64(e) < validatorDetails.ExitEpoch), nil +} + +func (bc *MockBeaconClient) GetCommitteesForEpoch(_epoch *uint64) (beacon.Committees, error) { + + out := &MockBeaconCommittees{} + out.epoch = epoch(*_epoch) + + // First find validators that must be assigned to specific slots + var missedDutiesValidators map[slot][]validatorIndex + missedDutiesValidators = bc.missedDuties[out.epoch] + + // Keep track of validators that have been assigned to a slot + assignedValidators := make(map[string]interface{}) + + out.slots = make([]mockBeaconCommitteeSlot, 32) + for s := out.epoch * 32; s < out.epoch*32+32; s++ { + idx := s - out.epoch*32 + out.slots[idx].validators = make([]string, 0, len(bc.validatorIndices)/32) + + // Assign validators that missed duties for this slot + for _, validator := range missedDutiesValidators[slot(s)] { + out.slots[idx].validators = append(out.slots[idx].validators, string(validator)) + } + for _, validator := range out.slots[idx].validators { + assignedValidators[validator] = struct{}{} + } + } + + // Assign the remaining validators based on total order / critical duties + for validator, _ := range bc.validatorIndices { + if _, ok := assignedValidators[string(validator)]; ok { + continue + } + + // If the validator was not active, skip it + active, err := bc.isValidatorActive(validator, out.epoch) + if err != nil { + return nil, err + } + if !active { + continue + } + + // If the validator has critical duties for this slot, assign it + if _, ok := bc.criticalDutiesSlots[validator]; ok { + assigned := false + for s, _ := range bc.criticalDutiesSlots[validator] { + if bc.state.BeaconConfig.SlotToEpoch(uint64(s)) == uint64(out.epoch) { + idx := s % 32 + out.slots[idx].validators = append(out.slots[idx].validators, string(validator)) + assigned = true + break + } + } + if assigned { + continue + } + } + + // The validator was not assigned to a slot, neither by missing duties nor critical duties + // Assign it to a pseudorandom slot + idx := validator.Mod32() + out.slots[idx].validators = append(out.slots[idx].validators, string(validator)) + } + + return out, nil +} + +func (v validatorIndex) Mod32() uint { + vInt, err := strconv.ParseUint(string(v), 10, 64) + if err != nil { + panic(err) + } + return uint(vInt % 32) +} + +func (bc *MockBeaconClient) GetAttestations(_slot string) ([]beacon.AttestationInfo, bool, error) { + + slotNative, err := strconv.ParseUint(_slot, 10, 64) + if err != nil { + bc.t.Fatalf("Invalid slot: %s", _slot) + } + s := slot(slotNative) + + // Report attestations for the previous slot + s -= 16 + + // Get the epoch of the previous slot + e := epoch(s / 32) + + // The length of the bitlist is the number of validators that missed duties + // for the slot, plus the number of validators whose mod 32 is the same as the slot, + // unless that validator has missed duties in the same epoch. + // + // However, a validator can be both in the set of validators that missed duties for the slot + // and the set of validators whose mod 32 is the same as the slot, so we have to be careful + // to not double count them. + slotMod32 := s % 32 + var bitlistLength uint + // Add the number of validators that missed duties for the slot + bitlistLength = bc.missedDuties.getCount(s) + + for index, _ := range bc.validatorIndices { + // Don't count validators that are have misses anywhere in this epoch + if bc.missedEpochs.validatorMissedEpoch(index, e) { + // This validator either missed this slot and was already counted, + // or missed a different slot in the same epoch, and shouldn't be counted + continue + } + + active, err := bc.isValidatorActive(index, e) + if err != nil { + bc.t.Fatalf("Error checking if validator %s is active: %v", index, err) + } + if !active { + continue + } + + // Don't count validators with critical duties in this epoch unless the duty is in slot s + if duties, ok := bc.criticalDutiesSlots[index]; ok { + // The validator has some critical duties + if _, ok := duties[s]; ok { + // The duty is in slot s, so count it + bitlistLength++ + } else { + // Check if any duties are in the same epoch + foundDuty := false + for criticalDutySlot, _ := range duties { + if bc.state.BeaconConfig.SlotToEpoch(uint64(criticalDutySlot)) == uint64(e) { + foundDuty = true + break + } + } + if foundDuty { + continue + } + } + } + + // This validator was assigned to this slot and did not miss duties. + validatorIndexMod32 := index.Mod32() + if validatorIndexMod32 == uint(slotMod32) { + bitlistLength++ + } + } + + bl := bitfield.NewBitlist(uint64(bitlistLength)) + // Include all validators + bl = bl.Not() + // Exclude validators that need to miss duties on the previous slot + if _, ok := bc.missedDuties[e]; ok { + if _, ok := bc.missedDuties[e][s]; ok { + numMissed := len(bc.missedDuties[e][s]) + for i := 0; i < numMissed; i++ { + bl.SetBitAt(uint64(i), false) + } + } + } + out := []beacon.AttestationInfo{ + { + AggregationBits: bl, + SlotIndex: uint64(s), + CommitteeIndex: 0, + }, + } + return out, true, nil +} + +// Count returns the number of committees in the response +func (mbc *MockBeaconCommittees) Count() int { + return len(mbc.slots) +} + +// Index returns the index of the committee at the provided offset +func (mbc *MockBeaconCommittees) Index(index int) uint64 { + return 0 +} + +// Slot returns the slot of the committee at the provided offset +func (mbc *MockBeaconCommittees) Slot(index int) uint64 { + return uint64(mbc.epoch)*32 + uint64(index) +} + +// Validators returns the list of validators of the committee at +// the provided offset +func (mbc *MockBeaconCommittees) Validators(index int) []string { + return mbc.slots[index].validators +} + +// Release is a no-op +func (mbc *MockBeaconCommittees) Release() { +} + +// SetMinipoolPerformance notes the minipool's performance +// to be mocked in the response to GetAttestations +func (bc *MockBeaconClient) SetMinipoolPerformance(index string, missedSlots []uint64) { + + // For each missed slot, add it to the inner map of slot to validator indices + for _, s := range missedSlots { + bc.missedDuties.add(slot(s), validatorIndex(index)) + + // Add to missedEpochs + bc.missedEpochs.set(validatorIndex(index), slot(s)) + } + + // A map of true validator index -> committee index + if _, ok := bc.validatorIndices[validatorIndex(index)]; ok { + bc.t.Fatalf("Validator %s already set", index) + } + bc.validatorIndices.set(validatorIndex(index), bc.validatorCount) + bc.validatorCount++ +} diff --git a/shared/services/rewards/test/mock.go b/shared/services/rewards/test/mock.go new file mode 100644 index 000000000..8761a46fa --- /dev/null +++ b/shared/services/rewards/test/mock.go @@ -0,0 +1,595 @@ +package test + +import ( + "math/big" + "strconv" + "time" + + "github.com/ethereum/go-ethereum/common" + rprewards "github.com/rocket-pool/rocketpool-go/rewards" + "github.com/rocket-pool/rocketpool-go/types" + "github.com/rocket-pool/rocketpool-go/utils/eth" + rpstate "github.com/rocket-pool/rocketpool-go/utils/state" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/state" +) + +const FarFutureEpoch uint64 = 0xffffffffffffffff + +// This file contains structs useful for quickly creating mock histories for testing. + +var ( + // Various offsets to create unique number spaces for each key type + lastNodeAddress = common.BigToAddress(big.NewInt(2000)) + lastMinipoolAddress = common.BigToAddress(big.NewInt(30000)) + lastValidatorPubkey = types.BytesToValidatorPubkey(big.NewInt(600000).Bytes()) + lastValidatorIndex = "0" +) + +func GetValidatorIndex() string { + u, err := strconv.ParseUint(lastValidatorIndex, 10, 64) + if err != nil { + panic(err) + } + lastValidatorIndex = strconv.FormatUint(u+1, 10) + return lastValidatorIndex +} + +func GetValidatorPubkey() types.ValidatorPubkey { + next := big.NewInt(0).Add(big.NewInt(0).SetBytes(lastValidatorPubkey.Bytes()), big.NewInt(1)) + lastValidatorPubkey = types.BytesToValidatorPubkey(next.Bytes()) + return lastValidatorPubkey +} + +func GetMinipoolAddress() common.Address { + next := big.NewInt(0).Add(big.NewInt(0).SetBytes(lastMinipoolAddress.Bytes()), big.NewInt(1)) + lastMinipoolAddress = common.BigToAddress(next) + return lastMinipoolAddress +} + +func GetNodeAddress() common.Address { + next := big.NewInt(0).Add(big.NewInt(0).SetBytes(lastNodeAddress.Bytes()), big.NewInt(1)) + lastNodeAddress = common.BigToAddress(next) + return lastNodeAddress +} + +type MockMinipool struct { + Address common.Address + Pubkey types.ValidatorPubkey + Status types.MinipoolStatus + StatusBlock *big.Int + StatusTime time.Time + Finalised bool + NodeFee *big.Int + NodeDepositBalance *big.Int + NodeAddress common.Address + + LastBondReductionTime time.Time + LastBondReductionPrevValue *big.Int + LastBondReductionPrevNodeFee *big.Int + + ValidatorIndex string + + Notes []string +} + +type BondSize *big.Int + +var ( + BondSizeEightEth = BondSize(eth.EthToWei(8)) + BondSizeSixteenEth = BondSize(eth.EthToWei(16)) + _bondSizeThirtyTwoEth = BondSize(eth.EthToWei(32)) +) + +func GetNewDefaultMockMinipool(bondSize BondSize) *MockMinipool { + if (*big.Int)(_bondSizeThirtyTwoEth).Cmp(bondSize) <= 0 { + panic("Bond size must be less than 32 ether") + } + + out := &MockMinipool{ + Address: GetMinipoolAddress(), + Pubkey: GetValidatorPubkey(), + // By default, staked since always + Status: types.Staking, + StatusBlock: big.NewInt(0), + StatusTime: time.Unix(DefaultMockHistoryGenesis, 0), + // Default to 10% to make math simpler. Aka 0.1 ether + NodeFee: big.NewInt(100000000000000000), + NodeDepositBalance: big.NewInt(0).Set(bondSize), + ValidatorIndex: GetValidatorIndex(), + } + + return out +} + +type MockNode struct { + Address common.Address + RegistrationTime time.Time + RplStake *big.Int + SmoothingPoolRegistrationState bool + SmoothingPoolRegistrationChanged time.Time + + IsOdao bool + JoinedOdaoAt time.Time + + bondedEth *big.Int + borrowedEth *big.Int + Minipools []*MockMinipool + + Notes string + Class string +} + +func (n *MockNode) AddMinipool(minipool *MockMinipool) { + minipool.NodeAddress = n.Address + n.bondedEth.Add(n.bondedEth, minipool.NodeDepositBalance) + borrowedEth := big.NewInt(0).Sub((*big.Int)(_bondSizeThirtyTwoEth), minipool.NodeDepositBalance) + n.borrowedEth.Add(n.borrowedEth, borrowedEth) + + n.Minipools = append(n.Minipools, minipool) +} + +type NewMockNodeParams struct { + SmoothingPool bool + EightEthMinipools int + SixteenEthMinipools int + CollateralRpl int64 +} + +func (h *MockHistory) GetNewDefaultMockNode(params *NewMockNodeParams) *MockNode { + if params == nil { + // Inefficient, but nice code follows. + params = &NewMockNodeParams{} + } + + out := &MockNode{ + Address: GetNodeAddress(), + RegistrationTime: time.Unix(DefaultMockHistoryGenesis, 0), + RplStake: big.NewInt(0), + SmoothingPoolRegistrationState: params.SmoothingPool, + SmoothingPoolRegistrationChanged: time.Unix(DefaultMockHistoryGenesis, 0), + + borrowedEth: big.NewInt(0), + bondedEth: big.NewInt(0), + } + + for i := 0; i < params.EightEthMinipools; i++ { + out.AddMinipool(GetNewDefaultMockMinipool(BondSizeEightEth)) + } + + for i := 0; i < params.SixteenEthMinipools; i++ { + out.AddMinipool(GetNewDefaultMockMinipool(BondSizeSixteenEth)) + } + + out.RplStake = big.NewInt(params.CollateralRpl) + out.RplStake.Mul(out.RplStake, eth.EthToWei(1)) + + return out +} + +// Returns a list of nodes with various attributes- +// some will have active minipools, some will not. +// some will be under and over collateralized. +// some will have opted in or out during the interval +// some will have bond reduced during the interval +func (h *MockHistory) GetDefaultMockNodes() []*MockNode { + nodes := []*MockNode{} + + // Create 10 nodes with one 8-eth minipool each and 10 RPL staked + for i := 0; i < 10; i++ { + node := h.GetNewDefaultMockNode(&NewMockNodeParams{ + EightEthMinipools: 1, + CollateralRpl: 10, + }) + node.Notes = "Regular node with one regular 8-eth minipool" + node.Class = "single_eight_eth" + nodes = append(nodes, node) + } + + // Create 10 more of the same, but in the SP + for i := 0; i < 10; i++ { + node := h.GetNewDefaultMockNode(&NewMockNodeParams{ + EightEthMinipools: 1, + SmoothingPool: true, + CollateralRpl: 10, + }) + node.Notes = "Smoothing pool node with one regular 8-eth minipool" + node.Class = "single_eight_eth_sp" + nodes = append(nodes, node) + } + + // Create 20 as above, but with 16-eth minipools + for i := 0; i < 10; i++ { + node := h.GetNewDefaultMockNode(&NewMockNodeParams{ + SixteenEthMinipools: 1, + CollateralRpl: 10, + }) + node.Notes = "Regular node with one regular 16-eth minipool" + node.Class = "single_sixteen_eth" + nodes = append(nodes, node) + } + + for i := 0; i < 10; i++ { + node := h.GetNewDefaultMockNode(&NewMockNodeParams{ + SixteenEthMinipools: 1, + SmoothingPool: true, + CollateralRpl: 10, + }) + node.Notes = "Smoothing pool node with one regular 16-eth minipool" + node.Class = "single_sixteen_eth_sp" + nodes = append(nodes, node) + } + + // Add a node that opts in a quarter of the way through the interval + node := h.GetNewDefaultMockNode(&NewMockNodeParams{ + EightEthMinipools: 1, + SmoothingPool: true, + CollateralRpl: 20, + }) + node.SmoothingPoolRegistrationChanged = h.BeaconConfig.GetSlotTime(h.BeaconConfig.FirstSlotOfEpoch(h.StartEpoch + (h.EndEpoch-h.StartEpoch)/4)) + node.Notes = "Smoothing pool node with one 8-eth that opts in 1/4 of the way through the interval" + node.Class = "single_eight_eth_opted_in_quarter" + nodes = append(nodes, node) + + // Add a node that opts in a quarter of the way through the interval + node = h.GetNewDefaultMockNode(&NewMockNodeParams{ + SixteenEthMinipools: 1, + SmoothingPool: true, + CollateralRpl: 20, + }) + node.SmoothingPoolRegistrationChanged = h.BeaconConfig.GetSlotTime(h.BeaconConfig.FirstSlotOfEpoch(h.StartEpoch + (h.EndEpoch-h.StartEpoch)/4)) + node.Notes = "Smoothing pool node with one 16-eth that opts in 1/4 of the way through the interval" + node.Class = "single_sixteen_eth_opted_in_quarter" + nodes = append(nodes, node) + + // Add a node that opts out a three quarters of the way through the interval + node = h.GetNewDefaultMockNode(&NewMockNodeParams{ + EightEthMinipools: 1, + SmoothingPool: false, + CollateralRpl: 20, + }) + node.SmoothingPoolRegistrationChanged = h.BeaconConfig.GetSlotTime(h.BeaconConfig.FirstSlotOfEpoch(h.StartEpoch + 3*(h.EndEpoch-h.StartEpoch)/4)) + node.Notes = "Smoothing pool node with one 8-eth that opts out 3/4 of the way through the interval" + node.Class = "single_eight_eth_opted_out_three_quarters" + nodes = append(nodes, node) + + // Add a node that opts out a three quarters of the way through the interval + node = h.GetNewDefaultMockNode(&NewMockNodeParams{ + SixteenEthMinipools: 1, + SmoothingPool: false, + CollateralRpl: 20, + }) + node.SmoothingPoolRegistrationChanged = h.BeaconConfig.GetSlotTime(h.BeaconConfig.FirstSlotOfEpoch(h.StartEpoch + 3*(h.EndEpoch-h.StartEpoch)/4)) + node.Notes = "Smoothing pool node with one 16-eth that opts out 3/4 of the way through the interval" + node.Class = "single_sixteen_eth_opted_out_three_quarters" + nodes = append(nodes, node) + + // Add a node that does a bond reduction half way through the interval + node = h.GetNewDefaultMockNode(&NewMockNodeParams{ + EightEthMinipools: 1, + SmoothingPool: true, + CollateralRpl: 10, + }) + node.Minipools[0].LastBondReductionTime = h.BeaconConfig.GetSlotTime(h.BeaconConfig.FirstSlotOfEpoch(h.StartEpoch + (h.EndEpoch-h.StartEpoch)/2)) + node.Minipools[0].LastBondReductionPrevValue = big.NewInt(0).Mul(big.NewInt(16), eth.EthToWei(1)) + // Say it was 20% for fun + node.Minipools[0].LastBondReductionPrevNodeFee, _ = big.NewInt(0).SetString("200000000000000000", 10) + node.Notes = "Node with one 16-eth that does a bond reduction to 8 eth halfway through the interval" + node.Class = "single_bond_reduction" + nodes = append(nodes, node) + + // Add a node with no minipools + node = h.GetNewDefaultMockNode(&NewMockNodeParams{ + // Give it collateral so we can test that it's ignored despite having collateral + CollateralRpl: 10, + }) + node.Notes = "Node with no minipools but RPL collateral" + node.Class = "no_minipools" + nodes = append(nodes, node) + + // Add a node with a pending minipool + node = h.GetNewDefaultMockNode(&NewMockNodeParams{ + EightEthMinipools: 1, + CollateralRpl: 10, + }) + node.Minipools[0].Status = types.Prelaunch + node.Notes = "Node with one 8-eth minipool that is pending" + node.Class = "single_eight_eth_pending" + nodes = append(nodes, node) + + // Add a node with a single staking minipool that is finalized + node = h.GetNewDefaultMockNode(&NewMockNodeParams{ + EightEthMinipools: 1, + CollateralRpl: 10, + }) + node.Minipools[0].Finalised = true + node.Notes = "Node with one 8-eth minipool that is finalized" + node.Class = "single_eight_eth_finalized" + nodes = append(nodes, node) + + // Finally, create two odao nodes to share the juicy odao rewards + for i := 0; i < 2; i++ { + node := h.GetNewDefaultMockNode(nil) + node.IsOdao = true + node.Class = "odao" + nodes = append(nodes, node) + } + + return nodes +} + +const DefaultMockHistoryGenesis = 1577836800 + +type MockHistory struct { + StartEpoch uint64 + EndEpoch uint64 + BlockOffset uint64 + BeaconConfig beacon.Eth2Config + + // Network details for the final slot + NetworkDetails *rpstate.NetworkDetails + + Nodes []*MockNode +} + +func NewDefaultMockHistory() *MockHistory { + out := &MockHistory{ + StartEpoch: 100, + EndEpoch: 200, + BlockOffset: 100000, + BeaconConfig: beacon.Eth2Config{ + GenesisEpoch: 0, + // 2020-01-01 midnight UTC for simplicity + GenesisTime: DefaultMockHistoryGenesis, + SlotsPerEpoch: 32, + SecondsPerSlot: 12, + SecondsPerEpoch: 12 * 32, + }, + + NetworkDetails: &rpstate.NetworkDetails{ + // Defaults to 0.24 ether, so 10 RPL is 2.4 ether and a leb8 with 10 RPL is 10% collateralized + RplPrice: big.NewInt(240000000000000000), + // Defaults to 10% aka 0.1 ether + MinCollateralFraction: big.NewInt(100000000000000000), + // Defaults to 60% to mimic current withdrawal limits + MaxCollateralFraction: big.NewInt(600000000000000000), + // Defaults to 100 epochs + IntervalDuration: 100 * 32 * 12 * time.Second, + // Defaults to genesis plus 100 epochs + IntervalStart: time.Unix(DefaultMockHistoryGenesis, 0).Add(100 * 32 * 12 * time.Second), + // Defaults to 0.7 ether to match mainnet + NodeOperatorRewardsPercent: big.NewInt(700000000000000000), + // Defaults to 0.015 ether to match mainnet as of 2024-10-08 + TrustedNodeOperatorRewardsPercent: big.NewInt(15000000000000000), + // Defaults to 1 - 0.7 - 0.015 ether to round out to 100% + ProtocolDaoRewardsPercent: big.NewInt(285000000000000000), + // Defaults to 70,000 ether of RPL to apprixmate 1/13th of 5% of 18m + PendingRPLRewards: big.NewInt(0).Mul(big.NewInt(70000), big.NewInt(1000000000000000000)), + // RewardIndex defaults to 40000 to avoid a test tree from being taken seriously + RewardIndex: 40000, + // Put 100 ether in the smoothing pool + SmoothingPoolBalance: big.NewInt(0).Mul(big.NewInt(100), big.NewInt(1000000000000000000)), + + // The rest of the fields seem unimportant and are left empty + }, + } + + out.Nodes = out.GetDefaultMockNodes() + return out +} + +func (h *MockHistory) GetEndNetworkState() *state.NetworkState { + out := &state.NetworkState{ + // El block number is the final slot's block, which is the last slot of the last epoch + // plus the offset + ElBlockNumber: h.BlockOffset + h.BeaconConfig.LastSlotOfEpoch(h.EndEpoch), + BeaconSlotNumber: h.BeaconConfig.LastSlotOfEpoch(h.EndEpoch), + BeaconConfig: h.BeaconConfig, + NetworkDetails: h.NetworkDetails, + NodeDetails: []rpstate.NativeNodeDetails{}, + NodeDetailsByAddress: make(map[common.Address]*rpstate.NativeNodeDetails), + MinipoolDetails: []rpstate.NativeMinipoolDetails{}, + MinipoolDetailsByAddress: make(map[common.Address]*rpstate.NativeMinipoolDetails), + MinipoolDetailsByNode: make(map[common.Address][]*rpstate.NativeMinipoolDetails), + ValidatorDetails: make(state.ValidatorDetailsMap), + OracleDaoMemberDetails: []rpstate.OracleDaoMemberDetails{}, + ProtocolDaoProposalDetails: nil, + } + + // Add nodes + for _, node := range h.Nodes { + // Calculate the node's effective RPL stake + // If it's below 10% of borrowed eth per the network details, it's 0 + rplStake := node.RplStake + rplPrice := h.NetworkDetails.RplPrice + // Calculate the minimum RPL stake according to the network details + minRplStake := big.NewInt(0).Mul(node.borrowedEth, h.NetworkDetails.MinCollateralFraction) + // minRplStake is now the minimum RPL stake in eth value measured in wei squared + // divide by the price to get the minimum RPL stake in RPL + minRplStake.Div(minRplStake, rplPrice) + + // Same for max + maxRplStake := big.NewInt(0).Mul(node.borrowedEth, h.NetworkDetails.MaxCollateralFraction) + maxRplStake.Div(maxRplStake, rplPrice) + + // Eth matching limit is rpl stake times the price divided by the collateral fraction + ethMatchingLimit := big.NewInt(0).Mul(node.RplStake, rplPrice) + ethMatchingLimit.Div(ethMatchingLimit, h.NetworkDetails.MinCollateralFraction) + collateralisationRatio := big.NewInt(0) + if node.borrowedEth.Sign() > 0 { + collateralisationRatio.Div(node.bondedEth, big.NewInt(0).Add(big.NewInt(0).Mul(node.bondedEth, eth.EthToWei(1)), node.borrowedEth)) + } + + // Create the node details + details := rpstate.NativeNodeDetails{ + Exists: true, + RegistrationTime: big.NewInt(node.RegistrationTime.Unix()), + TimezoneLocation: "UTC", + RewardNetwork: big.NewInt(0), + RplStake: node.RplStake, + EffectiveRPLStake: rplStake, + MinimumRPLStake: minRplStake, + MaximumRPLStake: maxRplStake, + EthMatched: node.borrowedEth, + EthMatchedLimit: ethMatchingLimit, + MinipoolCount: big.NewInt(int64(len(node.Minipools))), + // Empty node wallet + BalanceETH: big.NewInt(0), + BalanceRETH: big.NewInt(0), + BalanceRPL: big.NewInt(0), + BalanceOldRPL: big.NewInt(0), + DepositCreditBalance: big.NewInt(0), + DistributorBalance: big.NewInt(0), + DistributorBalanceUserETH: big.NewInt(0), + DistributorBalanceNodeETH: big.NewInt(0), + WithdrawalAddress: node.Address, + PendingWithdrawalAddress: common.Address{}, + SmoothingPoolRegistrationState: node.SmoothingPoolRegistrationState, + SmoothingPoolRegistrationChanged: big.NewInt(node.SmoothingPoolRegistrationChanged.Unix()), + NodeAddress: node.Address, + + AverageNodeFee: big.NewInt(0), // Populated by CalculateAverageFeeAndDistributorShares + + // Ratio of bonded to bonded plus borrowed + CollateralisationRatio: collateralisationRatio, + } + out.NodeDetails = append(out.NodeDetails, details) + ptr := &out.NodeDetails[len(out.NodeDetails)-1] + out.NodeDetailsByAddress[node.Address] = ptr + + // Add minipools + for _, minipool := range node.Minipools { + minipoolDetails := rpstate.NativeMinipoolDetails{ + Exists: true, + MinipoolAddress: minipool.Address, + Pubkey: minipool.Pubkey, + StatusRaw: uint8(minipool.Status), + StatusBlock: minipool.StatusBlock, + StatusTime: big.NewInt(minipool.StatusTime.Unix()), + Finalised: minipool.Finalised, + NodeFee: minipool.NodeFee, + NodeDepositBalance: minipool.NodeDepositBalance, + NodeDepositAssigned: true, + UserDepositBalance: big.NewInt(0).Sub(_bondSizeThirtyTwoEth, minipool.NodeDepositBalance), + UserDepositAssigned: true, + UserDepositAssignedTime: big.NewInt(h.BeaconConfig.GetSlotTime(minipool.StatusBlock.Uint64() - h.BlockOffset).Unix()), + NodeAddress: minipool.NodeAddress, + Balance: big.NewInt(0), + DistributableBalance: big.NewInt(0), + NodeShareOfBalance: big.NewInt(0), + UserShareOfBalance: big.NewInt(0), + NodeRefundBalance: big.NewInt(0), + PenaltyCount: big.NewInt(0), + PenaltyRate: big.NewInt(0), + WithdrawalCredentials: common.Hash{}, + Status: minipool.Status, + DepositType: types.Variable, + + LastBondReductionTime: big.NewInt(minipool.LastBondReductionTime.Unix()), + LastBondReductionPrevValue: minipool.LastBondReductionPrevValue, + LastBondReductionPrevNodeFee: minipool.LastBondReductionPrevNodeFee, + } + out.MinipoolDetails = append(out.MinipoolDetails, minipoolDetails) + minipoolPtr := &out.MinipoolDetails[len(out.MinipoolDetails)-1] + out.MinipoolDetailsByAddress[minipool.Address] = minipoolPtr + out.MinipoolDetailsByNode[minipool.NodeAddress] = append(out.MinipoolDetailsByNode[minipool.NodeAddress], minipoolPtr) + + // Finally, populate the the ValidatorDetails map + pubkey := minipool.Pubkey + details := beacon.ValidatorStatus{ + Pubkey: minipool.Pubkey, + Index: minipool.ValidatorIndex, + WithdrawalCredentials: common.Hash{}, + Balance: (*big.Int)(_bondSizeThirtyTwoEth).Uint64(), + EffectiveBalance: (*big.Int)(_bondSizeThirtyTwoEth).Uint64(), + Slashed: false, + ActivationEligibilityEpoch: 0, + ActivationEpoch: 0, + ExitEpoch: FarFutureEpoch, + WithdrawableEpoch: FarFutureEpoch, + Exists: true, + } + if minipool.Status == types.Staking { + details.Status = beacon.ValidatorState_ActiveOngoing + } + if minipool.Finalised { + details.Status = beacon.ValidatorState_WithdrawalDone + } + out.ValidatorDetails[pubkey] = details + } + + // Calculate the AverageNodeFee and DistributorShares + ptr.CalculateAverageFeeAndDistributorShares(out.MinipoolDetailsByNode[ptr.NodeAddress]) + + // Check if the node is an odao member + if node.IsOdao { + details := rpstate.OracleDaoMemberDetails{ + Address: node.Address, + Exists: true, + ID: node.Address.Hex(), + Url: "https://example.com", + JoinedTime: time.Unix(node.RegistrationTime.Unix(), 0), + LastProposalTime: time.Unix(node.RegistrationTime.Unix(), 0), + RPLBondAmount: node.RplStake, + } + out.OracleDaoMemberDetails = append(out.OracleDaoMemberDetails, details) + } + } + + return out +} + +// Boring derived data getters +func (h *MockHistory) GetConsensusStartBlock() uint64 { + return h.BeaconConfig.FirstSlotOfEpoch(h.StartEpoch) +} + +func (h *MockHistory) GetExecutionStartBlock() uint64 { + return h.GetConsensusStartBlock() + h.BlockOffset +} + +func (h *MockHistory) GetConsensusEndBlock() uint64 { + return h.BeaconConfig.LastSlotOfEpoch(h.EndEpoch) +} + +func (h *MockHistory) GetExecutionEndBlock() uint64 { + return h.GetConsensusEndBlock() + h.BlockOffset +} + +func (h *MockHistory) GetStartTime() time.Time { + return h.BeaconConfig.GetSlotTime(h.GetConsensusStartBlock()) +} + +func (h *MockHistory) GetEndTime() time.Time { + return h.BeaconConfig.GetSlotTime(h.GetConsensusEndBlock()) +} + +func (h *MockHistory) GetPreviousRewardSnapshotEvent() rprewards.RewardsEvent { + intervalEpochLength := h.EndEpoch - h.StartEpoch + 1 + consensusEndBlock := h.BeaconConfig.LastSlotOfEpoch(h.StartEpoch - 1) + consensusStartBlock := consensusEndBlock - intervalEpochLength*h.BeaconConfig.SlotsPerEpoch + return rprewards.RewardsEvent{ + Index: big.NewInt(int64(h.NetworkDetails.RewardIndex - 1)), + ExecutionBlock: big.NewInt(int64(consensusEndBlock + h.BlockOffset)), + ConsensusBlock: big.NewInt(int64(consensusEndBlock)), + MerkleRoot: common.Hash{}, + MerkleTreeCID: "", + IntervalsPassed: big.NewInt(1), + TreasuryRPL: big.NewInt(0), + TrustedNodeRPL: []*big.Int{}, + NodeRPL: []*big.Int{}, + NodeETH: []*big.Int{}, + UserETH: big.NewInt(0), + IntervalStartTime: h.BeaconConfig.GetSlotTime(consensusStartBlock), + IntervalEndTime: h.BeaconConfig.GetSlotTime(consensusEndBlock), + SubmissionTime: h.BeaconConfig.GetSlotTime(consensusEndBlock), + } +} + +func (h *MockHistory) GetNodeSummary() map[string][]*MockNode { + out := make(map[string][]*MockNode) + for _, node := range h.Nodes { + out[node.Class] = append(out[node.Class], node) + } + return out +} diff --git a/shared/services/rewards/test/rocketpool.go b/shared/services/rewards/test/rocketpool.go new file mode 100644 index 000000000..c78541bac --- /dev/null +++ b/shared/services/rewards/test/rocketpool.go @@ -0,0 +1,83 @@ +package test + +import ( + "context" + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/rocket-pool/rocketpool-go/rewards" +) + +// MockRocketPool is a EC mock specifically for testing treegen. +// At a high level our approach is to provide two options to the tester: +// 1) Use a recording of request/response data from production to emulate a canonical tree +// 2) Allow for full response customization. +// +// The former is useful for ensuring that no regressions arise during refactors that should +// otherwise be nonfunction, ie, not impact the merkle root. +// +// The latter is useful to probe specific behaviors such as opt-in/opt-out eligibility timing, +// node weight, smoothing pool status, etc. +// +// Because recording responses ties the test to a specific version of the contracts and therefor +// the client-side code, the interface we need to mock should be as minimized as possible, and the +// recorded data should tightly match that interface. That is, no recorded response should encode +// something like the contract address data are being requested from, but instead the high-level +// function name and arguments. +type MockRocketPool struct { + t *testing.T + rewardSnapshotEvents map[uint64]rewards.RewardsEvent + headers map[uint64]*types.Header +} + +func NewMockRocketPool(t *testing.T) *MockRocketPool { + return &MockRocketPool{t: t} +} + +func (mock *MockRocketPool) GetNetworkEnabled(networkId *big.Int, opts *bind.CallOpts) (bool, error) { + mock.t.Logf("GetNetworkEnabled(%+v, %+v)", networkId, opts) + return true, nil +} + +func (mock *MockRocketPool) HeaderByNumber(_ context.Context, number *big.Int) (*types.Header, error) { + mock.t.Logf("HeaderByNumber(%+v)", number) + if header, ok := mock.headers[number.Uint64()]; ok { + return header, nil + } + return nil, fmt.Errorf("header not found in mock for %d, please set it with SetHeaderByNumber", number.Uint64()) +} + +func (mock *MockRocketPool) SetHeaderByNumber(number *big.Int, header *types.Header) { + if mock.headers == nil { + mock.headers = make(map[uint64]*types.Header) + } + mock.headers[number.Uint64()] = header +} + +func (mock *MockRocketPool) GetRewardsEvent(index uint64, _ []common.Address, opts *bind.CallOpts) (bool, rewards.RewardsEvent, error) { + mock.t.Logf("GetRewardsEvent(%+v, %+v)", index, opts) + + if event, ok := mock.rewardSnapshotEvents[index]; ok { + return true, event, nil + } + return false, rewards.RewardsEvent{}, nil +} + +func (mock *MockRocketPool) GetRewardSnapshotEvent(previousRewardsPoolAddresses []common.Address, interval uint64, opts *bind.CallOpts) (rewards.RewardsEvent, error) { + mock.t.Logf("GetRewardSnapshotEvent(%+v, %+v, %+v)", previousRewardsPoolAddresses, interval, opts) + if event, ok := mock.rewardSnapshotEvents[interval]; ok { + return event, nil + } + return rewards.RewardsEvent{}, nil +} + +func (mock *MockRocketPool) SetRewardSnapshotEvent(event rewards.RewardsEvent) { + if mock.rewardSnapshotEvents == nil { + mock.rewardSnapshotEvents = make(map[uint64]rewards.RewardsEvent) + } + mock.rewardSnapshotEvents[event.Index.Uint64()] = event +} diff --git a/shared/services/rewards/types.go b/shared/services/rewards/types.go index c82571672..07aa0162f 100644 --- a/shared/services/rewards/types.go +++ b/shared/services/rewards/types.go @@ -1,13 +1,18 @@ package rewards import ( + "context" "fmt" "math/big" "strings" "time" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/rocket-pool/rocketpool-go/rewards" "github.com/rocket-pool/rocketpool-go/types" + "github.com/rocket-pool/smartnode/shared/services/beacon" "github.com/wealdtech/go-merkletree" ) @@ -25,6 +30,27 @@ const ( minRewardsFileVersionSSZ = rewardsFileVersionThree ) +// RewardsExecutionClient defines and interface +// that contains only the functions from rocketpool.RocketPool +// required for rewards generation. +// This facade makes it easier to perform dependency injection in tests. +type RewardsExecutionClient interface { + GetNetworkEnabled(networkId *big.Int, opts *bind.CallOpts) (bool, error) + HeaderByNumber(context.Context, *big.Int) (*ethtypes.Header, error) + GetRewardsEvent(index uint64, rocketRewardsPoolAddresses []common.Address, opts *bind.CallOpts) (bool, rewards.RewardsEvent, error) + GetRewardSnapshotEvent(previousRewardsPoolAddresses []common.Address, interval uint64, opts *bind.CallOpts) (rewards.RewardsEvent, error) +} + +// RewardsBeaconClient defines and interface +// that contains only the functions from beacon.Client +// required for rewards generation. +// This facade makes it easier to perform dependency injection in tests. +type RewardsBeaconClient interface { + GetBeaconBlock(slot string) (beacon.BeaconBlock, bool, error) + GetCommitteesForEpoch(epoch *uint64) (beacon.Committees, error) + GetAttestations(slot string) ([]beacon.AttestationInfo, bool, error) +} + // Interface for version-agnostic minipool performance type IMinipoolPerformanceFile interface { // Serialize a minipool performance file into bytes @@ -65,8 +91,12 @@ type IRewardsFile interface { GetTotalCollateralRpl() *big.Int GetTotalNodeOperatorSmoothingPoolEth() *big.Int GetTotalPoolStakerSmoothingPoolEth() *big.Int + GetExecutionStartBlock() uint64 + GetConsensusStartBlock() uint64 GetExecutionEndBlock() uint64 GetConsensusEndBlock() uint64 + GetStartTime() time.Time + GetEndTime() time.Time // Get all of the node addresses with rewards in this file // NOTE: the order of node addresses is not guaranteed to be stable, so don't rely on it diff --git a/shared/services/rewards/utils.go b/shared/services/rewards/utils.go index ebb1cc201..6cf330b16 100644 --- a/shared/services/rewards/utils.go +++ b/shared/services/rewards/utils.go @@ -88,8 +88,11 @@ func GetIntervalInfo(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, no info.Index = interval var event rewards.RewardsEvent + previousRewardsPoolAddresses := cfg.Smartnode.GetPreviousRewardsPoolAddresses() + // Get the event details for this interval - event, err = GetRewardSnapshotEvent(rp, cfg, interval, opts) + client := NewRewardsExecutionClient(rp) + event, err = client.GetRewardSnapshotEvent(previousRewardsPoolAddresses, interval, opts) if err != nil { return } @@ -151,22 +154,6 @@ func GetIntervalInfo(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, no return } -// Get the event for a rewards snapshot -func GetRewardSnapshotEvent(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, interval uint64, opts *bind.CallOpts) (rewards.RewardsEvent, error) { - - addresses := cfg.Smartnode.GetPreviousRewardsPoolAddresses() - found, event, err := rewards.GetRewardsEvent(rp, interval, addresses, opts) - if err != nil { - return rewards.RewardsEvent{}, fmt.Errorf("error getting rewards event for interval %d: %w", interval, err) - } - if !found { - return rewards.RewardsEvent{}, fmt.Errorf("interval %d event not found", interval) - } - - return event, nil - -} - // Get the number of the latest EL block that was created before the given timestamp func GetELBlockHeaderForTime(targetTime time.Time, rp *rocketpool.RocketPool) (*types.Header, error) { diff --git a/shared/services/state/cli/.gitignore b/shared/services/state/cli/.gitignore new file mode 100644 index 000000000..d369ba9e0 --- /dev/null +++ b/shared/services/state/cli/.gitignore @@ -0,0 +1,2 @@ +cli +*.json diff --git a/shared/services/state/cli/cli.go b/shared/services/state/cli/cli.go new file mode 100644 index 000000000..1099a31df --- /dev/null +++ b/shared/services/state/cli/cli.go @@ -0,0 +1,121 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/rocket-pool/rocketpool-go/rocketpool" + "github.com/rocket-pool/smartnode/shared/services/beacon/client" + "github.com/rocket-pool/smartnode/shared/services/config" + "github.com/rocket-pool/smartnode/shared/services/state" + cfgtypes "github.com/rocket-pool/smartnode/shared/types/config" +) + +// A basic CLI tool which can be used to serialize NetworkState objects to files +// for future use. +// Accepts arguments for a beacon node URL, an execution node URL, and a slot number +// to get the state for. + +var bnFlag = flag.String("b", "http://localhost:5052", "The beacon node URL") +var elFlag = flag.String("e", "http://localhost:8545", "The execution node URL") +var slotFlag = flag.Uint64("slot", 0, "The slot number to get the state for") +var networkFlag = flag.String("network", "mainnet", "The network to get the state for, i.e. 'mainnet' or 'holesky'") +var prettyFlag = flag.Bool("p", false, "Pretty print the output") +var inputFlag = flag.Bool("i", false, "Parse a network state from stdin instead of retrieving it from the network") +var criticalDutiesSlotsFlag = flag.Bool("critical-duties-slots", false, "If passed, output a list of critical duties slots for the given state as if it were the final state in a 6300 epoch interval. This is outputted instead of the state json.") +var criticalDutiesEpochCountFlag = flag.Uint64("critical-duties-epoch-count", 6300, "The number of epochs to consider when calculating critical duties") + +func main() { + flag.Parse() + + sn := config.NewSmartnodeConfig(nil) + switch *networkFlag { + case "mainnet": + sn.Network.Value = cfgtypes.Network_Mainnet + case "holesky": + sn.Network.Value = cfgtypes.Network_Holesky + default: + fmt.Fprintf(os.Stderr, "Invalid network: %s\n", *networkFlag) + fmt.Fprintf(os.Stderr, "Valid networks are: mainnet, holesky\n") + os.Exit(1) + } + + ec, err := ethclient.Dial(*elFlag) + if err != nil { + fmt.Fprintf(os.Stderr, "Error connecting to execution node: %v\n", err) + os.Exit(1) + } + + contracts := sn.GetStateManagerContracts() + fmt.Fprintf(os.Stderr, "Contracts: %+v\n", contracts) + + rocketStorage := sn.GetStorageAddress() + + rp, err := rocketpool.NewRocketPool(ec, common.HexToAddress(rocketStorage)) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating rocketpool: %v\n", err) + os.Exit(1) + } + bc := client.NewStandardHttpClient(*bnFlag) + sm := state.NewNetworkStateManager(rp, contracts, bc, nil) + + var networkState *state.NetworkState + + if *inputFlag { + decoder := json.NewDecoder(os.Stdin) + err := decoder.Decode(&networkState) + if err != nil { + fmt.Fprintf(os.Stderr, "Error decoding network state: %v\n", err) + os.Exit(1) + } + } else if *slotFlag == 0 { + fmt.Fprintf(os.Stderr, "Slot number not provided, defaulting to head slot.\n") + networkState, err = sm.GetHeadState() + } else { + networkState, err = sm.GetStateForSlot(*slotFlag) + } + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting network state: %v\n", err) + os.Exit(1) + } + + if *criticalDutiesSlotsFlag { + criticalDutiesEpochs := state.NewCriticalDutiesEpochs(*criticalDutiesEpochCountFlag, networkState) + fmt.Fprintf(os.Stderr, "Critical duties epochs to check: %d\n", len(criticalDutiesEpochs.CriticalDuties)) + + criticalDutiesSlots, err := state.NewCriticalDutiesSlots(criticalDutiesEpochs, bc) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating critical duties slots: %v\n", err) + os.Exit(1) + } + + // Serialize the critical duties slots to stdout + encoder := json.NewEncoder(os.Stdout) + if *prettyFlag { + encoder.SetIndent("", " ") + } + err = encoder.Encode(criticalDutiesSlots) + if err != nil { + fmt.Fprintf(os.Stderr, "Error encoding critical duties slots: %v\n", err) + os.Exit(1) + } + + os.Exit(0) + return + } + + fmt.Fprintf(os.Stderr, "Network state fetched, outputting to stdout\n") + encoder := json.NewEncoder(os.Stdout) + if *prettyFlag { + encoder.SetIndent("", " ") + } + err = encoder.Encode(networkState) + if err != nil { + fmt.Fprintf(os.Stderr, "Error encoding network state: %v\n", err) + os.Exit(1) + } +} diff --git a/shared/services/state/critical-duties-slots.go b/shared/services/state/critical-duties-slots.go new file mode 100644 index 000000000..8a81661eb --- /dev/null +++ b/shared/services/state/critical-duties-slots.go @@ -0,0 +1,103 @@ +package state + +import ( + "github.com/rocket-pool/smartnode/shared/services/beacon" +) + +type CriticalDutiesEpochs struct { + // Map of epoch uint64 to a list of validator indices + CriticalDuties map[uint64][]string +} + +type CriticalDutiesSlots struct { + // Map of validator index to a list of critical duties slots + CriticalDuties map[string][]uint64 +} + +// Gets the critical duties slots for a given state as if it were the final state in a epochs epoch interval +func NewCriticalDutiesEpochs(epochs uint64, state *NetworkState) *CriticalDutiesEpochs { + criticalDuties := &CriticalDutiesEpochs{ + CriticalDuties: make(map[uint64][]string), + } + + endSlot := state.BeaconSlotNumber + endEpoch := state.BeaconConfig.SlotToEpoch(endSlot) + // Coerce endSlot to the last slot of the epoch + endSlot = state.BeaconConfig.LastSlotOfEpoch(endEpoch) + // Get the start epoch. Since the end epoch is the last inclusive epoch, we need to subtract 1 from the start epoch + startEpoch := endEpoch - epochs - 1 + + // Check for bond reductions first + for _, minipool := range state.MinipoolDetails { + lastReductionSlot := state.BeaconConfig.FirstSlotAtLeast(minipool.LastBondReductionTime.Int64()) + lastReductionEpoch := state.BeaconConfig.SlotToEpoch(lastReductionSlot) + if lastReductionEpoch < startEpoch { + continue + } + + if lastReductionEpoch > endEpoch { + continue + } + + pubkey := minipool.Pubkey + validatorIndex := state.ValidatorDetails[pubkey].Index + criticalDuties.CriticalDuties[lastReductionEpoch] = append(criticalDuties.CriticalDuties[lastReductionEpoch], validatorIndex) + } + + // Check for smoothing pool opt status changes next + for _, node := range state.NodeDetails { + lastOptStatusChange := state.BeaconConfig.FirstSlotAtLeast(node.SmoothingPoolRegistrationChanged.Int64()) + lastOptStatusChangeEpoch := state.BeaconConfig.SlotToEpoch(lastOptStatusChange) + if lastOptStatusChangeEpoch < startEpoch { + continue + } + + if lastOptStatusChangeEpoch > endEpoch { + continue + } + + // Flag every minipool for this node as having a critical duty + for _, minipool := range state.MinipoolDetailsByNode[node.NodeAddress] { + pubkey := minipool.Pubkey + validatorIndex := state.ValidatorDetails[pubkey].Index + criticalDuties.CriticalDuties[lastOptStatusChangeEpoch] = append(criticalDuties.CriticalDuties[lastOptStatusChangeEpoch], validatorIndex) + } + } + + return criticalDuties +} + +// For each validator in criticalDutiesEpochs, map the epochs to the slot the attestation duty assignment was for +func NewCriticalDutiesSlots(criticalDutiesEpochs *CriticalDutiesEpochs, bc beacon.Client) (*CriticalDutiesSlots, error) { + criticalDuties := &CriticalDutiesSlots{ + CriticalDuties: make(map[string][]uint64), + } + + for epoch, validatorIndices := range criticalDutiesEpochs.CriticalDuties { + // Create a set of validator indices to query when iterating committees + validatorIndicesSet := make(map[string]interface{}) + for _, validatorIndex := range validatorIndices { + validatorIndicesSet[validatorIndex] = struct{}{} + } + + // Get the beacon committee assignments for this epoch + // Rebind e to avoid using a pointer to the accumulator. + e := epoch + committees, err := bc.GetCommitteesForEpoch(&e) + if err != nil { + return nil, err + } + + // Iterate over the committees and check if the validator indices are in the set + for i := 0; i < committees.Count(); i++ { + validators := committees.Validators(i) + for _, validator := range validators { + if _, ok := validatorIndicesSet[validator]; ok { + criticalDuties.CriticalDuties[validator] = append(criticalDuties.CriticalDuties[validator], committees.Slot(i)) + } + } + } + } + + return criticalDuties, nil +} diff --git a/shared/services/state/manager.go b/shared/services/state/manager.go index c3c8d523c..25c856fb9 100644 --- a/shared/services/state/manager.go +++ b/shared/services/state/manager.go @@ -10,46 +10,51 @@ import ( "github.com/rocket-pool/rocketpool-go/rocketpool" "github.com/rocket-pool/smartnode/shared/services/beacon" "github.com/rocket-pool/smartnode/shared/services/config" - cfgtypes "github.com/rocket-pool/smartnode/shared/types/config" "github.com/rocket-pool/smartnode/shared/utils/log" ) type NetworkStateManager struct { - cfg *config.RocketPoolConfig - rp *rocketpool.RocketPool - ec rocketpool.ExecutionClient - bc beacon.Client - log *log.ColorLogger - Config *config.RocketPoolConfig - Network cfgtypes.Network - ChainID uint - BeaconConfig beacon.Eth2Config + rp *rocketpool.RocketPool + bc beacon.Client + log *log.ColorLogger + + // Memoized Beacon config + beaconConfig *beacon.Eth2Config + + // Multicaller and batch balance contract addresses + contracts config.StateManagerContracts } // Create a new manager for the network state -func NewNetworkStateManager(rp *rocketpool.RocketPool, cfg *config.RocketPoolConfig, ec rocketpool.ExecutionClient, bc beacon.Client, log *log.ColorLogger) (*NetworkStateManager, error) { +func NewNetworkStateManager( + rp *rocketpool.RocketPool, + contracts config.StateManagerContracts, + bc beacon.Client, + log *log.ColorLogger, +) *NetworkStateManager { // Create the manager - m := &NetworkStateManager{ - cfg: cfg, - rp: rp, - ec: ec, - bc: bc, - log: log, - Config: cfg, - Network: cfg.Smartnode.Network.Value.(cfgtypes.Network), - ChainID: cfg.Smartnode.GetChainID(), + return &NetworkStateManager{ + rp: rp, + bc: bc, + log: log, + contracts: contracts, + } +} + +func (m *NetworkStateManager) getBeaconConfig() (*beacon.Eth2Config, error) { + if m.beaconConfig != nil { + return m.beaconConfig, nil } // Get the Beacon config info - var err error - m.BeaconConfig, err = m.bc.GetEth2Config() + beaconConfig, err := m.bc.GetEth2Config() if err != nil { return nil, err } + m.beaconConfig = &beaconConfig - return m, nil - + return m.beaconConfig, nil } // Get the state of the network using the latest Execution layer block @@ -86,27 +91,35 @@ func (m *NetworkStateManager) GetLatestBeaconBlock() (beacon.BeaconBlock, error) // Gets the latest valid finalized block func (m *NetworkStateManager) GetLatestFinalizedBeaconBlock() (beacon.BeaconBlock, error) { + beaconConfig, err := m.getBeaconConfig() + if err != nil { + return beacon.BeaconBlock{}, fmt.Errorf("error getting Beacon config: %w", err) + } head, err := m.bc.GetBeaconHead() if err != nil { return beacon.BeaconBlock{}, fmt.Errorf("error getting Beacon chain head: %w", err) } - targetSlot := head.FinalizedEpoch*m.BeaconConfig.SlotsPerEpoch + (m.BeaconConfig.SlotsPerEpoch - 1) + targetSlot := head.FinalizedEpoch*beaconConfig.SlotsPerEpoch + (beaconConfig.SlotsPerEpoch - 1) return m.GetLatestProposedBeaconBlock(targetSlot) } // Gets the Beacon slot for the latest execution layer block func (m *NetworkStateManager) GetHeadSlot() (uint64, error) { + beaconConfig, err := m.getBeaconConfig() + if err != nil { + return 0, fmt.Errorf("error getting Beacon config: %w", err) + } // Get the latest EL block - latestBlockHeader, err := m.ec.HeaderByNumber(context.Background(), nil) + latestBlockHeader, err := m.rp.Client.HeaderByNumber(context.Background(), nil) if err != nil { return 0, fmt.Errorf("error getting latest EL block: %w", err) } // Get the corresponding Beacon slot based on the timestamp latestBlockTime := time.Unix(int64(latestBlockHeader.Time), 0) - genesisTime := time.Unix(int64(m.BeaconConfig.GenesisTime), 0) + genesisTime := time.Unix(int64(beaconConfig.GenesisTime), 0) secondsSinceGenesis := uint64(latestBlockTime.Sub(genesisTime).Seconds()) - targetSlot := secondsSinceGenesis / m.BeaconConfig.SecondsPerSlot + targetSlot := secondsSinceGenesis / beaconConfig.SecondsPerSlot return targetSlot, nil } @@ -131,7 +144,11 @@ func (m *NetworkStateManager) GetLatestProposedBeaconBlock(targetSlot uint64) (b // Get the state of the network at the provided Beacon slot func (m *NetworkStateManager) getState(slotNumber uint64) (*NetworkState, error) { - state, err := CreateNetworkState(m.cfg, m.rp, m.ec, m.bc, m.log, slotNumber, m.BeaconConfig) + beaconConfig, err := m.getBeaconConfig() + if err != nil { + return nil, fmt.Errorf("error getting Beacon config: %w", err) + } + state, err := createNetworkState(m.contracts, m.rp, m.bc, m.log, slotNumber, beaconConfig) if err != nil { return nil, err } @@ -140,7 +157,11 @@ func (m *NetworkStateManager) getState(slotNumber uint64) (*NetworkState, error) // Get the state of the network for a specific node only at the provided Beacon slot func (m *NetworkStateManager) getStateForNode(nodeAddress common.Address, slotNumber uint64, calculateTotalEffectiveStake bool) (*NetworkState, *big.Int, error) { - state, totalEffectiveStake, err := CreateNetworkStateForNode(m.cfg, m.rp, m.ec, m.bc, m.log, slotNumber, m.BeaconConfig, nodeAddress, calculateTotalEffectiveStake) + beaconConfig, err := m.getBeaconConfig() + if err != nil { + return nil, nil, fmt.Errorf("error getting Beacon config: %w", err) + } + state, totalEffectiveStake, err := createNetworkStateForNode(m.contracts, m.rp, m.bc, m.log, slotNumber, beaconConfig, nodeAddress, calculateTotalEffectiveStake) if err != nil { return nil, nil, err } diff --git a/shared/services/state/network-state.go b/shared/services/state/network-state.go index fe77381e9..7c913654b 100644 --- a/shared/services/state/network-state.go +++ b/shared/services/state/network-state.go @@ -1,6 +1,7 @@ package state import ( + "encoding/json" "fmt" "math/big" "time" @@ -31,44 +32,134 @@ var fifteenEth = big.NewInt(0).Mul(big.NewInt(15), oneEth) var _13_6137_Eth = big.NewInt(0).Mul(big.NewInt(136137), big.NewInt(1e14)) var _13_Eth = big.NewInt(0).Mul(big.NewInt(13), oneEth) +type ValidatorDetailsMap map[types.ValidatorPubkey]beacon.ValidatorStatus + +func (vdm ValidatorDetailsMap) MarshalJSON() ([]byte, error) { + // Marshal as a slice of ValidatorStatus + out := make([]beacon.ValidatorStatus, 0, len(vdm)) + for _, v := range vdm { + out = append(out, v) + } + return json.Marshal(out) +} + +func (vdm *ValidatorDetailsMap) UnmarshalJSON(data []byte) error { + // Unmarshal as a slice of ValidatorStatus + var inp []beacon.ValidatorStatus + err := json.Unmarshal(data, &inp) + if err != nil { + return err + } + + *vdm = make(ValidatorDetailsMap, len(inp)) + + // Convert back to a map + for _, v := range inp { + // Return an error if the pubkey is already in the map + if _, exists := (*vdm)[v.Pubkey]; exists { + return fmt.Errorf("duplicate validator details for pubkey %s", v.Pubkey.Hex()) + } + (*vdm)[v.Pubkey] = v + } + return nil +} + type NetworkState struct { // Network version // Block / slot for this state - ElBlockNumber uint64 - BeaconSlotNumber uint64 - BeaconConfig beacon.Eth2Config + ElBlockNumber uint64 `json:"el_block_number"` + BeaconSlotNumber uint64 `json:"beacon_slot_number"` + BeaconConfig beacon.Eth2Config `json:"beacon_config"` // Network details - NetworkDetails *rpstate.NetworkDetails + NetworkDetails *rpstate.NetworkDetails `json:"network_details"` // Node details - NodeDetails []rpstate.NativeNodeDetails - NodeDetailsByAddress map[common.Address]*rpstate.NativeNodeDetails + NodeDetails []rpstate.NativeNodeDetails `json:"node_details"` + // NodeDetailsByAddress is an index over NodeDetails and is ignored when marshaling to JSON + // it is rebuilt when unmarshaling from JSON. + NodeDetailsByAddress map[common.Address]*rpstate.NativeNodeDetails `json:"-"` // Minipool details - MinipoolDetails []rpstate.NativeMinipoolDetails - MinipoolDetailsByAddress map[common.Address]*rpstate.NativeMinipoolDetails - MinipoolDetailsByNode map[common.Address][]*rpstate.NativeMinipoolDetails + MinipoolDetails []rpstate.NativeMinipoolDetails `json:"minipool_details"` + // These next two fields are indexes over MinipoolDetails and are ignored when marshaling to JSON + // they are rebuilt when unmarshaling from JSON. + MinipoolDetailsByAddress map[common.Address]*rpstate.NativeMinipoolDetails `json:"-"` + MinipoolDetailsByNode map[common.Address][]*rpstate.NativeMinipoolDetails `json:"-"` // Validator details - ValidatorDetails map[types.ValidatorPubkey]beacon.ValidatorStatus + ValidatorDetails ValidatorDetailsMap `json:"validator_details"` // Oracle DAO details - OracleDaoMemberDetails []rpstate.OracleDaoMemberDetails + OracleDaoMemberDetails []rpstate.OracleDaoMemberDetails `json:"oracle_dao_member_details"` // Protocol DAO proposals - ProtocolDaoProposalDetails []protocol.ProtocolDaoProposalDetails + ProtocolDaoProposalDetails []protocol.ProtocolDaoProposalDetails `json:"protocol_dao_proposal_details,omitempty"` // Internal fields log *log.ColorLogger } +func (ns NetworkState) MarshalJSON() ([]byte, error) { + // No changes needed + type Alias NetworkState + a := (*Alias)(&ns) + return json.Marshal(a) +} + +func (ns *NetworkState) UnmarshalJSON(data []byte) error { + type Alias NetworkState + var a Alias + err := json.Unmarshal(data, &a) + if err != nil { + return err + } + *ns = NetworkState(a) + // Rebuild the node details by address index + ns.NodeDetailsByAddress = make(map[common.Address]*rpstate.NativeNodeDetails) + for i, details := range ns.NodeDetails { + if _, ok := ns.NodeDetailsByAddress[details.NodeAddress]; ok { + return fmt.Errorf("duplicate node details for address %s", details.NodeAddress.Hex()) + } + // N.B. &details is not the same as &ns.NodeDetails[i] + // &details is the address of the current element in the loop + // &ns.NodeDetails[i] is the address of the struct in the slice + ns.NodeDetailsByAddress[details.NodeAddress] = &ns.NodeDetails[i] + } + + // Rebuild the minipool details by address index + ns.MinipoolDetailsByAddress = make(map[common.Address]*rpstate.NativeMinipoolDetails) + for i, details := range ns.MinipoolDetails { + if _, ok := ns.MinipoolDetailsByAddress[details.MinipoolAddress]; ok { + return fmt.Errorf("duplicate minipool details for address %s", details.MinipoolAddress.Hex()) + } + + // N.B. &details is not the same as &ns.MinipoolDetails[i] + // &details is the address of the current element in the loop + // &ns.MinipoolDetails[i] is the address of the struct in the slice + ns.MinipoolDetailsByAddress[details.MinipoolAddress] = &ns.MinipoolDetails[i] + } + + // Rebuild the minipool details by node index + ns.MinipoolDetailsByNode = make(map[common.Address][]*rpstate.NativeMinipoolDetails) + for i, details := range ns.MinipoolDetails { + // See comments in above loops as to why we're using &ns.MinipoolDetails[i] + currentDetails := &ns.MinipoolDetails[i] + nodeList, exists := ns.MinipoolDetailsByNode[details.NodeAddress] + if !exists { + ns.MinipoolDetailsByNode[details.NodeAddress] = []*rpstate.NativeMinipoolDetails{currentDetails} + continue + } + // See comments in other loops + ns.MinipoolDetailsByNode[details.NodeAddress] = append(nodeList, currentDetails) + } + + return nil +} + // Creates a snapshot of the entire Rocket Pool network state, on both the Execution and Consensus layers -func CreateNetworkState(cfg *config.RocketPoolConfig, rp *rocketpool.RocketPool, ec rocketpool.ExecutionClient, bc beacon.Client, log *log.ColorLogger, slotNumber uint64, beaconConfig beacon.Eth2Config) (*NetworkState, error) { - // Get the relevant network contracts - multicallerAddress := common.HexToAddress(cfg.Smartnode.GetMulticallAddress()) - balanceBatcherAddress := common.HexToAddress(cfg.Smartnode.GetBalanceBatcherAddress()) +func createNetworkState(batchContracts config.StateManagerContracts, rp *rocketpool.RocketPool, bc beacon.Client, log *log.ColorLogger, slotNumber uint64, beaconConfig *beacon.Eth2Config) (*NetworkState, error) { // Get the execution block for the given slot beaconBlock, exists, err := bc.GetBeaconBlock(fmt.Sprintf("%d", slotNumber)) @@ -92,7 +183,7 @@ func CreateNetworkState(cfg *config.RocketPoolConfig, rp *rocketpool.RocketPool, MinipoolDetailsByNode: map[common.Address][]*rpstate.NativeMinipoolDetails{}, BeaconSlotNumber: slotNumber, ElBlockNumber: elBlockNumber, - BeaconConfig: beaconConfig, + BeaconConfig: *beaconConfig, log: log, } @@ -100,7 +191,7 @@ func CreateNetworkState(cfg *config.RocketPoolConfig, rp *rocketpool.RocketPool, start := time.Now() // Network contracts and details - contracts, err := rpstate.NewNetworkContracts(rp, multicallerAddress, balanceBatcherAddress, opts) + contracts, err := rpstate.NewNetworkContracts(rp, batchContracts.Multicaller, batchContracts.BalanceBatcher, opts) if err != nil { return nil, fmt.Errorf("error getting network contracts: %w", err) } @@ -149,7 +240,7 @@ func CreateNetworkState(cfg *config.RocketPoolConfig, rp *rocketpool.RocketPool, // Calculate avg node fees and distributor shares for _, details := range state.NodeDetails { - rpstate.CalculateAverageFeeAndDistributorShares(rp, contracts, details, state.MinipoolDetailsByNode[details.NodeAddress]) + details.CalculateAverageFeeAndDistributorShares(state.MinipoolDetailsByNode[details.NodeAddress]) } // Oracle DAO member details @@ -193,16 +284,12 @@ func CreateNetworkState(cfg *config.RocketPoolConfig, rp *rocketpool.RocketPool, // Creates a snapshot of the Rocket Pool network, but only for a single node // Also gets the total effective RPL stake of the network for convenience since this is required by several node routines -func CreateNetworkStateForNode(cfg *config.RocketPoolConfig, rp *rocketpool.RocketPool, ec rocketpool.ExecutionClient, bc beacon.Client, log *log.ColorLogger, slotNumber uint64, beaconConfig beacon.Eth2Config, nodeAddress common.Address, calculateTotalEffectiveStake bool) (*NetworkState, *big.Int, error) { +func createNetworkStateForNode(batchContracts config.StateManagerContracts, rp *rocketpool.RocketPool, bc beacon.Client, log *log.ColorLogger, slotNumber uint64, beaconConfig *beacon.Eth2Config, nodeAddress common.Address, calculateTotalEffectiveStake bool) (*NetworkState, *big.Int, error) { steps := 5 if calculateTotalEffectiveStake { steps++ } - // Get the relevant network contracts - multicallerAddress := common.HexToAddress(cfg.Smartnode.GetMulticallAddress()) - balanceBatcherAddress := common.HexToAddress(cfg.Smartnode.GetBalanceBatcherAddress()) - // Get the execution block for the given slot beaconBlock, exists, err := bc.GetBeaconBlock(fmt.Sprintf("%d", slotNumber)) if err != nil { @@ -225,7 +312,7 @@ func CreateNetworkStateForNode(cfg *config.RocketPoolConfig, rp *rocketpool.Rock MinipoolDetailsByNode: map[common.Address][]*rpstate.NativeMinipoolDetails{}, BeaconSlotNumber: slotNumber, ElBlockNumber: elBlockNumber, - BeaconConfig: beaconConfig, + BeaconConfig: *beaconConfig, log: log, } @@ -233,7 +320,7 @@ func CreateNetworkStateForNode(cfg *config.RocketPoolConfig, rp *rocketpool.Rock start := time.Now() // Network contracts and details - contracts, err := rpstate.NewNetworkContracts(rp, multicallerAddress, balanceBatcherAddress, opts) + contracts, err := rpstate.NewNetworkContracts(rp, batchContracts.Multicaller, batchContracts.BalanceBatcher, opts) if err != nil { return nil, nil, fmt.Errorf("error getting network contracts: %w", err) } @@ -283,7 +370,7 @@ func CreateNetworkStateForNode(cfg *config.RocketPoolConfig, rp *rocketpool.Rock // Calculate avg node fees and distributor shares for _, details := range state.NodeDetails { - rpstate.CalculateAverageFeeAndDistributorShares(rp, contracts, details, state.MinipoolDetailsByNode[details.NodeAddress]) + details.CalculateAverageFeeAndDistributorShares(state.MinipoolDetailsByNode[details.NodeAddress]) } // Get the total network effective RPL stake diff --git a/shared/services/state/utils.go b/shared/services/state/utils.go index b09b06c28..d7bb23dd0 100644 --- a/shared/services/state/utils.go +++ b/shared/services/state/utils.go @@ -7,31 +7,30 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/rocket-pool/rocketpool-go/rewards" "github.com/rocket-pool/rocketpool-go/rocketpool" - "github.com/rocket-pool/smartnode/shared/services/config" ) // TODO: temp until rocketpool-go supports RocketStorage contract address lookups per block -func GetClaimIntervalTime(cfg *config.RocketPoolConfig, index uint64, rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { +func GetClaimIntervalTime(index uint64, rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { return rewards.GetClaimIntervalTime(rp, opts) } // TODO: temp until rocketpool-go supports RocketStorage contract address lookups per block -func GetNodeOperatorRewardsPercent(cfg *config.RocketPoolConfig, index uint64, rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { +func GetNodeOperatorRewardsPercent(index uint64, rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { return rewards.GetNodeOperatorRewardsPercent(rp, opts) } // TODO: temp until rocketpool-go supports RocketStorage contract address lookups per block -func GetTrustedNodeOperatorRewardsPercent(cfg *config.RocketPoolConfig, index uint64, rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { +func GetTrustedNodeOperatorRewardsPercent(index uint64, rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { return rewards.GetTrustedNodeOperatorRewardsPercent(rp, opts) } // TODO: temp until rocketpool-go supports RocketStorage contract address lookups per block -func GetProtocolDaoRewardsPercent(cfg *config.RocketPoolConfig, index uint64, rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { +func GetProtocolDaoRewardsPercent(index uint64, rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { return rewards.GetProtocolDaoRewardsPercent(rp, opts) } // TODO: temp until rocketpool-go supports RocketStorage contract address lookups per block -func GetPendingRPLRewards(cfg *config.RocketPoolConfig, index uint64, rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { +func GetPendingRPLRewards(index uint64, rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { return rewards.GetPendingRPLRewards(rp, opts) }