diff --git a/bindings/megapool/beacon-state-verifier.go b/bindings/megapool/beacon-state-verifier.go index 9752368e2..4cdbe6a57 100644 --- a/bindings/megapool/beacon-state-verifier.go +++ b/bindings/megapool/beacon-state-verifier.go @@ -61,15 +61,3 @@ func GetBeaconStateVerifierVersion(rp *rocketpool.RocketPool, opts *bind.CallOpt } return rocketpool.GetContractVersion(rp, *beaconStateVerifier.Address, opts) } - -func UsesProofBundles(rp *rocketpool.RocketPool, opts *bind.CallOpts) (bool, error) { - version, err := GetBeaconStateVerifierVersion(rp, opts) - if err != nil { - return false, err - } - return usesProofBundles(version), nil -} - -func usesProofBundles(beaconStateVerifierVersion uint8) bool { - return beaconStateVerifierVersion >= 2 -} diff --git a/bindings/megapool/challenge-bonds.go b/bindings/megapool/challenge-bonds.go new file mode 100644 index 000000000..5f4d52741 --- /dev/null +++ b/bindings/megapool/challenge-bonds.go @@ -0,0 +1,86 @@ +package megapool + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/bindings/logs" + "github.com/rocket-pool/smartnode/bindings/rocketpool" +) + +// GetChallengeBondCandidates finds bonds proposed by this node and defeated +// challenges of either pool type. Defeat events do not identify the responder; +// callers must filter these candidates using getChallengeBondDetails. +func GetChallengeBondCandidates(rp *rocketpool.RocketPool, node common.Address, interval, fromBlock, toBlock *big.Int, opts *bind.CallOpts) ([]*big.Int, error) { + contract, err := getRocketNetworkParticipation(rp, opts) + if err != nil { + return nil, err + } + locked, ok := contract.ABI.Events["ChallengeBondLocked"] + if !ok { + return nil, fmt.Errorf("ChallengeBondLocked event missing from participation ABI") + } + megapoolDefeated, ok := contract.ABI.Events["MegapoolChallengeDefeated"] + if !ok { + return nil, fmt.Errorf("MegapoolChallengeDefeated event missing from participation ABI") + } + minipoolDefeated, ok := contract.ABI.Events["MinipoolChallengeDefeated"] + if !ok { + return nil, fmt.Errorf("MinipoolChallengeDefeated event missing from participation ABI") + } + fromBlock, toBlock, err = performanceChallengeScanRange(rp, fromBlock, toBlock, opts) + if err != nil || fromBlock == nil { + return nil, err + } + addresses, err := participationContractAddresses(rp, contract, interval, fromBlock, toBlock, opts) + if err != nil { + return nil, err + } + lockedLogs, err := logs.GetLogs(rp, addresses, [][]common.Hash{{locked.ID}, nil, {common.BytesToHash(node.Bytes())}}, interval, fromBlock, toBlock, nil) + if err != nil { + return nil, err + } + defeatedLogs, err := logs.GetLogs(rp, addresses, [][]common.Hash{{megapoolDefeated.ID, minipoolDefeated.ID}}, interval, fromBlock, toBlock, nil) + if err != nil { + return nil, err + } + ids := []*big.Int{} + seen := map[string]bool{} + add := func(id *big.Int) { + if !seen[id.String()] { + ids = append(ids, id) + seen[id.String()] = true + } + } + for _, entry := range lockedLogs { + if entry.Removed { + continue + } + if len(entry.Topics) != 3 || entry.Topics[0] != locked.ID { + return nil, fmt.Errorf("invalid ChallengeBondLocked event") + } + add(entry.Topics[1].Big()) + } + for _, entry := range defeatedLogs { + if entry.Removed { + continue + } + if len(entry.Topics) != 1 { + return nil, fmt.Errorf("invalid challenge defeat event") + } + event := megapoolDefeated + if entry.Topics[0] == minipoolDefeated.ID { + event = minipoolDefeated + } else if entry.Topics[0] != megapoolDefeated.ID { + return nil, fmt.Errorf("unexpected challenge defeat event") + } + values, err := event.Inputs.NonIndexed().Unpack(entry.Data) + if err != nil || len(values) != 1 { + return nil, fmt.Errorf("invalid challenge defeat data: %v", err) + } + add(values[0].(*big.Int)) + } + return ids, nil +} diff --git a/bindings/megapool/performance-challenges.go b/bindings/megapool/performance-challenges.go new file mode 100644 index 000000000..b030c6a87 --- /dev/null +++ b/bindings/megapool/performance-challenges.go @@ -0,0 +1,175 @@ +package megapool + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/rocket-pool/smartnode/bindings/logs" + "github.com/rocket-pool/smartnode/bindings/rocketpool" +) + +// PerformanceChallenge contains the data needed to reconstruct a challenge proof. +type PerformanceChallenge struct { + ChallengeId *big.Int + MegapoolAddress common.Address + NodeAddress common.Address + MinipoolAddresses []common.Address + ValidatorIds []uint32 + StartEpoch uint64 + Participation []*big.Int +} + +type PerformanceChallengeStatus struct { + Proposer common.Address + Responder common.Address + BondAmount *big.Int + ResponseDeadline *big.Int + BondSettled bool + Responded bool + Finalised bool +} + +func (s PerformanceChallengeStatus) CanReleaseBond(timestamp uint64) bool { + return !s.BondSettled && !s.Responded && s.ResponseDeadline.Cmp(new(big.Int).SetUint64(timestamp)) < 0 +} + +func (s PerformanceChallengeStatus) CanClaimReward(caller common.Address) bool { + return !s.BondSettled && s.Responded && s.Responder == caller +} + +// GetMegapoolPerformanceChallenges reads challenge events, including events from +// previous deployments of rocketNetworkParticipation. A nil fromBlock scans +// from the first challenge, so overdue unresolved challenges are included. +func GetMegapoolPerformanceChallenges(rp *rocketpool.RocketPool, address common.Address, interval, fromBlock, toBlock *big.Int, opts *bind.CallOpts) ([]PerformanceChallenge, error) { + contract, err := getRocketNetworkParticipation(rp, opts) + if err != nil { + return nil, err + } + event, ok := contract.ABI.Events["MegapoolChallenged"] + if !ok { + return nil, fmt.Errorf("MegapoolChallenged event not found in rocketNetworkParticipation ABI") + } + fromBlock, toBlock, err = performanceChallengeScanRange(rp, fromBlock, toBlock, opts) + if err != nil || fromBlock == nil { + return nil, err + } + addresses, err := participationContractAddresses(rp, contract, interval, fromBlock, toBlock, opts) + if err != nil { + return nil, err + } + + entries, err := logs.GetLogs(rp, addresses, [][]common.Hash{ + {event.ID}, {common.BytesToHash(address.Bytes())}, + }, interval, fromBlock, toBlock, nil) + if err != nil { + return nil, err + } + challenges := make([]PerformanceChallenge, 0, len(entries)) + for _, entry := range entries { + if entry.Removed { + continue + } + challenge, err := decodePerformanceChallenge(event, entry) + if err != nil { + return nil, err + } + challenges = append(challenges, challenge) + } + return challenges, nil +} + +func decodePerformanceChallenge(event abi.Event, entry types.Log) (PerformanceChallenge, error) { + if len(entry.Topics) != 3 || entry.Topics[0] != event.ID { + return PerformanceChallenge{}, fmt.Errorf("invalid MegapoolChallenged event topics") + } + values, err := event.Inputs.NonIndexed().Unpack(entry.Data) + if err != nil { + return PerformanceChallenge{}, fmt.Errorf("error unpacking MegapoolChallenged: %w", err) + } + if len(values) != 4 { + return PerformanceChallenge{}, fmt.Errorf("invalid MegapoolChallenged event data") + } + ids, idsOK := values[0].([]uint32) + start, startOK := values[1].(uint64) + participation, participationOK := values[3].([]*big.Int) + if !idsOK || !startOK || !participationOK || len(ids) == 0 || len(participation) == 0 { + return PerformanceChallenge{}, fmt.Errorf("invalid MegapoolChallenged validator list or participation bitmap") + } + return PerformanceChallenge{ + ChallengeId: entry.Topics[2].Big(), MegapoolAddress: common.BytesToAddress(entry.Topics[1].Bytes()), + ValidatorIds: ids, StartEpoch: start, Participation: participation, + }, nil +} + +// GetPerformanceChallengeStatus reads the snapshotted deadline and the two +// terminal flags. Bond settlement is independent of challenge finalisation. +func GetPerformanceChallengeStatus(rp *rocketpool.RocketPool, challengeId *big.Int, opts *bind.CallOpts) (PerformanceChallengeStatus, error) { + contract, err := getRocketNetworkParticipation(rp, opts) + if err != nil { + return PerformanceChallengeStatus{}, err + } + var values []interface{} + if err := contract.Contract.Call(opts, &values, "getChallengeBondDetails", challengeId); err != nil { + return PerformanceChallengeStatus{}, fmt.Errorf("error getting challenge %s bond details: %w", challengeId, err) + } + if len(values) != 5 { + return PerformanceChallengeStatus{}, fmt.Errorf("invalid challenge bond details") + } + status := PerformanceChallengeStatus{ + Proposer: values[0].(common.Address), Responder: values[1].(common.Address), + BondAmount: values[2].(*big.Int), ResponseDeadline: values[3].(*big.Int), BondSettled: values[4].(bool), + } + for key, target := range map[string]*bool{ + "participation.challenge.responded": &status.Responded, + "participation.challenge.finalised": &status.Finalised, + } { + // Solidity uses abi.encodePacked(string, uint256) for these storage keys. + hash := crypto.Keccak256Hash([]byte(key), common.LeftPadBytes(challengeId.Bytes(), 32)) + value, err := rp.RocketStorage.GetBool(opts, hash) + if err != nil { + return PerformanceChallengeStatus{}, fmt.Errorf("error getting challenge %s status: %w", challengeId, err) + } + *target = value + } + return status, nil +} + +// participationContractAddresses includes upgraded implementations without rescanning +// upgrade history outside the requested event range. +func participationContractAddresses(rp *rocketpool.RocketPool, contract *rocketpool.Contract, interval, fromBlock, toBlock *big.Int, opts *bind.CallOpts) ([]common.Address, error) { + // Include every address active within this range. Each upgrade identifies + // the old address; the current contract supplies the last address. Limiting + // upgrade discovery to the same range keeps subsequent scans incremental. + upgrade, err := rp.GetContract("rocketDAONodeTrustedUpgrade", opts) + if err != nil { + return nil, err + } + upgradeEvent, ok := upgrade.ABI.Events["ContractUpgraded"] + if !ok { + return nil, fmt.Errorf("ContractUpgraded event not found in rocketDAONodeTrustedUpgrade ABI") + } + upgrades, err := logs.GetLogs(rp, []common.Address{*upgrade.Address}, [][]common.Hash{ + {upgradeEvent.ID}, {crypto.Keccak256Hash([]byte("rocketNetworkParticipation"))}, + }, interval, fromBlock, toBlock, nil) + if err != nil { + return nil, err + } + addresses := []common.Address{*contract.Address} + seen := map[common.Address]bool{*contract.Address: true} + for _, entry := range upgrades { + if len(entry.Topics) != 4 { + return nil, fmt.Errorf("invalid ContractUpgraded event topics") + } + old := common.BytesToAddress(entry.Topics[2].Bytes()) + if !seen[old] { + addresses = append(addresses, old) + seen[old] = true + } + } + return addresses, nil +} diff --git a/bindings/megapool/performance-challenges_test.go b/bindings/megapool/performance-challenges_test.go new file mode 100644 index 000000000..1df7e1d0a --- /dev/null +++ b/bindings/megapool/performance-challenges_test.go @@ -0,0 +1,202 @@ +package megapool + +import ( + "context" + "fmt" + "math/big" + "reflect" + "strings" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/rocket-pool/smartnode/bindings/contracts" + "github.com/rocket-pool/smartnode/bindings/rocketpool" +) + +const performanceChallengeEventABI = `[{"type":"event","name":"MegapoolChallenged","inputs":[ + {"name":"megapoolAddress","type":"address","indexed":true}, + {"name":"_validatorIds","type":"uint32[]"}, + {"name":"_challengeId","type":"uint256","indexed":true}, + {"name":"_startEpoch","type":"uint64"}, + {"name":"_root","type":"bytes32"}, + {"name":"_participation","type":"uint256[]"}]}]` + +func TestDecodePerformanceChallenge(t *testing.T) { + parsed, err := abi.JSON(strings.NewReader(performanceChallengeEventABI)) + if err != nil { + t.Fatal(err) + } + event := parsed.Events["MegapoolChallenged"] + pool := common.HexToAddress("0x1234") + id := new(big.Int).Lsh(big.NewInt(1), 100) // Preserve Solidity uint256 IDs. + participation := []*big.Int{big.NewInt(5), new(big.Int).Lsh(big.NewInt(1), 255)} + data, err := event.Inputs.NonIndexed().Pack([]uint32{3, 9}, uint64(105000), common.Hash{}, participation) + if err != nil { + t.Fatal(err) + } + entry := types.Log{Topics: []common.Hash{event.ID, common.BytesToHash(pool.Bytes()), common.BigToHash(id)}, Data: data} + got, err := decodePerformanceChallenge(event, entry) + if err != nil { + t.Fatal(err) + } + if got.ChallengeId.Cmp(id) != 0 || got.MegapoolAddress != pool || got.StartEpoch != 105000 || !reflect.DeepEqual(got.ValidatorIds, []uint32{3, 9}) || !reflect.DeepEqual(got.Participation, participation) { + t.Fatalf("event data not preserved: %+v", got) + } + entry.Topics = entry.Topics[:2] + if _, err := decodePerformanceChallenge(event, entry); err == nil { + t.Fatal("accepted event without indexed challenge ID") + } + entry.Topics = append(entry.Topics, common.BigToHash(id)) + entry.Data = []byte{1} + if _, err := decodePerformanceChallenge(event, entry); err == nil { + t.Fatal("accepted truncated event data") + } +} + +type challengeStatusClient struct { + rocketpool.ExecutionClient + storageABI, participationABI abi.ABI + encodedABI string + challengeId *big.Int + responded, finalised bool + queries []ethereum.FilterQuery + challengeLog types.Log +} + +const challengeUpgradeEventABI = `[{"type":"event","name":"ContractUpgraded","inputs":[{"name":"name","type":"bytes32","indexed":true},{"name":"oldAddress","type":"address","indexed":true},{"name":"newAddress","type":"address","indexed":true},{"name":"time","type":"uint256"}]}]` + +func (c *challengeStatusClient) FilterLogs(_ context.Context, query ethereum.FilterQuery) ([]types.Log, error) { + c.queries = append(c.queries, query) + if len(query.Addresses) == 1 && query.Addresses[0] == common.HexToAddress("0x04") { + return []types.Log{{Topics: []common.Hash{query.Topics[0][0], query.Topics[1][0], common.BigToHash(big.NewInt(5)), common.BigToHash(big.NewInt(2))}}}, nil + } + return []types.Log{c.challengeLog}, nil +} + +func (c *challengeStatusClient) CallContract(_ context.Context, call ethereum.CallMsg, block *big.Int) ([]byte, error) { + if block == nil || block.Int64() != 777 { + return nil, fmt.Errorf("challenge status must be pinned to block 777") + } + if *call.To == common.HexToAddress("0x02") { + method, err := c.participationABI.MethodById(call.Data) + if err != nil { + return nil, err + } + args, err := method.Inputs.Unpack(call.Data[4:]) + if err != nil || args[0].(*big.Int).Cmp(c.challengeId) != 0 { + return nil, fmt.Errorf("incorrect challenge ID: %v", err) + } + // A released/settled bond does not mean the exits have been finalised. + return method.Outputs.Pack(common.HexToAddress("0x03"), common.Address{}, big.NewInt(100), big.NewInt(123456), true) + } + method, err := c.storageABI.MethodById(call.Data) + if err != nil { + return nil, err + } + args, err := method.Inputs.Unpack(call.Data[4:]) + if err != nil { + return nil, err + } + key := common.Hash(args[0].([32]byte)) + switch { + case method.Name == "getAddress" && key == crypto.Keccak256Hash([]byte("contract.addressrocketDAONodeTrustedUpgrade")): + return method.Outputs.Pack(common.HexToAddress("0x04")) + case method.Name == "getString" && key == crypto.Keccak256Hash([]byte("contract.abirocketDAONodeTrustedUpgrade")): + encoded, err := rocketpool.EncodeAbiStr(challengeUpgradeEventABI) + if err != nil { + return nil, err + } + return method.Outputs.Pack(encoded) + case method.Name == "getAddress" && key == crypto.Keccak256Hash([]byte("contract.addressrocketNetworkParticipation")): + return method.Outputs.Pack(common.HexToAddress("0x02")) + case method.Name == "getString" && key == crypto.Keccak256Hash([]byte("contract.abirocketNetworkParticipation")): + return method.Outputs.Pack(c.encodedABI) + case method.Name == "getBool" && key == crypto.Keccak256Hash([]byte("participation.challenge.responded"), common.LeftPadBytes(c.challengeId.Bytes(), 32)): + return method.Outputs.Pack(c.responded) + case method.Name == "getBool" && key == crypto.Keccak256Hash([]byte("participation.challenge.finalised"), common.LeftPadBytes(c.challengeId.Bytes(), 32)): + return method.Outputs.Pack(c.finalised) + default: + return nil, fmt.Errorf("unexpected storage call %s %s", method.Name, key) + } +} + +func TestChallengeEventsIncludePreviousContractAndBoundedUpgradeScan(t *testing.T) { + networkABI, err := abi.JSON(strings.NewReader(performanceChallengeEventABI)) + if err != nil { + t.Fatal(err) + } + storageABI, err := abi.JSON(strings.NewReader(contracts.RocketStorageABI)) + if err != nil { + t.Fatal(err) + } + encoded, err := rocketpool.EncodeAbiStr(performanceChallengeEventABI) + if err != nil { + t.Fatal(err) + } + pool := common.HexToAddress("0x1234") + event := networkABI.Events["MegapoolChallenged"] + data, err := event.Inputs.NonIndexed().Pack([]uint32{1, 2}, uint64(50), common.Hash{}, []*big.Int{big.NewInt(3)}) + if err != nil { + t.Fatal(err) + } + client := &challengeStatusClient{ + storageABI: storageABI, participationABI: networkABI, encodedABI: encoded, + challengeLog: types.Log{Address: common.HexToAddress("0x05"), Topics: []common.Hash{event.ID, common.BytesToHash(pool.Bytes()), common.BigToHash(big.NewInt(9))}, Data: data}, + } + rp, err := rocketpool.NewRocketPool(client, common.HexToAddress("0x01")) + if err != nil { + t.Fatal(err) + } + challenges, err := GetMegapoolPerformanceChallenges(rp, pool, big.NewInt(100), big.NewInt(700), big.NewInt(777), &bind.CallOpts{BlockNumber: big.NewInt(777)}) + if err != nil || len(challenges) != 1 || challenges[0].ChallengeId.Int64() != 9 { + t.Fatalf("did not discover old contract event: %v, %v", challenges, err) + } + if len(client.queries) != 2 { + t.Fatalf("expected one upgrade scan and one challenge scan, got %d", len(client.queries)) + } + for _, query := range client.queries { + if query.FromBlock.Int64() != 700 || query.ToBlock.Int64() != 777 { + t.Fatalf("scan escaped incremental bounds: %+v", query) + } + } + query := client.queries[1] + if !reflect.DeepEqual(query.Addresses, []common.Address{common.HexToAddress("0x02"), common.HexToAddress("0x05")}) || query.Topics[1][0] != common.BytesToHash(pool.Bytes()) { + t.Fatalf("incorrect contract or megapool filter: %+v", query) + } +} + +func TestGetPerformanceChallengeStatus(t *testing.T) { + const bondABI = `[{"name":"getChallengeBondDetails","type":"function","stateMutability":"view","inputs":[{"type":"uint256"}],"outputs":[{"type":"address"},{"type":"address"},{"type":"uint256"},{"type":"uint256"},{"type":"bool"}]}]` + parsed, err := abi.JSON(strings.NewReader(bondABI)) + if err != nil { + t.Fatal(err) + } + storageABI, err := abi.JSON(strings.NewReader(contracts.RocketStorageABI)) + if err != nil { + t.Fatal(err) + } + encoded, err := rocketpool.EncodeAbiStr(bondABI) + if err != nil { + t.Fatal(err) + } + client := &challengeStatusClient{storageABI: storageABI, participationABI: parsed, encodedABI: encoded, challengeId: new(big.Int).Lsh(big.NewInt(1), 100)} + rp, err := rocketpool.NewRocketPool(client, common.HexToAddress("0x01")) + if err != nil { + t.Fatal(err) + } + for _, flags := range [][2]bool{{false, false}, {true, false}, {false, true}} { + client.responded, client.finalised = flags[0], flags[1] + status, err := GetPerformanceChallengeStatus(rp, client.challengeId, &bind.CallOpts{BlockNumber: big.NewInt(777)}) + if err != nil { + t.Fatal(err) + } + if status.Proposer != common.HexToAddress("0x03") || status.ResponseDeadline.Int64() != 123456 || status.Responded != flags[0] || status.Finalised != flags[1] { + t.Fatalf("incorrect status: %+v", status) + } + } +} diff --git a/bindings/megapool/performance-lifecycle.go b/bindings/megapool/performance-lifecycle.go new file mode 100644 index 000000000..575e3fc8e --- /dev/null +++ b/bindings/megapool/performance-lifecycle.go @@ -0,0 +1,185 @@ +package megapool + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/rocket-pool/smartnode/bindings/logs" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" +) + +// MaxPerformanceChallengeValidators is the contract's per-list limit. +const MaxPerformanceChallengeValidators = 32 + +// EstimateChallengeMinipoolsGas estimates the gas to challenge a minipool list. +func EstimateChallengeMinipoolsGas(rp *rocketpool.RocketPool, minipoolAddresses []common.Address, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (gaslimit.Limits, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + return rocketNetworkParticipation.GetTransactionGasInfo(opts, "challengeMinipools", minipoolAddresses, startEpoch, participation, slotTimestamp, slotProof) +} + +// ChallengeMinipools challenges the target-vote performance of a minipool list. +func ChallengeMinipools(rp *rocketpool.RocketPool, minipoolAddresses []common.Address, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkParticipation.Transact(opts, "challengeMinipools", minipoolAddresses, startEpoch, participation, slotTimestamp, slotProof) + if err != nil { + return common.Hash{}, fmt.Errorf("error challenging minipools: %w", err) + } + return tx.Hash(), nil +} + +// PerformanceChallengeDefense represents either permitted proof for either pool +// type. A nonzero MinipoolAddress selects the minipool entry point; otherwise +// ValidatorId identifies a member of the challenged megapool. +type PerformanceChallengeDefense struct { + ChallengeId *big.Int + ValidatorId uint32 + MinipoolAddress common.Address + SlotTimestamp uint64 + Validator ValidatorProof + Slot SlotProof + Participation *ParticipationProof + Offset uint64 + ChallengeLeaf *big.Int + ChallengeWitness []common.Hash +} + +func (d PerformanceChallengeDefense) call() (string, []interface{}) { + validatorMethod := "respondWithMegapoolValidator" + participationMethod := "respondWithMegapoolParticipation" + var member interface{} = d.ValidatorId + if d.MinipoolAddress != (common.Address{}) { + validatorMethod = "respondWithMinipoolValidator" + participationMethod = "respondWithMinipoolParticipation" + member = d.MinipoolAddress + } + if d.Participation != nil { + return participationMethod, []interface{}{ + d.ChallengeId, member, d.Offset, d.ChallengeLeaf, d.ChallengeWitness, + d.SlotTimestamp, d.Validator, *d.Participation, d.Slot, + } + } + return validatorMethod, []interface{}{d.ChallengeId, member, d.SlotTimestamp, d.Validator, d.Slot} +} + +func (d PerformanceChallengeDefense) EstimateGas(rp *rocketpool.RocketPool, opts *bind.TransactOpts) (gaslimit.Limits, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + method, args := d.call() + return rocketNetworkParticipation.GetTransactionGasInfo(opts, method, args...) +} + +func (d PerformanceChallengeDefense) Submit(rp *rocketpool.RocketPool, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return common.Hash{}, err + } + method, args := d.call() + tx, err := rocketNetworkParticipation.Transact(opts, method, args...) + if err != nil { + return common.Hash{}, err + } + return tx.Hash(), nil +} + +func (s PerformanceChallengeStatus) CanFinalise(timestamp uint64) bool { + return !s.Responded && !s.Finalised && s.ResponseDeadline.Cmp(new(big.Int).SetUint64(timestamp)) < 0 +} + +func (s PerformanceChallengeStatus) CanDefend(caller common.Address, timestamp uint64) bool { + return !s.Responded && !s.Finalised && s.Proposer != caller && s.ResponseDeadline.Cmp(new(big.Int).SetUint64(timestamp)) >= 0 +} + +// GetPerformanceChallenges discovers both challenge types across upgrades. +// challengeId optionally limits the event query to one challenge. +func GetPerformanceChallenges(rp *rocketpool.RocketPool, challengeId, interval, fromBlock, toBlock *big.Int, opts *bind.CallOpts) ([]PerformanceChallenge, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, opts) + if err != nil { + return nil, err + } + megapoolEvent, ok := rocketNetworkParticipation.ABI.Events["MegapoolChallenged"] + if !ok { + return nil, fmt.Errorf("MegapoolChallenged event missing") + } + minipoolEvent, ok := rocketNetworkParticipation.ABI.Events["MinipoolsChallenged"] + if !ok { + return nil, fmt.Errorf("MinipoolsChallenged event missing") + } + fromBlock, toBlock, err = performanceChallengeScanRange(rp, fromBlock, toBlock, opts) + if err != nil || fromBlock == nil { + return nil, err + } + addresses, err := participationContractAddresses(rp, rocketNetworkParticipation, interval, fromBlock, toBlock, opts) + if err != nil { + return nil, err + } + topics := [][]common.Hash{{megapoolEvent.ID, minipoolEvent.ID}} + if challengeId != nil { + topics = append(topics, nil, []common.Hash{common.BigToHash(challengeId)}) + } + entries, err := logs.GetLogs(rp, addresses, topics, interval, fromBlock, toBlock, nil) + if err != nil { + return nil, err + } + challenges := make([]PerformanceChallenge, 0, len(entries)) + for _, entry := range entries { + if entry.Removed { + continue + } + if len(entry.Topics) != 3 { + return nil, fmt.Errorf("invalid performance challenge event topics") + } + var challenge PerformanceChallenge + switch entry.Topics[0] { + case megapoolEvent.ID: + challenge, err = decodePerformanceChallenge(megapoolEvent, entry) + case minipoolEvent.ID: + challenge, err = decodeMinipoolPerformanceChallenge(minipoolEvent, entry) + default: + return nil, fmt.Errorf("unknown performance challenge event") + } + if err != nil { + return nil, err + } + challenges = append(challenges, challenge) + } + return challenges, nil +} + +func decodeMinipoolPerformanceChallenge(event abi.Event, entry types.Log) (PerformanceChallenge, error) { + if len(entry.Topics) != 3 || entry.Topics[0] != event.ID { + return PerformanceChallenge{}, fmt.Errorf("invalid MinipoolsChallenged event topics") + } + values, err := event.Inputs.NonIndexed().Unpack(entry.Data) + if err != nil { + return PerformanceChallenge{}, fmt.Errorf("error unpacking MinipoolsChallenged: %w", err) + } + if len(values) != 4 { + return PerformanceChallenge{}, fmt.Errorf("invalid MinipoolsChallenged event data") + } + minipoolAddresses, addressesOK := values[0].([]common.Address) + startEpoch, startOK := values[1].(uint64) + participation, participationOK := values[3].([]*big.Int) + if !addressesOK || !startOK || !participationOK || len(minipoolAddresses) == 0 || len(participation) == 0 { + return PerformanceChallenge{}, fmt.Errorf("invalid MinipoolsChallenged minipool list or participation bitmap") + } + return PerformanceChallenge{ + ChallengeId: entry.Topics[2].Big(), + NodeAddress: common.BytesToAddress(entry.Topics[1].Bytes()), + MinipoolAddresses: minipoolAddresses, + StartEpoch: startEpoch, + Participation: participation, + }, nil +} diff --git a/bindings/megapool/performance-lifecycle_test.go b/bindings/megapool/performance-lifecycle_test.go new file mode 100644 index 000000000..a22c0506e --- /dev/null +++ b/bindings/megapool/performance-lifecycle_test.go @@ -0,0 +1,164 @@ +package megapool + +import ( + "math/big" + "os" + "reflect" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "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/smartnode/bindings/contracts" + "github.com/rocket-pool/smartnode/bindings/rocketpool" +) + +func performanceContractABI(t *testing.T) (abi.ABI, string) { + t.Helper() + raw, err := os.ReadFile("testdata/performance-v1.5-dev.abi.json") + if err != nil { + t.Fatal(err) + } + parsed, err := abi.JSON(strings.NewReader(string(raw))) + if err != nil { + t.Fatal(err) + } + return parsed, string(raw) +} + +func TestDefensePayloadMatchesContractABI(t *testing.T) { + contractABI, _ := performanceContractABI(t) + for _, mini := range []bool{false, true} { + for _, participation := range []bool{false, true} { + d := PerformanceChallengeDefense{ + ChallengeId: new(big.Int).Lsh(big.NewInt(1), 100), + ValidatorId: 9, + SlotTimestamp: 123456, + Validator: ValidatorProof{ValidatorIndex: big.NewInt(567)}, + Slot: SlotProof{Slot: 98765}, + Offset: 257, + ChallengeLeaf: big.NewInt(2), + ChallengeWitness: []common.Hash{{1}, {2}}, + } + pool := "Megapool" + if mini { + d.MinipoolAddress = common.HexToAddress("0x1234") + pool = "Minipool" + } + suffix := "Validator" + if participation { + d.Participation = &ParticipationProof{ValidatorIndex: big.NewInt(567)} + suffix = "Participation" + } + method, args := d.call() + expected := "respondWith" + pool + suffix + if method != expected { + t.Fatalf("got %s, want %s", method, expected) + } + payload, err := contractABI.Pack(method, args...) + if err != nil { + t.Fatalf("%s: %v", method, err) + } + decoded, err := contractABI.Methods[method].Inputs.Unpack(payload[4:]) + if err != nil { + t.Fatal(err) + } + if decoded[0].(*big.Int).Cmp(d.ChallengeId) != 0 { + t.Fatal("truncated challenge ID") + } + if mini && decoded[1].(common.Address) != d.MinipoolAddress { + t.Fatal("incorrect minipool member") + } + if !mini && decoded[1].(uint32) != d.ValidatorId { + t.Fatal("incorrect megapool member") + } + if participation && (decoded[2].(uint64) != d.Offset || decoded[3].(*big.Int).Cmp(d.ChallengeLeaf) != 0) { + t.Fatal("incorrect challenge leaf or offset") + } + } + } +} + +func TestDiscoverBothPerformanceChallengeTypes(t *testing.T) { + networkABI, raw := performanceContractABI(t) + storageABI, err := abi.JSON(strings.NewReader(contracts.RocketStorageABI)) + if err != nil { + t.Fatal(err) + } + encoded, err := rocketpool.EncodeAbiStr(raw) + if err != nil { + t.Fatal(err) + } + for _, mini := range []bool{false, true} { + name := "MegapoolChallenged" + var members interface{} = []uint32{0, 31} + if mini { + name, members = "MinipoolsChallenged", []common.Address{common.HexToAddress("0x1234"), common.HexToAddress("0x5678")} + } + event := networkABI.Events[name] + data, err := event.Inputs.NonIndexed().Pack(members, uint64(50), common.Hash{}, []*big.Int{big.NewInt(3)}) + if err != nil { + t.Fatal(err) + } + id, owner := new(big.Int).Lsh(big.NewInt(1), 100), common.HexToAddress("0xabcd") + client := &challengeStatusClient{ + storageABI: storageABI, + participationABI: networkABI, + encodedABI: encoded, + challengeLog: types.Log{ + Address: common.HexToAddress("0x05"), + Topics: []common.Hash{event.ID, common.BytesToHash(owner.Bytes()), common.BigToHash(id)}, + Data: data, + }, + } + rp, err := rocketpool.NewRocketPool(client, common.HexToAddress("0x01")) + if err != nil { + t.Fatal(err) + } + got, err := GetPerformanceChallenges(rp, id, big.NewInt(100), big.NewInt(700), big.NewInt(777), &bind.CallOpts{BlockNumber: big.NewInt(777)}) + if err != nil || len(got) != 1 { + t.Fatalf("%s: %v, %v", name, got, err) + } + if got[0].ChallengeId.Cmp(id) != 0 || got[0].StartEpoch != 50 || got[0].Participation[0].Int64() != 3 { + t.Fatal("incorrect event payload") + } + if mini && (got[0].NodeAddress != owner || !reflect.DeepEqual(got[0].MinipoolAddresses, members)) { + t.Fatal("incorrect minipool members") + } + if !mini && (got[0].MegapoolAddress != owner || !reflect.DeepEqual(got[0].ValidatorIds, members)) { + t.Fatal("incorrect megapool members") + } + query := client.queries[1] + if len(query.Topics[0]) != 2 || len(query.Topics[1]) != 0 || query.Topics[2][0] != common.BigToHash(id) { + t.Fatalf("incorrect ID/topic filter: %v", query.Topics) + } + if !reflect.DeepEqual(query.Addresses, []common.Address{common.HexToAddress("0x02"), common.HexToAddress("0x05")}) { + t.Fatal("lost previous contract address") + } + } +} + +func TestPerformanceChallengeDeadlineAndSettlement(t *testing.T) { + proposer, defender := common.HexToAddress("0x01"), common.HexToAddress("0x02") + s := PerformanceChallengeStatus{Proposer: proposer, ResponseDeadline: big.NewInt(100)} + if !s.CanDefend(defender, 100) || s.CanDefend(proposer, 100) || s.CanFinalise(100) { + t.Fatal("incorrect exact deadline or proposer rule") + } + if s.CanDefend(defender, 101) || !s.CanFinalise(101) { + t.Fatal("incorrect expired rules") + } + s.BondSettled = true + if !s.CanFinalise(101) { + t.Fatal("releasing bond must not block finalisation") + } + s.BondSettled, s.Finalised = false, true + if !s.CanReleaseBond(101) || s.CanFinalise(101) || s.CanDefend(defender, 99) { + t.Fatal("finalisation must not block bond release or allow repeat actions") + } + s.Finalised, s.Responded, s.Responder = false, true, defender + if s.CanFinalise(101) || s.CanReleaseBond(101) || s.CanDefend(defender, 99) || !s.CanClaimReward(defender) { + t.Fatal("incorrect defeated challenge rules") + } +} diff --git a/bindings/megapool/performance-scan.go b/bindings/megapool/performance-scan.go new file mode 100644 index 000000000..21bb04a5e --- /dev/null +++ b/bindings/megapool/performance-scan.go @@ -0,0 +1,78 @@ +package megapool + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/rocket-pool/smartnode/bindings/rocketpool" +) + +// Bound initial scans by the first challenge, whose timestamp survives both +// settlement and contract upgrades. Header lookup does not need archive state. +// A nil returned start means there are no challenges in this snapshot. +func performanceChallengeScanRange(rp *rocketpool.RocketPool, from, to *big.Int, opts *bind.CallOpts) (*big.Int, *big.Int, error) { + if from != nil { + return from, to, nil + } + callOpts := bind.CallOpts{} + if opts != nil { + callOpts = *opts + } + ctx := callOpts.Context + if ctx == nil { + ctx = context.Background() + } + if to == nil { + to = callOpts.BlockNumber + if to == nil { + block, err := rp.Client.BlockNumber(ctx) + if err != nil { + return nil, nil, err + } + to = new(big.Int).SetUint64(block) + } + } + if !to.IsUint64() { + return nil, nil, fmt.Errorf("invalid performance challenge scan end block: %s", to) + } + callOpts.BlockNumber, callOpts.BlockHash, callOpts.Pending = to, common.Hash{}, false + key := crypto.Keccak256Hash([]byte("participation.challenge.time"), common.LeftPadBytes(big.NewInt(1).Bytes(), 32)) + firstTime, err := rp.RocketStorage.GetUint(&callOpts, key) + if err != nil { + return nil, nil, fmt.Errorf("error getting first performance challenge timestamp: %w", err) + } + if firstTime.Sign() == 0 { + return nil, to, nil + } + if !firstTime.IsUint64() { + return nil, nil, fmt.Errorf("invalid first performance challenge timestamp: %s", firstTime) + } + lo, hi := uint64(0), to.Uint64() + head, err := rp.Client.HeaderByNumber(ctx, to) + if err != nil { + return nil, nil, err + } + if head == nil || head.Time < firstTime.Uint64() { + return nil, nil, fmt.Errorf("first performance challenge timestamp is after scan end block") + } + for lo < hi { + mid := lo + (hi-lo)/2 + header, err := rp.Client.HeaderByNumber(ctx, new(big.Int).SetUint64(mid)) + if err != nil { + return nil, nil, err + } + if header == nil { + return nil, nil, fmt.Errorf("missing execution header at block %d", mid) + } + if header.Time < firstTime.Uint64() { + lo = mid + 1 + } else { + hi = mid + } + } + return new(big.Int).SetUint64(lo), to, nil +} diff --git a/bindings/megapool/performance.go b/bindings/megapool/performance.go index 209abf7cb..818f1666e 100644 --- a/bindings/megapool/performance.go +++ b/bindings/megapool/performance.go @@ -12,21 +12,21 @@ import ( ) // Estimate the gas to call ChallengeMegapool -func EstimateChallengeMegapoolGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (gaslimit.Limits, error) { +func EstimateChallengeMegapoolGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { return gaslimit.Limits{}, err } - return rocketNetworkParticipation.GetTransactionGasInfo(opts, "challengeMegapool", megapoolAddress, validatorId, startEpoch, participation, slotTimestamp, slotProof) + return rocketNetworkParticipation.GetTransactionGasInfo(opts, "challengeMegapool", megapoolAddress, validatorIds, startEpoch, participation, slotTimestamp, slotProof) } // Challenge the megapool -func ChallengeMegapool(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { +func ChallengeMegapool(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { return common.Hash{}, err } - tx, err := rocketNetworkParticipation.Transact(opts, "challengeMegapool", megapoolAddress, validatorId, startEpoch, participation, slotTimestamp, slotProof) + tx, err := rocketNetworkParticipation.Transact(opts, "challengeMegapool", megapoolAddress, validatorIds, startEpoch, participation, slotTimestamp, slotProof) if err != nil { return common.Hash{}, fmt.Errorf("error challenging megapool: %w", err) } @@ -34,22 +34,22 @@ func ChallengeMegapool(rp *rocketpool.RocketPool, megapoolAddress common.Address } // Estimate the gas to call RespondWithParticipation -func EstimateRespondWithParticipationGas(rp *rocketpool.RocketPool, challengeId uint64, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, validatorProof ValidatorProof, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (gaslimit.Limits, error) { +func EstimateRespondWithParticipationGas(rp *rocketpool.RocketPool, challengeId *big.Int, validatorId uint32, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, validatorProof ValidatorProof, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { return gaslimit.Limits{}, err } - return rocketNetworkParticipation.GetTransactionGasInfo(opts, "respondWithParticipation", challengeId, offset, challengeLeaf, challengeWitness, slotTimestamp, validatorProof, participationProof, slotProof) + return rocketNetworkParticipation.GetTransactionGasInfo(opts, "respondWithMegapoolParticipation", challengeId, validatorId, offset, challengeLeaf, challengeWitness, slotTimestamp, validatorProof, participationProof, slotProof) } // Respond to a performance challenge with a proof that the validator did // participate in one of the challenged epochs -func RespondWithParticipation(rp *rocketpool.RocketPool, challengeId uint64, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, validatorProof ValidatorProof, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { +func RespondWithParticipation(rp *rocketpool.RocketPool, challengeId *big.Int, validatorId uint32, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, validatorProof ValidatorProof, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { return common.Hash{}, err } - tx, err := rocketNetworkParticipation.Transact(opts, "respondWithParticipation", challengeId, offset, challengeLeaf, challengeWitness, slotTimestamp, validatorProof, participationProof, slotProof) + tx, err := rocketNetworkParticipation.Transact(opts, "respondWithMegapoolParticipation", challengeId, validatorId, offset, challengeLeaf, challengeWitness, slotTimestamp, validatorProof, participationProof, slotProof) if err != nil { return common.Hash{}, fmt.Errorf("error responding to challenge: %w", err) } @@ -57,22 +57,22 @@ func RespondWithParticipation(rp *rocketpool.RocketPool, challengeId uint64, off } // Estimate the gas to call RespondWithValidator -func EstimateRespondWithValidatorGas(rp *rocketpool.RocketPool, challengeId uint64, slotTimestamp uint64, validatorProof ValidatorProof, slotProof SlotProof, opts *bind.TransactOpts) (gaslimit.Limits, error) { +func EstimateRespondWithValidatorGas(rp *rocketpool.RocketPool, challengeId *big.Int, validatorId uint32, slotTimestamp uint64, validatorProof ValidatorProof, slotProof SlotProof, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { return gaslimit.Limits{}, err } - return rocketNetworkParticipation.GetTransactionGasInfo(opts, "respondWithValidator", challengeId, slotTimestamp, validatorProof, slotProof) + return rocketNetworkParticipation.GetTransactionGasInfo(opts, "respondWithMegapoolValidator", challengeId, validatorId, slotTimestamp, validatorProof, slotProof) } -// Respond to a performance challenge with a proof that the validator was not -// staking for the entire challenge period -func RespondWithValidator(rp *rocketpool.RocketPool, challengeId uint64, slotTimestamp uint64, validatorProof ValidatorProof, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { +// Respond to a performance challenge with proof that a listed validator +// activated after the challenge's start epoch. +func RespondWithValidator(rp *rocketpool.RocketPool, challengeId *big.Int, validatorId uint32, slotTimestamp uint64, validatorProof ValidatorProof, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { return common.Hash{}, err } - tx, err := rocketNetworkParticipation.Transact(opts, "respondWithValidator", challengeId, slotTimestamp, validatorProof, slotProof) + tx, err := rocketNetworkParticipation.Transact(opts, "respondWithMegapoolValidator", challengeId, validatorId, slotTimestamp, validatorProof, slotProof) if err != nil { return common.Hash{}, fmt.Errorf("error responding to challenge: %w", err) } @@ -80,7 +80,7 @@ func RespondWithValidator(rp *rocketpool.RocketPool, challengeId uint64, slotTim } // Estimate the gas to call FinaliseChallenge -func EstimateFinaliseChallengeGas(rp *rocketpool.RocketPool, challengeId uint64, opts *bind.TransactOpts) (gaslimit.Limits, error) { +func EstimateFinaliseChallengeGas(rp *rocketpool.RocketPool, challengeId *big.Int, opts *bind.TransactOpts) (gaslimit.Limits, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { return gaslimit.Limits{}, err @@ -88,7 +88,7 @@ func EstimateFinaliseChallengeGas(rp *rocketpool.RocketPool, challengeId uint64, return rocketNetworkParticipation.GetTransactionGasInfo(opts, "finaliseChallenge", challengeId) } -func FinaliseChallenge(rp *rocketpool.RocketPool, challengeId uint64, opts *bind.TransactOpts) (common.Hash, error) { +func FinaliseChallenge(rp *rocketpool.RocketPool, challengeId *big.Int, opts *bind.TransactOpts) (common.Hash, error) { rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) if err != nil { return common.Hash{}, err @@ -100,6 +100,48 @@ func FinaliseChallenge(rp *rocketpool.RocketPool, challengeId uint64, opts *bind return tx.Hash(), nil } +// ReleaseChallengeBond unlocks the proposer's recorded bond independently of exit finalisation. +func ReleaseChallengeBond(rp *rocketpool.RocketPool, challengeId *big.Int, opts *bind.TransactOpts) (common.Hash, error) { + contract, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := contract.Transact(opts, "releaseChallengeBond", challengeId) + if err != nil { + return common.Hash{}, fmt.Errorf("error releasing challenge %s bond: %w", challengeId, err) + } + return tx.Hash(), nil +} + +func EstimateReleaseChallengeBondGas(rp *rocketpool.RocketPool, challengeId *big.Int, opts *bind.TransactOpts) (gaslimit.Limits, error) { + contract, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + return contract.GetTransactionGasInfo(opts, "releaseChallengeBond", challengeId) +} + +// ClaimChallengeReward credits the recorded defender with recoverable staked RPL after the burn. +func ClaimChallengeReward(rp *rocketpool.RocketPool, challengeId *big.Int, opts *bind.TransactOpts) (common.Hash, error) { + contract, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := contract.Transact(opts, "claimChallengeReward", challengeId) + if err != nil { + return common.Hash{}, fmt.Errorf("error claiming challenge %s reward: %w", challengeId, err) + } + return tx.Hash(), nil +} + +func EstimateClaimChallengeRewardGas(rp *rocketpool.RocketPool, challengeId *big.Int, opts *bind.TransactOpts) (gaslimit.Limits, error) { + contract, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + return contract.GetTransactionGasInfo(opts, "claimChallengeReward", challengeId) +} + // Get contracts var rocketNetworkParticipationLock sync.Mutex diff --git a/bindings/megapool/proof-bundle.go b/bindings/megapool/proof-bundle.go index 5830997c5..3fa2f2166 100644 --- a/bindings/megapool/proof-bundle.go +++ b/bindings/megapool/proof-bundle.go @@ -5,6 +5,9 @@ import ( "math/big" "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + + "github.com/rocket-pool/smartnode/bindings/rocketpool" ) // Proof versions accepted by BeaconStateVerifier on v1.4.1-dev. @@ -33,6 +36,27 @@ type FinalBalanceProofBundleV2 struct { ValidatorBalanceProof ValidatorBalanceProof `json:"validatorBalanceProof"` } +// UsesProofBundles selects the transaction format from the deployed manager version. +// The consensus fork determines the proof witnesses independently. +func UsesProofBundles(rp *rocketpool.RocketPool, opts *bind.CallOpts) (bool, error) { + manager, err := getRocketMegapoolManager(rp, opts) + if err != nil { + return false, err + } + version, err := rocketpool.GetContractVersion(rp, *manager.Address, opts) + if err != nil { + return false, err + } + switch version { + case 1: + return false, nil + case 2: + return true, nil + default: + return false, fmt.Errorf("unsupported rocketMegapoolManager version: %d", version) + } +} + var ( validatorComponents = []abi.ArgumentMarshaling{ {Name: "pubkey", Type: "bytes"}, diff --git a/bindings/megapool/testdata/performance-v1.5-dev.abi.json b/bindings/megapool/testdata/performance-v1.5-dev.abi.json new file mode 100644 index 000000000..7da8eeb95 --- /dev/null +++ b/bindings/megapool/testdata/performance-v1.5-dev.abi.json @@ -0,0 +1,722 @@ +[ + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "megapoolAddress", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint32[]", + "name": "_validatorIds", + "type": "uint32[]" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "_challengeId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "_startEpoch", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "_root", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint256[]", + "name": "_participation", + "type": "uint256[]" + } + ], + "name": "MegapoolChallenged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "nodeAddress", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "challengeId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "address[]", + "name": "minipoolAddresses", + "type": "address[]" + }, + { + "indexed": false, + "internalType": "uint64", + "name": "startEpoch", + "type": "uint64" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "root", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint256[]", + "name": "participation", + "type": "uint256[]" + } + ], + "name": "MinipoolsChallenged", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_megapoolAddress", + "type": "address" + }, + { + "internalType": "uint32[]", + "name": "_validatorIds", + "type": "uint32[]" + }, + { + "internalType": "uint64", + "name": "_startEpoch", + "type": "uint64" + }, + { + "internalType": "uint256[]", + "name": "_participation", + "type": "uint256[]" + }, + { + "internalType": "uint64", + "name": "_slotTimestamp", + "type": "uint64" + }, + { + "components": [ + { + "internalType": "uint64", + "name": "slot", + "type": "uint64" + }, + { + "internalType": "bytes32[]", + "name": "witnesses", + "type": "bytes32[]" + } + ], + "internalType": "struct SlotProof", + "name": "_slotProof", + "type": "tuple" + } + ], + "name": "challengeMegapool", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address[]", + "name": "_minipoolAddresses", + "type": "address[]" + }, + { + "internalType": "uint64", + "name": "_startEpoch", + "type": "uint64" + }, + { + "internalType": "uint256[]", + "name": "_participation", + "type": "uint256[]" + }, + { + "internalType": "uint64", + "name": "_slotTimestamp", + "type": "uint64" + }, + { + "components": [ + { + "internalType": "uint64", + "name": "slot", + "type": "uint64" + }, + { + "internalType": "bytes32[]", + "name": "witnesses", + "type": "bytes32[]" + } + ], + "internalType": "struct SlotProof", + "name": "_slotProof", + "type": "tuple" + } + ], + "name": "challengeMinipools", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_challengeId", + "type": "uint256" + } + ], + "name": "claimChallengeReward", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_challengeId", + "type": "uint256" + } + ], + "name": "finaliseChallenge", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_challengeId", + "type": "uint256" + } + ], + "name": "releaseChallengeBond", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_challengeId", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "_validatorId", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "_offset", + "type": "uint64" + }, + { + "internalType": "uint256", + "name": "_challengeLeaf", + "type": "uint256" + }, + { + "internalType": "bytes32[]", + "name": "_challengeWitness", + "type": "bytes32[]" + }, + { + "internalType": "uint64", + "name": "_slotTimestamp", + "type": "uint64" + }, + { + "components": [ + { + "internalType": "uint40", + "name": "validatorIndex", + "type": "uint40" + }, + { + "components": [ + { + "internalType": "bytes", + "name": "pubkey", + "type": "bytes" + }, + { + "internalType": "bytes32", + "name": "withdrawalCredentials", + "type": "bytes32" + }, + { + "internalType": "uint64", + "name": "effectiveBalance", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "slashed", + "type": "bool" + }, + { + "internalType": "uint64", + "name": "activationEligibilityEpoch", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "activationEpoch", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "exitEpoch", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "withdrawableEpoch", + "type": "uint64" + } + ], + "internalType": "struct Validator", + "name": "validator", + "type": "tuple" + }, + { + "internalType": "bytes32[]", + "name": "witnesses", + "type": "bytes32[]" + } + ], + "internalType": "struct ValidatorProof", + "name": "_validatorProof", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint64", + "name": "participationSlot", + "type": "uint64" + }, + { + "internalType": "uint40", + "name": "validatorIndex", + "type": "uint40" + }, + { + "internalType": "bytes32", + "name": "participationFlagsChunk", + "type": "bytes32" + }, + { + "internalType": "bytes32[]", + "name": "witnesses", + "type": "bytes32[]" + } + ], + "internalType": "struct ParticipationProof", + "name": "_participationProof", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint64", + "name": "slot", + "type": "uint64" + }, + { + "internalType": "bytes32[]", + "name": "witnesses", + "type": "bytes32[]" + } + ], + "internalType": "struct SlotProof", + "name": "_slotProof", + "type": "tuple" + } + ], + "name": "respondWithMegapoolParticipation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_challengeId", + "type": "uint256" + }, + { + "internalType": "uint32", + "name": "_validatorId", + "type": "uint32" + }, + { + "internalType": "uint64", + "name": "_slotTimestamp", + "type": "uint64" + }, + { + "components": [ + { + "internalType": "uint40", + "name": "validatorIndex", + "type": "uint40" + }, + { + "components": [ + { + "internalType": "bytes", + "name": "pubkey", + "type": "bytes" + }, + { + "internalType": "bytes32", + "name": "withdrawalCredentials", + "type": "bytes32" + }, + { + "internalType": "uint64", + "name": "effectiveBalance", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "slashed", + "type": "bool" + }, + { + "internalType": "uint64", + "name": "activationEligibilityEpoch", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "activationEpoch", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "exitEpoch", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "withdrawableEpoch", + "type": "uint64" + } + ], + "internalType": "struct Validator", + "name": "validator", + "type": "tuple" + }, + { + "internalType": "bytes32[]", + "name": "witnesses", + "type": "bytes32[]" + } + ], + "internalType": "struct ValidatorProof", + "name": "_validatorProof", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint64", + "name": "slot", + "type": "uint64" + }, + { + "internalType": "bytes32[]", + "name": "witnesses", + "type": "bytes32[]" + } + ], + "internalType": "struct SlotProof", + "name": "_slotProof", + "type": "tuple" + } + ], + "name": "respondWithMegapoolValidator", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_challengeId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "_minipoolAddress", + "type": "address" + }, + { + "internalType": "uint64", + "name": "_offset", + "type": "uint64" + }, + { + "internalType": "uint256", + "name": "_challengeLeaf", + "type": "uint256" + }, + { + "internalType": "bytes32[]", + "name": "_challengeWitness", + "type": "bytes32[]" + }, + { + "internalType": "uint64", + "name": "_slotTimestamp", + "type": "uint64" + }, + { + "components": [ + { + "internalType": "uint40", + "name": "validatorIndex", + "type": "uint40" + }, + { + "components": [ + { + "internalType": "bytes", + "name": "pubkey", + "type": "bytes" + }, + { + "internalType": "bytes32", + "name": "withdrawalCredentials", + "type": "bytes32" + }, + { + "internalType": "uint64", + "name": "effectiveBalance", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "slashed", + "type": "bool" + }, + { + "internalType": "uint64", + "name": "activationEligibilityEpoch", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "activationEpoch", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "exitEpoch", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "withdrawableEpoch", + "type": "uint64" + } + ], + "internalType": "struct Validator", + "name": "validator", + "type": "tuple" + }, + { + "internalType": "bytes32[]", + "name": "witnesses", + "type": "bytes32[]" + } + ], + "internalType": "struct ValidatorProof", + "name": "_validatorProof", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint64", + "name": "participationSlot", + "type": "uint64" + }, + { + "internalType": "uint40", + "name": "validatorIndex", + "type": "uint40" + }, + { + "internalType": "bytes32", + "name": "participationFlagsChunk", + "type": "bytes32" + }, + { + "internalType": "bytes32[]", + "name": "witnesses", + "type": "bytes32[]" + } + ], + "internalType": "struct ParticipationProof", + "name": "_participationProof", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint64", + "name": "slot", + "type": "uint64" + }, + { + "internalType": "bytes32[]", + "name": "witnesses", + "type": "bytes32[]" + } + ], + "internalType": "struct SlotProof", + "name": "_slotProof", + "type": "tuple" + } + ], + "name": "respondWithMinipoolParticipation", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_challengeId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "_minipoolAddress", + "type": "address" + }, + { + "internalType": "uint64", + "name": "_slotTimestamp", + "type": "uint64" + }, + { + "components": [ + { + "internalType": "uint40", + "name": "validatorIndex", + "type": "uint40" + }, + { + "components": [ + { + "internalType": "bytes", + "name": "pubkey", + "type": "bytes" + }, + { + "internalType": "bytes32", + "name": "withdrawalCredentials", + "type": "bytes32" + }, + { + "internalType": "uint64", + "name": "effectiveBalance", + "type": "uint64" + }, + { + "internalType": "bool", + "name": "slashed", + "type": "bool" + }, + { + "internalType": "uint64", + "name": "activationEligibilityEpoch", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "activationEpoch", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "exitEpoch", + "type": "uint64" + }, + { + "internalType": "uint64", + "name": "withdrawableEpoch", + "type": "uint64" + } + ], + "internalType": "struct Validator", + "name": "validator", + "type": "tuple" + }, + { + "internalType": "bytes32[]", + "name": "witnesses", + "type": "bytes32[]" + } + ], + "internalType": "struct ValidatorProof", + "name": "_validatorProof", + "type": "tuple" + }, + { + "components": [ + { + "internalType": "uint64", + "name": "slot", + "type": "uint64" + }, + { + "internalType": "bytes32[]", + "name": "witnesses", + "type": "bytes32[]" + } + ], + "internalType": "struct SlotProof", + "name": "_slotProof", + "type": "tuple" + } + ], + "name": "respondWithMinipoolValidator", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/bindings/minipool/minipool-contract-v4.go b/bindings/minipool/minipool-contract-v4.go index f2c0c6ddb..f6ee4b92f 100644 --- a/bindings/minipool/minipool-contract-v4.go +++ b/bindings/minipool/minipool-contract-v4.go @@ -22,7 +22,7 @@ import ( ) const ( - minipoolV4EncodedAbi string = "" + minipoolV4EncodedAbi string = "eJztWUtv4jAQ/iurnHvqaler3mDbnrZdBLR7qKrKiQewauzIDyiq+t93EkKAkjcE3N0e49jjbz7PjGfGD6+eWYTgXXgjwrlPgmfvzNOGGLixhviMM7PAnyFZEJ+D93aWzlcQAJtB1ekjKwLDpMD5gkyjEQocxriyL9ONmQit0d7Fw+OZJ61Zf+xuIaSotctdOFaEQjubjMFcJvvkbvC6mowzEkGEUgVax0sMKEH4cPvHWyaqGYN5KZ6r0QhwdAauAespmDFptWu47jT8woW60Un6UvJdVPFoU0hMMMMIZ3oLSLr3k5LBM5iBkYqMoQ5FZ2sZQlLopAvqkHwgx9ElvK+h4kzDxLgy5Y0AwgyEWaO73IhQQLvvYlSKTXK6gT2DRQovQL0LjLAaqpyLgPlhBRo23ZBkccH5t+/lklYTIz4JMraYot8mk8tZSyIu/eSsnLMrMwHVX96o2YSNlJwWKmaUraQXQUDx1o01OxZHu+HChzETGDDUJdNGMd8WROm94lJAeGA5rr7FCDmYEJUTlXzCiQhyOMjVufAqqSShyYWS6hQR+M/oJHroGPLktzUCGRjyfHIYXOqWXIJCKDUz9YRXk8y0lnzWFu40VHRTw85Kp2BOFNW/BV+0m2PsIhwxsZPnHVC6VAFcvbRycnHdE5tFTE0N248c/8cutSDs9MsNpr4hcpyI3iepv06opSf2S4Ryu5VrO1HyRJASkjtas7FwhacEVIbDnuymSZBdg1Nw+oC/qIM8DWV4F94Tbp1B1VNYZ+KPiWuobhjWSdEPx45xECjr32NqlV2QPE1h6oNq3Dc4WkDBvMzYWjG3yuWUSN0bV5fLgtbnsY88hjRcVnVOIBpKQ3hqibVPsS1YcQ3q2s2ZAcqlo9yA51ik2+4oOHCS9wTryfyE3Y0G9Mmbx6FaJYo5jQwp6Oqyr2xFG93xGSpPiZGqZ/1n2CwLF6tQtM12PJwpYYDeiKFVQQMhSeF9SQzpS2neSfh6niMDf1TguirRIVEQW2WwyCe7U9BaLGU7sEqB2AgNzbpPe1pUcS9pL+EKqA2gu8lTK7tEFUE7sjUnetKS6PfNs4/kRFWVtOsbsIVmjN26xTqcy/nJ77IZKL0c2Df5Xg43xoFeHWeRh7Ded283kUf3Y9/OLpTC5MG7WxIh6z6+CJgfWuTx37wSd/h89Coi6Q8zE6rInPCekgGqlkMXbh8kCez+lMXp3QFpi8LTQW01qgwLs4WPc7Ai8ziN/L9tf9VquV69YOSY/UlA9ZDmKGfJhlShdCjEtpPAVEuC6smslBOVi4xypHasbZ7GvZ+YPOMxYDmq99f7RPYSlxYOWXCcD/kuIUoKzN6y3MwGVlZn1rWxKmWn01GypE1f1KWveZEcV624FX0XYtDL0Uyv+vo7hUORXkkd0a5Wj38Bg5t8yA==" ) type MinipoolV4 interface { @@ -46,7 +46,7 @@ type minipool_v4 struct { RocketPool *rocketpool.RocketPool } -// The decoded ABI for v2 minipools +// The decoded ABI for v4 minipools var minipoolV4Abi *abi.ABI // Create new minipool contract diff --git a/bindings/network/exit.go b/bindings/network/exit.go index 954e754b6..8ec3ee21f 100644 --- a/bindings/network/exit.go +++ b/bindings/network/exit.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "math/big" + "slices" "sync" "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" "github.com/rocket-pool/smartnode/bindings/logs" "github.com/rocket-pool/smartnode/bindings/megapool" @@ -190,8 +192,56 @@ func ExitMegapoolValidators(rp *rocketpool.RocketPool, megapoolAddress common.Ad return tx.Hash(), nil } +// Estimate the gas of RetryMegapoolExit +func EstimateRetryMegapoolExitGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (gaslimit.Limits, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "retryMegapoolExit", megapoolAddress, validatorId) +} + +// Resubmit an EL exit for a Megapool validator already marked as exiting +func RetryMegapoolExit(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "retryMegapoolExit", megapoolAddress, validatorId) + if err != nil { + return common.Hash{}, fmt.Errorf("error retrying megapool exit for %s validator %d: %w", megapoolAddress.Hex(), validatorId, err) + } + return tx.Hash(), nil +} + +// Get the latest forced exit or retry submission in the given block range +func GetLatestMegapoolExitSubmission(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, intervalSize, fromBlock, toBlock *big.Int) (time.Time, error) { + topicFilter := [][]common.Hash{{ + crypto.Keccak256Hash([]byte("MegapoolValidatorForceExited(uint256,uint256)")), + crypto.Keccak256Hash([]byte("MegapoolValidatorExitRetried(uint256,uint256)")), + }, {common.BigToHash(new(big.Int).SetUint64(uint64(validatorId)))}} + entries, err := logs.GetLogs(rp, []common.Address{megapoolAddress}, topicFilter, intervalSize, fromBlock, toBlock, nil) + if err != nil { + return time.Time{}, fmt.Errorf("error getting megapool exit submissions: %w", err) + } + if len(entries) == 0 { + return time.Time{}, nil + } + block := entries[0].BlockNumber + for _, entry := range entries[1:] { + if entry.BlockNumber > block { + block = entry.BlockNumber + } + } + header, err := rp.Client.HeaderByNumber(context.Background(), new(big.Int).SetUint64(block)) + if err != nil { + return time.Time{}, fmt.Errorf("error getting megapool exit submission block %d: %w", block, err) + } + return time.Unix(int64(header.Time), 0), nil +} + // Get MinipoolExitRequested events emitted during the given block range -func GetMinipoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, fromBlock *big.Int, toBlock *big.Int, opts *bind.CallOpts) ([]MinipoolExitRequest, error) { +func GetMinipoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, fromBlock *big.Int, toBlock *big.Int, rocketNetworkExitAddresses []common.Address, opts *bind.CallOpts) ([]MinipoolExitRequest, error) { rocketNetworkExit, err := getRocketNetworkExit(rp, opts) if err != nil { return nil, err @@ -202,7 +252,7 @@ func GetMinipoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, f return nil, fmt.Errorf("MinipoolExitRequested event not found in rocketNetworkExit ABI") } - addressFilter := []common.Address{*rocketNetworkExit.Address} + addressFilter := getRocketNetworkExitAddresses(rocketNetworkExit, rocketNetworkExitAddresses) topicFilter := [][]common.Hash{{minipoolExitRequestedEvent.ID}} logs, err := logs.GetLogs(rp, addressFilter, topicFilter, intervalSize, fromBlock, toBlock, nil) @@ -254,7 +304,7 @@ func GetMinipoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, f } // Get MegapoolExitRequested events emitted during the given block range -func GetMegapoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, fromBlock *big.Int, toBlock *big.Int, opts *bind.CallOpts) ([]MegapoolExitRequest, error) { +func GetMegapoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, fromBlock *big.Int, toBlock *big.Int, rocketNetworkExitAddresses []common.Address, opts *bind.CallOpts) ([]MegapoolExitRequest, error) { rocketNetworkExit, err := getRocketNetworkExit(rp, opts) if err != nil { return nil, err @@ -265,7 +315,7 @@ func GetMegapoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, f return nil, fmt.Errorf("MegapoolExitRequested event not found in rocketNetworkExit ABI") } - addressFilter := []common.Address{*rocketNetworkExit.Address} + addressFilter := getRocketNetworkExitAddresses(rocketNetworkExit, rocketNetworkExitAddresses) topicFilter := [][]common.Hash{{megapoolExitRequestedEvent.ID}} logs, err := logs.GetLogs(rp, addressFilter, topicFilter, intervalSize, fromBlock, toBlock, nil) @@ -317,6 +367,19 @@ func GetMegapoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, f return requests, nil } +func getRocketNetworkExitAddresses(contract *rocketpool.Contract, previousAddresses []common.Address) []common.Address { + addresses := make([]common.Address, 0, len(previousAddresses)+1) + for _, address := range previousAddresses { + if !slices.Contains(addresses, address) { + addresses = append(addresses, address) + } + } + if !slices.Contains(addresses, *contract.Address) { + addresses = append(addresses, *contract.Address) + } + return addresses +} + // Get contracts var rocketNetworkExitLock sync.Mutex diff --git a/bindings/utils/version-checker.go b/bindings/utils/version-checker.go index cee4f81f7..a56b47d7b 100644 --- a/bindings/utils/version-checker.go +++ b/bindings/utils/version-checker.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/crypto" "github.com/hashicorp/go-version" "github.com/rocket-pool/smartnode/bindings/deposit" @@ -14,6 +15,14 @@ import ( ) func GetCurrentVersion(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*version.Version, error) { + protocolVersion, err := rp.RocketStorage.GetString(opts, crypto.Keccak256Hash([]byte("protocol.version"))) + if err != nil { + return nil, fmt.Errorf("error checking protocol version: %w", err) + } + if protocolVersion != "" { + return version.NewVersion(protocolVersion) + } + depositPoolVersion, err := deposit.GetRocketDepositPoolVersion(rp, opts) if err != nil { return nil, fmt.Errorf("error checking deposit pool version: %w", err) diff --git a/rocketpool-cli/cli/verify-performance/submit.go b/rocketpool-cli/cli/verify-performance/submit.go new file mode 100644 index 000000000..038304e0b --- /dev/null +++ b/rocketpool-cli/cli/verify-performance/submit.go @@ -0,0 +1,59 @@ +package verifyperformance + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" + cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" + "github.com/rocket-pool/smartnode/rocketpool-cli/cli/prompt" + "github.com/rocket-pool/smartnode/shared/services/gas" + "github.com/rocket-pool/smartnode/shared/services/rocketpool" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// SubmitChallenges submits one bounded list per shared bitmap and node owner. +func SubmitChallenges(rp *rocketpool.Client, address common.Address, results api.VerifyPerformanceBatchResponse, minipools, yes bool) error { + for _, group := range GroupChallengeable(results.Results) { + var can api.CanChallengeMegapoolPerformanceResponse + var err error + var targets interface{} = group.ValidatorIds + if minipools { + targets = group.MinipoolAddresses + can, err = rp.CanChallengeMinipoolPerformance(group.MinipoolAddresses, group.StartEpoch, group.Participation) + } else { + can, err = rp.CanChallengeMegapoolPerformance(address, group.ValidatorIds, group.StartEpoch, group.Participation) + } + if err != nil { + return err + } + if !can.CanChallenge { + fmt.Printf("Skipping %v: %s\n", targets, can.Reason) + continue + } + bond := new(big.Rat).SetFrac(can.ChallengeBond, big.NewInt(1e18)).FloatString(6) + fmt.Printf("\nChallenge targets: %v\nOne bond of %s staked RPL covers this entire list.\n", targets, bond) + if err := gas.AssignMaxFeeAndLimit(can.GasLimits, rp, yes); err != nil { + return err + } + if prompt.Declined(yes, "Submit this performance challenge and lock %s staked RPL?", bond) { + fmt.Println("Skipped.") + continue + } + var result api.ChallengeMegapoolPerformanceResponse + if minipools { + result, err = rp.ChallengeMinipoolPerformance(group.MinipoolAddresses, group.StartEpoch, group.Participation, can.ChallengeBond) + } else { + result, err = rp.ChallengeMegapoolPerformance(address, group.ValidatorIds, group.StartEpoch, group.Participation, can.ChallengeBond) + } + if err != nil { + return err + } + cliutils.PrintTransactionHash(rp, result.TxHash) + if _, err := rp.WaitForTransaction(result.TxHash); err != nil { + return err + } + fmt.Printf("Successfully submitted the performance challenge for %v.\n", targets) + } + return nil +} diff --git a/rocketpool-cli/cli/verify-performance/verify-performance.go b/rocketpool-cli/cli/verify-performance/verify-performance.go index 04bac067b..861944e7f 100644 --- a/rocketpool-cli/cli/verify-performance/verify-performance.go +++ b/rocketpool-cli/cli/verify-performance/verify-performance.go @@ -8,6 +8,8 @@ import ( "math/big" "time" + "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/bindings/megapool" "github.com/rocket-pool/smartnode/rocketpool-cli/cli/prompt" "github.com/rocket-pool/smartnode/shared/services/performance" "github.com/rocket-pool/smartnode/shared/services/rocketpool" @@ -33,6 +35,9 @@ func ResolveEpochRange(rp *rocketpool.Client, startEpoch, epochs uint64) (uint64 epochs = performance.DefaultPerformancePeriodEpochs } } + if epochs == 0 || startEpoch > ^uint64(0)-(epochs-1) { + return 0, 0, fmt.Errorf("epoch range overflows uint64") + } endEpoch := startEpoch + epochs - 1 return startEpoch, endEpoch, nil } @@ -79,9 +84,9 @@ func PrintResult(resp api.VerifyPerformanceResponse, label string) { fmt.Printf(" Performance: %.2f%%\n", resp.PerformancePct) fmt.Printf(" Threshold: %.2f%%\n", resp.PerformanceThresholdPct) if resp.PassesThreshold { - fmt.Println(" Result: PASS (not exit-eligible under RPIP-73 with these parameters)") + fmt.Println(" Result: PASS (measured target participation meets the threshold)") } else { - fmt.Println(" Result: FAIL (exit-eligible under RPIP-73 with these parameters)") + fmt.Println(" Result: FAIL (measured target participation is below the threshold)") } if len(resp.MissedEpochList) > 0 { @@ -135,7 +140,7 @@ func PrintBatchResults(resp api.VerifyPerformanceBatchResponse, labelFor func(ap // List the validators that failed (and any that errored) at the very end so // they are easy to spot without scrolling back through every result. if len(failedLabels) > 0 { - fmt.Printf("\nFailed validators (exit-eligible under RPIP-73):\n") + fmt.Printf("\nValidators below the measured performance threshold:\n") for _, label := range failedLabels { fmt.Printf(" - %s\n", label) } @@ -149,12 +154,13 @@ func PrintBatchResults(resp api.VerifyPerformanceBatchResponse, labelFor func(ap } // ChallengeGroup is a set of validators sharing an identical missed-epoch -// set, challengeable together in a single challengeMegapool call. +// set, challengeable together in one megapool or minipool challenge. type ChallengeGroup struct { - ValidatorIds []uint32 - StartEpoch uint64 - Participation []*big.Int - MissedEpochs []uint64 + ValidatorIds []uint32 + MinipoolAddresses []common.Address + StartEpoch uint64 + Participation []*big.Int + MissedEpochs []uint64 } // GroupChallengeable groups the challengeable validators of a batch result by @@ -169,17 +175,25 @@ func GroupChallengeable(results []api.VerifyPerformanceResult) []ChallengeGroup if result.Error != "" || perf == nil || !perf.Challengeable || len(perf.MissedEpochList) == 0 { continue } - key := fmt.Sprint(perf.StartEpoch, perf.MissedEpochList) - if i, ok := groupIndexByKey[key]; ok { + key := fmt.Sprint(result.NodeAddress, perf.StartEpoch, perf.MissedEpochList) + if i, ok := groupIndexByKey[key]; ok && len(groups[i].ValidatorIds) < megapool.MaxPerformanceChallengeValidators { groups[i].ValidatorIds = append(groups[i].ValidatorIds, result.ValidatorId) + if result.MinipoolAddress != (common.Address{}) { + groups[i].MinipoolAddresses = append(groups[i].MinipoolAddresses, result.MinipoolAddress) + } continue } groupIndexByKey[key] = len(groups) + var addresses []common.Address + if result.MinipoolAddress != (common.Address{}) { + addresses = []common.Address{result.MinipoolAddress} + } groups = append(groups, ChallengeGroup{ - ValidatorIds: []uint32{result.ValidatorId}, - StartEpoch: perf.StartEpoch, - Participation: perf.Participation, - MissedEpochs: perf.MissedEpochList, + MinipoolAddresses: addresses, + ValidatorIds: []uint32{result.ValidatorId}, + StartEpoch: perf.StartEpoch, + Participation: perf.Participation, + MissedEpochs: perf.MissedEpochList, }) } return groups diff --git a/rocketpool-cli/cli/verify-performance/verify-performance_test.go b/rocketpool-cli/cli/verify-performance/verify-performance_test.go index 07c26cd3c..df305295b 100644 --- a/rocketpool-cli/cli/verify-performance/verify-performance_test.go +++ b/rocketpool-cli/cli/verify-performance/verify-performance_test.go @@ -5,6 +5,8 @@ import ( "reflect" "testing" + "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/shared/types/api" ) @@ -112,3 +114,43 @@ func TestGroupChallengeable(t *testing.T) { }) } } + +func TestChallengeBatchBoundariesAndMinipoolOwners(t *testing.T) { + results := make([]api.VerifyPerformanceResult, 65) + for i := range results { + results[i] = api.VerifyPerformanceResult{ + ValidatorId: uint32(i), + Performance: &api.VerifyPerformanceResponse{ + StartEpoch: 100, + Challengeable: true, + MissedEpochList: []uint64{100}, + Participation: []*big.Int{big.NewInt(1)}, + }, + } + } + groups := GroupChallengeable(results) + if len(groups) != 3 || len(groups[0].ValidatorIds) != 32 || len(groups[1].ValidatorIds) != 32 || len(groups[2].ValidatorIds) != 1 { + t.Fatalf("incorrect batching: %+v", groups) + } + for i := range results { + results[i].MinipoolAddress = common.BigToAddress(big.NewInt(int64(i + 1))) + results[i].NodeAddress = common.BigToAddress(big.NewInt(int64(i%2 + 1))) + } + groups = GroupChallengeable(results) + total := 0 + for _, group := range groups { + if len(group.MinipoolAddresses) > 32 { + t.Fatal("oversized minipool batch") + } + ownerParity := group.MinipoolAddresses[0].Big().Int64() % 2 + for _, addr := range group.MinipoolAddresses { + if addr.Big().Int64()%2 != ownerParity { + t.Fatal("mixed owners in one challenge") + } + } + total += len(group.MinipoolAddresses) + } + if len(groups) != 3 || total != 65 { + t.Fatalf("lost minipools: %d groups, %d members", len(groups), total) + } +} diff --git a/rocketpool-cli/megapool/commands.go b/rocketpool-cli/megapool/commands.go index f024ecfa6..f86275f31 100644 --- a/rocketpool-cli/megapool/commands.go +++ b/rocketpool-cli/megapool/commands.go @@ -465,7 +465,7 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { Aliases: []string{"vp"}, Usage: "Verify the RPIP-73 target-vote attestation performance of one or more megapool validators over a range of epochs.", UsageText: "rocketpool megapool verify-performance validator-ids [options]", - Description: "validator-ids is either a single validator id, a comma-separated list of validator ids, or 'all' to check every validator on the megapool.", + Description: "validator-ids is either a single validator id, a comma-separated list of validator ids, or 'all' to check every validator on the megapool. Eligible results are offered as challenges, grouped into lists of at most 32 validators sharing missed epochs.", Flags: []cli.Flag{ &cli.Uint64Flag{ Name: "start-epoch", @@ -485,7 +485,7 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { &cli.BoolFlag{ Name: "yes", Aliases: []string{"y"}, - Usage: "Skip the warning prompt when --epochs is large.", + Usage: "Automatically confirm verification prompts and eligible challenge transactions that lock staked RPL.", }, }, Action: func(ctx context.Context, c *cli.Command) error { diff --git a/rocketpool-cli/megapool/notify-final-balance.go b/rocketpool-cli/megapool/notify-final-balance.go index c9a22627b..a3c6a737c 100644 --- a/rocketpool-cli/megapool/notify-final-balance.go +++ b/rocketpool-cli/megapool/notify-final-balance.go @@ -24,9 +24,9 @@ func getNotifiableValidator() (uint64, uint64, bool, error) { return 0, 0, false, err } defer rp.Close() - // Get Megapool status (finalized beacon state — required for final balance proofs) - fmt.Println("Loading megapool validators at the finalized beacon state...") - status, err := rp.MegapoolStatus(true) + // Get megapool status at the beacon head + fmt.Println("Loading megapool validators at the beacon head...") + status, err := rp.MegapoolStatus(false) if err != nil { return 0, 0, false, err } @@ -48,7 +48,7 @@ func getNotifiableValidator() (uint64, uint64, bool, error) { } continue } - // Exit is visible on the finalized beacon state but not yet recorded on the megapool. + // Exit is visible on the beacon head but not yet recorded on the megapool. if validator.Activated && validator.BeaconStatus.Exists && validator.BeaconStatus.ExitEpoch != 0 && @@ -75,17 +75,14 @@ func getNotifiableValidator() (uint64, uint64, bool, error) { } func printPendingFinalBalanceValidators(pending, needsExitNotify []api.MegapoolValidatorDetails, status api.MegapoolStatusResponse) { - currentEpoch := status.BeaconHead.FinalizedEpoch - if currentEpoch == 0 { - currentEpoch = status.BeaconHead.Epoch - } + currentEpoch := status.BeaconHead.Epoch secondsPerEpoch := status.SecondsPerEpoch if secondsPerEpoch == 0 { secondsPerEpoch = 384 } if len(pending) == 0 && len(needsExitNotify) == 0 { - fmt.Println("There are also no megapool validators currently exiting on the finalized beacon state.") + fmt.Println("There are also no megapool validators currently exiting on the beacon head.") fmt.Println("A final balance proof can only be submitted after:") fmt.Println(" 1. the validator has exited on the beacon chain") fmt.Println(" 2. notify-validator-exit has been run") @@ -95,7 +92,7 @@ func printPendingFinalBalanceValidators(pending, needsExitNotify []api.MegapoolV if len(needsExitNotify) > 0 { sort.Sort(ByIndex(needsExitNotify)) - fmt.Printf("The following %d validator(s) have an exit visible on the finalized beacon state but still need notify-validator-exit first:\n", len(needsExitNotify)) + fmt.Printf("The following %d validator(s) have an exit visible on the beacon head but still need notify-validator-exit first:\n", len(needsExitNotify)) fmt.Println() for _, v := range needsExitNotify { printPendingFinalBalanceValidator(v, currentEpoch, secondsPerEpoch) @@ -106,7 +103,7 @@ func printPendingFinalBalanceValidators(pending, needsExitNotify []api.MegapoolV if len(pending) > 0 { sort.Sort(ByIndex(pending)) - fmt.Printf("The following %d validator(s) are exiting on the megapool but not yet fully withdrawn (finalized epoch %d):\n", len(pending), currentEpoch) + fmt.Printf("The following %d validator(s) are exiting on the megapool but not yet fully withdrawn (head epoch %d):\n", len(pending), currentEpoch) fmt.Println() for _, v := range pending { printPendingFinalBalanceValidator(v, currentEpoch, secondsPerEpoch) @@ -133,7 +130,7 @@ func printPendingFinalBalanceValidator(v api.MegapoolValidatorDetails, currentEp if exitEpoch != 0 && exitEpoch != FarFutureEpoch { fmt.Printf(" exit_epoch: %d%s\n", exitEpoch, epochTimingSuffix(exitEpoch, currentEpoch, secondsPerEpoch)) } else { - fmt.Printf(" exit_epoch: not yet set on finalized state\n") + fmt.Printf(" exit_epoch: not yet set on beacon head\n") } if withdrawableEpoch != 0 && withdrawableEpoch != FarFutureEpoch { @@ -142,7 +139,7 @@ func printPendingFinalBalanceValidator(v api.MegapoolValidatorDetails, currentEp fmt.Printf(" note: %s\n", beacon.FinalBalanceSweepNote(bs)) } } else { - fmt.Printf(" withdrawable_epoch: not yet set on finalized state\n") + fmt.Printf(" withdrawable_epoch: not yet set on beacon head\n") } fmt.Println() } diff --git a/rocketpool-cli/megapool/notify-validator-exit.go b/rocketpool-cli/megapool/notify-validator-exit.go index fa2910f3a..67ddf4d2f 100644 --- a/rocketpool-cli/megapool/notify-validator-exit.go +++ b/rocketpool-cli/megapool/notify-validator-exit.go @@ -21,8 +21,8 @@ func getExitedValidator() (uint64, bool, error) { return 0, false, err } defer rp.Close() - fmt.Println("Loading megapool validators at the finalized beacon state...") - status, err := rp.MegapoolStatus(true) + fmt.Println("Loading megapool validators at the beacon head...") + status, err := rp.MegapoolStatus(false) if err != nil { return 0, false, err } @@ -61,7 +61,7 @@ func getExitedValidator() (uint64, bool, error) { return uint64(activeValidators[selected].ValidatorId), true, nil } fmt.Println("No validators are ready to notify exit.") - fmt.Println("A validator is only listed once its exit_epoch is set on the *finalized* beacon state") + fmt.Println("A validator is listed once its exit_epoch and withdrawable_epoch are set at the beacon head.") return 0, false, nil } @@ -92,8 +92,8 @@ func notifyValidatorExit(validatorId uint64, yes bool) error { if response.AlreadyExited { fmt.Println(" The validator has already been fully exited on the megapool.") } - if response.ExitNotFinalized { - fmt.Println(" The validator exit is not yet reflected in the finalized beacon state.") + if response.ExitNotReady { + fmt.Println(" The validator exit is not yet visible in the beacon state available for the proof.") } return fmt.Errorf("cannot notify exit of validator id %d", validatorId) } diff --git a/rocketpool-cli/megapool/verify-performance.go b/rocketpool-cli/megapool/verify-performance.go index 2283dcdde..ff3645ddf 100644 --- a/rocketpool-cli/megapool/verify-performance.go +++ b/rocketpool-cli/megapool/verify-performance.go @@ -8,10 +8,7 @@ import ( "github.com/ethereum/go-ethereum/common" cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" - "github.com/rocket-pool/smartnode/rocketpool-cli/cli/prompt" verifyperf "github.com/rocket-pool/smartnode/rocketpool-cli/cli/verify-performance" - "github.com/rocket-pool/smartnode/shared/math" - "github.com/rocket-pool/smartnode/shared/services/gas" "github.com/rocket-pool/smartnode/shared/services/rocketpool" "github.com/rocket-pool/smartnode/shared/types/api" ) @@ -72,75 +69,6 @@ func verifyMegapoolPerformance(megapoolAddress common.Address, targetValidators return challengePerformance(rp, megapoolAddress, resp, yes) } -// challengePerformance drives the on-chain challenge flow for the -// challengeable validators of a verify-performance run: it groups validators -// sharing the same missed epochs so they can be confirmed together, then -// submits one challengeMegapool call per validator (challenges are -// per-validator on-chain, each requiring its own RPL bond) after the gas -// confirmation. -func challengePerformance(rp *rocketpool.Client, megapoolAddress common.Address, resp api.VerifyPerformanceBatchResponse, yes bool) error { - groups := verifyperf.GroupChallengeable(resp.Results) - if len(groups) == 0 { - return nil - } - - settings, err := rp.PDAOGetSettings() - if err != nil { - return fmt.Errorf("error fetching pDAO settings for the challenge bond: %w", err) - } - if !settings.Saturn2Deployed { - fmt.Println("\nPerformance challenges are not available until Saturn 2 is deployed.") - return nil - } - bondRpl := math.RoundDown(math.WeiToEth(settings.Performance.ChallengeBond), 6) - - for _, group := range groups { - ids := make([]string, len(group.ValidatorIds)) - for i, id := range group.ValidatorIds { - ids[i] = fmt.Sprint(id) - } - fmt.Printf("\nValidator id(s) %s missed the same %d target epoch(s).\n", strings.Join(ids, ", "), len(group.MissedEpochs)) - fmt.Printf("Each validator is challenged individually and requires a bond of %.6f RPL.\n", bondRpl) - - if prompt.Declined(yes, "Do you want to challenge validator id(s) %s with a bond of %.6f RPL each?", strings.Join(ids, ", "), bondRpl) { - fmt.Println("Skipped.") - continue - } - - for _, validatorId := range group.ValidatorIds { - can, err := rp.CanChallengeMegapoolPerformance(megapoolAddress, validatorId, group.StartEpoch, group.Participation) - if err != nil { - return err - } - if can.InsufficientRplBalance { - fmt.Printf("The node wallet holds %.6f RPL but the challenge bond requires %.6f RPL. Skipping validator %d.\n", - math.RoundDown(math.WeiToEth(can.RplBalance), 6), math.RoundDown(math.WeiToEth(can.ChallengeBond), 6), validatorId) - continue - } - if !can.CanChallenge { - fmt.Printf("The challenge for validator %d cannot be submitted. Skipping.\n", validatorId) - continue - } - - // Assign max fees - err = gas.AssignMaxFeeAndLimit(can.GasLimits, rp, yes) - if err != nil { - return err - } - - challengeResp, err := rp.ChallengeMegapoolPerformance(megapoolAddress, validatorId, group.StartEpoch, group.Participation) - if err != nil { - return err - } - - fmt.Printf("Submitting the performance challenge for validator %d...\n", validatorId) - cliutils.PrintTransactionHash(rp, challengeResp.TxHash) - if _, err = rp.WaitForTransaction(challengeResp.TxHash); err != nil { - return err - } - fmt.Printf("Successfully challenged validator %d.\n", validatorId) - } - } - - return nil +func challengePerformance(rp *rocketpool.Client, address common.Address, resp api.VerifyPerformanceBatchResponse, yes bool) error { + return verifyperf.SubmitChallenges(rp, address, resp, false, yes) } diff --git a/rocketpool-cli/minipool/commands.go b/rocketpool-cli/minipool/commands.go index 6dd6cc9ce..69b00fa2a 100644 --- a/rocketpool-cli/minipool/commands.go +++ b/rocketpool-cli/minipool/commands.go @@ -331,7 +331,7 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { Aliases: []string{"vp"}, Usage: "Verify the RPIP-73 target-vote attestation performance of one or more minipools over a range of epochs.", UsageText: "rocketpool minipool verify-performance minipools [options]", - Description: "minipools is either a single minipool address, a comma-separated list of minipool addresses, or 'all' to check every minipool owned by the node.", + Description: "minipools is either a single minipool address, a comma-separated list of minipool addresses, or 'all' to check every minipool owned by the node. Eligible results are offered as challenges, grouped into lists of at most 32 minipools with the same owner and missed epochs.", Flags: []cli.Flag{ &cli.Uint64Flag{ Name: "start-epoch", @@ -346,7 +346,7 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { &cli.BoolFlag{ Name: "yes", Aliases: []string{"y"}, - Usage: "Skip the warning prompt when --epochs is large.", + Usage: "Automatically confirm verification prompts and eligible challenge transactions that lock staked RPL.", }, }, Action: func(ctx context.Context, c *cli.Command) error { diff --git a/rocketpool-cli/minipool/verify-performance.go b/rocketpool-cli/minipool/verify-performance.go index 13b0d9bc6..ad954d4bb 100644 --- a/rocketpool-cli/minipool/verify-performance.go +++ b/rocketpool-cli/minipool/verify-performance.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "github.com/ethereum/go-ethereum/common" + cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" verifyperf "github.com/rocket-pool/smartnode/rocketpool-cli/cli/verify-performance" "github.com/rocket-pool/smartnode/shared/services/rocketpool" @@ -60,5 +62,5 @@ func verifyMinipoolPerformance(targets string, startEpoch uint64, epochs uint64, return "minipool " + r.MinipoolAddress.Hex() }) verifyperf.PrintElapsed(elapsed) - return nil + return verifyperf.SubmitChallenges(rp, common.Address{}, resp, true, yes) } diff --git a/rocketpool-cli/node/challenge-bonds.go b/rocketpool-cli/node/challenge-bonds.go new file mode 100644 index 000000000..6273a46dc --- /dev/null +++ b/rocketpool-cli/node/challenge-bonds.go @@ -0,0 +1,95 @@ +package node + +import ( + "fmt" + "math/big" + + cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" + "github.com/rocket-pool/smartnode/rocketpool-cli/cli/prompt" + "github.com/rocket-pool/smartnode/shared/services/gas" + "github.com/rocket-pool/smartnode/shared/services/rocketpool" +) + +func releaseChallengeBond(id *big.Int, yes bool) error { + rp, err := rocketpool.NewClient().WithReady() + if err != nil { + return err + } + defer rp.Close() + can, err := rp.CanReleaseChallengeBond(id) + if err != nil { + return err + } + if !can.Saturn2Deployed { + fmt.Println(cliutils.Saturn2NotDeployedMessage) + return nil + } + if !can.CanSettle { + fmt.Println(can.Reason) + return nil + } + formatRPL := func(value *big.Int) string { + return new(big.Rat).SetFrac(value, big.NewInt(1e18)).FloatString(18) + } + fmt.Printf("Challenge %s; proposer: %s\nRecorded bond: %s RPL\n", id, can.Proposer.Hex(), formatRPL(can.BondAmount)) + fmt.Println("This unlocks the proposer's existing staked RPL and does not finalise validator exits.") + if err := gas.AssignMaxFeeAndLimit(can.GasLimits, rp, yes); err != nil { + return err + } + if prompt.Declined(yes, "Release bond for challenge %s?", id) { + fmt.Println("Cancelled.") + return nil + } + result, err := rp.ReleaseChallengeBond(id) + if err != nil { + return err + } + cliutils.PrintTransactionHash(rp, result.TxHash) + if _, err := rp.WaitForTransaction(result.TxHash); err != nil { + return err + } + fmt.Printf("Challenge %s: Release bond completed.\n", id) + return nil +} + +func claimChallengeReward(id *big.Int, yes bool) error { + rp, err := rocketpool.NewClient().WithReady() + if err != nil { + return err + } + defer rp.Close() + can, err := rp.CanClaimChallengeReward(id) + if err != nil { + return err + } + if !can.Saturn2Deployed { + fmt.Println(cliutils.Saturn2NotDeployedMessage) + return nil + } + if !can.CanSettle { + fmt.Println(can.Reason) + return nil + } + formatRPL := func(value *big.Int) string { + return new(big.Rat).SetFrac(value, big.NewInt(1e18)).FloatString(18) + } + fmt.Printf("Challenge %s; proposer: %s\nRecorded bond: %s RPL\n", id, can.Proposer.Hex(), formatRPL(can.BondAmount)) + fmt.Printf("Estimated reward: %s staked RPL; burn: %s RPL. Amounts depend on the proposer's recoverable stake at execution.\n", formatRPL(can.EstimatedReward), formatRPL(can.EstimatedBurn)) + if err := gas.AssignMaxFeeAndLimit(can.GasLimits, rp, yes); err != nil { + return err + } + if prompt.Declined(yes, "Claim defender reward for challenge %s?", id) { + fmt.Println("Cancelled.") + return nil + } + result, err := rp.ClaimChallengeReward(id) + if err != nil { + return err + } + cliutils.PrintTransactionHash(rp, result.TxHash) + if _, err := rp.WaitForTransaction(result.TxHash); err != nil { + return err + } + fmt.Printf("Challenge %s: Claim defender reward completed.\n", id) + return nil +} diff --git a/rocketpool-cli/node/commands.go b/rocketpool-cli/node/commands.go index 18b2b33d8..25f4a747d 100644 --- a/rocketpool-cli/node/commands.go +++ b/rocketpool-cli/node/commands.go @@ -3,6 +3,7 @@ package node import ( "context" "fmt" + "math/big" "strconv" "strings" @@ -18,6 +19,117 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { Aliases: aliases, Usage: "Manage the node", Commands: []*cli.Command{ + { + Name: "performance-challenges", + Usage: "List unresolved performance challenges and unsettled bonds, or inspect a challenge by ID", + UsageText: "rocketpool node performance-challenges [challenge-id]", + Action: func(ctx context.Context, c *cli.Command) error { + if c.Args().Len() > 1 { + return fmt.Errorf("expected at most one challenge ID") + } + var id *big.Int + if c.Args().Len() == 1 { + var err error + id, err = validatePerformanceChallengeID(c.Args().Get(0)) + if err != nil { + return err + } + } + return getPerformanceChallenges(id) + }, + }, + + { + Name: "defend-performance-challenge", + Usage: "Find and submit a proof defeating an unresolved performance challenge", + UsageText: "rocketpool node defend-performance-challenge challenge-id [options]", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm interactive questions", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + id, err := validatePerformanceChallengeID(c.Args().Get(0)) + if err != nil { + return err + } + return actOnPerformanceChallenge(id, true, c.Bool("yes")) + }, + }, + + { + Name: "finalise-performance-challenge", + Usage: "Finalise an undefeated performance challenge after its response deadline", + UsageText: "rocketpool node finalise-performance-challenge challenge-id [options]", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm interactive questions", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + id, err := validatePerformanceChallengeID(c.Args().Get(0)) + if err != nil { + return err + } + return actOnPerformanceChallenge(id, false, c.Bool("yes")) + }, + }, + + { + Name: "release-challenge-bond", + Usage: "Unlock an undefeated performance challenge's RPL bond after its response deadline.", + UsageText: "rocketpool node release-challenge-bond challenge-id [options]", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm interactive questions", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + id, ok := new(big.Int).SetString(c.Args().Get(0), 10) + if !ok || id.Sign() <= 0 || id.BitLen() > 256 { + return fmt.Errorf("challenge ID must be a positive uint256") + } + return releaseChallengeBond(id, c.Bool("yes")) + }, + }, + + { + Name: "claim-challenge-reward", + Usage: "Claim a defeated performance challenge's reward as staked RPL (recorded defender only).", + UsageText: "rocketpool node claim-challenge-reward challenge-id [options]", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm interactive questions", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + id, ok := new(big.Int).SetString(c.Args().Get(0), 10) + if !ok || id.Sign() <= 0 || id.BitLen() > 256 { + return fmt.Errorf("challenge ID must be a positive uint256") + } + return claimChallengeReward(id, c.Bool("yes")) + }, + }, { Name: "status", diff --git a/rocketpool-cli/node/performance-challenges.go b/rocketpool-cli/node/performance-challenges.go new file mode 100644 index 000000000..089248a83 --- /dev/null +++ b/rocketpool-cli/node/performance-challenges.go @@ -0,0 +1,107 @@ +package node + +import ( + "fmt" + "math/big" + "time" + + cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" + "github.com/rocket-pool/smartnode/rocketpool-cli/cli/prompt" + "github.com/rocket-pool/smartnode/shared/services/gas" + "github.com/rocket-pool/smartnode/shared/services/rocketpool" +) + +func validatePerformanceChallengeID(value string) (*big.Int, error) { + id, ok := new(big.Int).SetString(value, 10) + if !ok || id.Sign() <= 0 || id.BitLen() > 256 { + return nil, fmt.Errorf("challenge ID must be a positive uint256") + } + return id, nil +} + +func getPerformanceChallenges(id *big.Int) error { + rp, err := rocketpool.NewClient().WithReady() + if err != nil { + return err + } + defer rp.Close() + + response, err := rp.GetPerformanceChallenges(id) + if err != nil { + return err + } + if !response.Saturn2Deployed { + fmt.Println(cliutils.Saturn2NotDeployedMessage) + return nil + } + if len(response.Challenges) == 0 { + fmt.Println("No matching performance challenges.") + return nil + } + + for _, details := range response.Challenges { + challenge := details.Challenge + status := details.State + bond := new(big.Rat).SetFrac(status.BondAmount, big.NewInt(1e18)).FloatString(18) + deadline := time.Unix(status.ResponseDeadline.Int64(), 0).UTC().Format(time.RFC3339) + + fmt.Printf("Challenge %s; start epoch %d\n", challenge.ChallengeId, challenge.StartEpoch) + if len(challenge.MinipoolAddresses) > 0 { + fmt.Printf(" Node: %s; minipools: %v\n", challenge.NodeAddress, challenge.MinipoolAddresses) + } else { + fmt.Printf(" Megapool: %s; validators: %v\n", challenge.MegapoolAddress, challenge.ValidatorIds) + } + fmt.Printf(" Proposer: %s; responder: %s\n", status.Proposer, status.Responder) + fmt.Printf(" Response deadline: %s\n", deadline) + fmt.Printf(" Defeated: %t; finalised: %t; bond settled: %t; recorded bond: %s RPL\n", status.Responded, status.Finalised, status.BondSettled, bond) + } + return nil +} + +func actOnPerformanceChallenge(id *big.Int, defend bool, yes bool) error { + rp, err := rocketpool.NewClient().WithReady() + if err != nil { + return err + } + defer rp.Close() + + canAct := rp.CanFinalisePerformanceChallenge + submit := rp.FinalisePerformanceChallenge + action := "Finalise" + if defend { + canAct = rp.CanDefendPerformanceChallenge + submit = rp.DefendPerformanceChallenge + action = "Defend" + } + + response, err := canAct(id) + if err != nil { + return err + } + if !response.CanAct { + fmt.Println(response.Reason) + return nil + } + if !defend { + fmt.Println("Finalisation requests exits for eligible challenged validators. Bond settlement is separate.") + } + + if err := gas.AssignMaxFeeAndLimit(response.GasLimits, rp, yes); err != nil { + return err + } + if prompt.Declined(yes, "%s performance challenge %s?", action, id) { + fmt.Println("Cancelled.") + return nil + } + + result, err := submit(id) + if err != nil { + return err + } + cliutils.PrintTransactionHash(rp, result.TxHash) + if _, err := rp.WaitForTransaction(result.TxHash); err != nil { + return err + } + fmt.Printf("Challenge %s: %s completed.\n", id, action) + return nil +} diff --git a/rocketpool/api/megapool/challenge-performance.go b/rocketpool/api/megapool/challenge-performance.go index 3a1e312d2..57831917c 100644 --- a/rocketpool/api/megapool/challenge-performance.go +++ b/rocketpool/api/megapool/challenge-performance.go @@ -1,220 +1,9 @@ package megapool import ( - "fmt" - "math/big" - - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/urfave/cli/v3" - - "github.com/rocket-pool/smartnode/bindings/megapool" - "github.com/rocket-pool/smartnode/bindings/node" - "github.com/rocket-pool/smartnode/bindings/rocketpool" - "github.com/rocket-pool/smartnode/bindings/settings/protocol" - "github.com/rocket-pool/smartnode/bindings/tokens" - "github.com/rocket-pool/smartnode/rocketpool/api/response" + "github.com/rocket-pool/smartnode/rocketpool/api/performance" "github.com/rocket-pool/smartnode/rocketpool/api/snroute" - "github.com/rocket-pool/smartnode/shared/services" - "github.com/rocket-pool/smartnode/shared/services/beacon" - "github.com/rocket-pool/smartnode/shared/services/wallet" - "github.com/rocket-pool/smartnode/shared/types/api" ) -// canChallengePerformance checks whether the node can challenge the target-vote -// performance of a megapool validator: the node wallet must hold the -// performance_challenge_bond in RPL, and the challengeMegapool call must pass -// gas estimation with a fresh slot proof. -func canChallengePerformance( - c *cli.Command, - megapoolAddress common.Address, - validatorId uint32, - startEpoch uint64, - participation []*big.Int, -) (*api.CanChallengeMegapoolPerformanceResponse, error) { - if err := services.RequireNodeRegistered(c); err != nil { - return nil, err - } - if err := services.RequireBeaconClientSynced(c); err != nil { - return nil, err - } - w, err := services.GetWallet(c) - if err != nil { - return nil, err - } - rp, err := services.GetRocketPool(c) - if err != nil { - return nil, err - } - bc, err := services.GetBeaconClient(c) - if err != nil { - return nil, err - } - - response := api.CanChallengeMegapoolPerformanceResponse{} - - nodeAccount, err := w.GetNodeAccount() - if err != nil { - return nil, err - } - megapoolAddress, err = resolveChallengedMegapool(rp, nodeAccount.Address, megapoolAddress) - if err != nil { - return nil, err - } - - // Check the node wallet holds the challenge bond in RPL before doing any - // expensive proof work. - response.ChallengeBond, err = protocol.GetPerformanceChallengeBond(rp, nil) - if err != nil { - return nil, err - } - response.RplBalance, err = tokens.GetRPLBalance(rp, nodeAccount.Address, nil) - if err != nil { - return nil, fmt.Errorf("error getting node RPL balance: %w", err) - } - if response.RplBalance.Cmp(response.ChallengeBond) < 0 { - response.InsufficientRplBalance = true - return &response, nil - } - - slotTimestamp, slotProof, err := getChallengeSlotProof(c, w, bc, rp, megapoolAddress, validatorId) - if err != nil { - return nil, err - } - - opts, err := w.GetNodeAccountTransactor() - if err != nil { - return nil, err - } - response.GasLimits, err = megapool.EstimateChallengeMegapoolGas(rp, megapoolAddress, validatorId, startEpoch, participation, slotTimestamp, slotProof, opts) - if err != nil { - return nil, fmt.Errorf("error estimating challengeMegapool gas: %w", err) - } - - response.CanChallenge = true - return &response, nil -} - -// challengePerformance submits the challengeMegapool transaction for a -// megapool validator. -func challengePerformance( - c *cli.Command, - megapoolAddress common.Address, - validatorId uint32, - startEpoch uint64, - participation []*big.Int, - opts *bind.TransactOpts, -) (*api.ChallengeMegapoolPerformanceResponse, error) { - if err := services.RequireNodeRegistered(c); err != nil { - return nil, err - } - if err := services.RequireBeaconClientSynced(c); err != nil { - return nil, err - } - w, err := services.GetWallet(c) - if err != nil { - return nil, err - } - rp, err := services.GetRocketPool(c) - if err != nil { - return nil, err - } - bc, err := services.GetBeaconClient(c) - if err != nil { - return nil, err - } - - response := api.ChallengeMegapoolPerformanceResponse{} - - nodeAccount, err := w.GetNodeAccount() - if err != nil { - return nil, err - } - megapoolAddress, err = resolveChallengedMegapool(rp, nodeAccount.Address, megapoolAddress) - if err != nil { - return nil, err - } - - slotTimestamp, slotProof, err := getChallengeSlotProof(c, w, bc, rp, megapoolAddress, validatorId) - if err != nil { - return nil, err - } - - response.TxHash, err = megapool.ChallengeMegapool(rp, megapoolAddress, validatorId, startEpoch, participation, slotTimestamp, slotProof, opts) - if err != nil { - return nil, err - } - - return &response, nil -} - -// resolveChallengedMegapool returns the megapool address to challenge, -// resolving the zero address to the node's own megapool. -func resolveChallengedMegapool(rp *rocketpool.RocketPool, nodeAddress common.Address, megapoolAddress common.Address) (common.Address, error) { - if (megapoolAddress != common.Address{}) { - return megapoolAddress, nil - } - megapoolAddress, err := node.GetMegapoolAddress(rp, nodeAddress, nil) - if err != nil { - return common.Address{}, fmt.Errorf("error looking up node's megapool address: %w", err) - } - if (megapoolAddress == common.Address{}) { - return common.Address{}, fmt.Errorf("node has no megapool deployed; pass a megapool address to challenge") - } - return megapoolAddress, nil -} - -// getChallengeSlotProof builds the beacon slot proof and timestamp required by -// challengeMegapool, anchored on the challenged validator's pubkey (the -// challenge itself only consumes the slot proof, not the validator proof). -func getChallengeSlotProof( - c *cli.Command, - w wallet.Wallet, - bc beacon.Client, - rp *rocketpool.RocketPool, - megapoolAddress common.Address, - validatorId uint32, -) (uint64, megapool.SlotProof, error) { - mp, err := megapool.NewMegaPoolV1(rp, megapoolAddress, nil) - if err != nil { - return 0, megapool.SlotProof{}, fmt.Errorf("error creating megapool binding for %s: %w", megapoolAddress.Hex(), err) - } - pubkey, err := mp.GetValidatorPubkey(validatorId, nil) - if err != nil { - return 0, megapool.SlotProof{}, fmt.Errorf("error getting megapool validator %d pubkey: %w", validatorId, err) - } - eth2Config, err := bc.GetEth2Config() - if err != nil { - return 0, megapool.SlotProof{}, fmt.Errorf("error getting beacon config: %w", err) - } - _, slotTimestamp, slotProof, err := services.GetValidatorProof(c, 0, w, eth2Config, pubkey, nil) - if err != nil { - return 0, megapool.SlotProof{}, fmt.Errorf("error building slot proof: %w", err) - } - return slotTimestamp, slotProof, nil -} - -func canChallengePerformanceHandler(ctx snroute.Context) { - megapoolAddr, validatorId, startEpoch, participation, err := parseChallengePerformanceParams(ctx.Request) - if err != nil { - response.WriteErrorResponse(ctx.Writer, err) - return - } - resp, err := canChallengePerformance(ctx.Command(), megapoolAddr, validatorId, startEpoch, participation) - response.WriteResponse(ctx.Writer, resp, err) -} - -func challengePerformanceHandler(ctx snroute.WriteContext) { - megapoolAddr, validatorId, startEpoch, participation, err := parseChallengePerformanceParams(ctx.Request) - if err != nil { - response.WriteErrorResponse(ctx.Writer, err) - return - } - opts, err := ctx.Transactor() - if err != nil { - response.WriteErrorResponse(ctx.Writer, err) - return - } - resp, err := challengePerformance(ctx.Command(), megapoolAddr, validatorId, startEpoch, participation, opts.Opts()) - response.WriteResponse(ctx.Writer, resp, err) -} +func canChallengePerformanceHandler(ctx snroute.Context) { performance.CanChallengeHandler(false)(ctx) } +func challengePerformanceHandler(ctx snroute.WriteContext) { performance.ChallengeHandler(false)(ctx) } diff --git a/rocketpool/api/megapool/dissolve-with-proof.go b/rocketpool/api/megapool/dissolve-with-proof.go index a10cb0fdc..b1ed68d54 100644 --- a/rocketpool/api/megapool/dissolve-with-proof.go +++ b/rocketpool/api/megapool/dissolve-with-proof.go @@ -69,12 +69,12 @@ func canDissolveWithProof(c *cli.Command, validatorId uint32) (*api.CanDissolveW return nil, err } - eth2Config, err := bc.GetEth2Config() + beaconState, slotTimestamp, err := services.GetHeadBeaconState(bc, rp.Client) if err != nil { return nil, err } - proof, slotTimestamp, slotProof, err := services.GetValidatorProof(c, 0, w, eth2Config, types.ValidatorPubkey(validatorInfo.Pubkey), nil) + proof, slotProof, err := services.GetValidatorProofFromState(bc, types.ValidatorPubkey(validatorInfo.Pubkey), beaconState) if err != nil { return nil, err } @@ -158,12 +158,12 @@ func dissolveWithProof(c *cli.Command, validatorId uint32, t *snroute.TransactOp return nil, err } - eth2Config, err := bc.GetEth2Config() + beaconState, slotTimestamp, err := services.GetHeadBeaconState(bc, rp.Client) if err != nil { return nil, err } - validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(c, 0, w, eth2Config, types.ValidatorPubkey(validatorInfo.Pubkey), nil) + validatorProof, slotProof, err := services.GetValidatorProofFromState(bc, types.ValidatorPubkey(validatorInfo.Pubkey), beaconState) if err != nil { return nil, err } diff --git a/rocketpool/api/megapool/notify-final-balance.go b/rocketpool/api/megapool/notify-final-balance.go index 14c46bddd..8a56a8d25 100644 --- a/rocketpool/api/megapool/notify-final-balance.go +++ b/rocketpool/api/megapool/notify-final-balance.go @@ -68,7 +68,11 @@ func canNotifyFinalBalance(c *cli.Command, validatorId uint32, withdrawalSlot ui } // If the slot was not provided, use the validator's withdrawable epoch supplied by the beacon client if withdrawalSlot == 0 { - withdrawalSlot = validatorStatus.WithdrawableEpoch * 32 + eth2Config, err := bc.GetEth2Config() + if err != nil { + return nil, err + } + withdrawalSlot = validatorStatus.WithdrawableEpoch * eth2Config.SlotsPerEpoch } opts, err := w.GetNodeAccountTransactor() @@ -76,7 +80,7 @@ func canNotifyFinalBalance(c *cli.Command, validatorId uint32, withdrawalSlot ui return nil, err } - proof, err := services.BuildMegapoolFinalBalanceProof(c, rp, megapoolAddress, withdrawalSlot, validatorIndex, types.ValidatorPubkey(validatorInfo.Pubkey), w) + proof, err := services.BuildMegapoolFinalBalanceProof(c, rp, withdrawalSlot, validatorIndex, types.ValidatorPubkey(validatorInfo.Pubkey)) if err != nil { return nil, err } @@ -154,10 +158,14 @@ func notifyFinalBalance(c *cli.Command, validatorId uint32, withdrawalSlot uint6 } // If the slot was not provided, use the validator's withdrawable epoch supplied by the beacon client if withdrawalSlot == 0 { - withdrawalSlot = validatorStatus.WithdrawableEpoch * 32 + eth2Config, err := bc.GetEth2Config() + if err != nil { + return nil, err + } + withdrawalSlot = validatorStatus.WithdrawableEpoch * eth2Config.SlotsPerEpoch } - proof, err := services.BuildMegapoolFinalBalanceProof(c, rp, megapoolAddress, withdrawalSlot, validatorIndex, types.ValidatorPubkey(validatorInfo.Pubkey), w) + proof, err := services.BuildMegapoolFinalBalanceProof(c, rp, withdrawalSlot, validatorIndex, types.ValidatorPubkey(validatorInfo.Pubkey)) if err != nil { return nil, err } diff --git a/rocketpool/api/megapool/notify-validator-exit.go b/rocketpool/api/megapool/notify-validator-exit.go index dc38f111a..9d203bdf1 100644 --- a/rocketpool/api/megapool/notify-validator-exit.go +++ b/rocketpool/api/megapool/notify-validator-exit.go @@ -8,6 +8,7 @@ import ( "github.com/urfave/cli/v3" "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/rocketpool" "github.com/rocket-pool/smartnode/bindings/types" "github.com/rocket-pool/smartnode/rocketpool/api/response" "github.com/rocket-pool/smartnode/rocketpool/api/snroute" @@ -17,30 +18,37 @@ import ( "github.com/rocket-pool/smartnode/shared/types/eth2" ) -var errExitNotFinalized = errors.New("validator exit not yet finalized") +var errExitNotReady = errors.New("validator exit not yet visible in the proof state") -func ensureExitFinalized(bc beacon.Client, pubkey types.ValidatorPubkey) (eth2.BeaconState, error) { - beaconState, err := services.GetBeaconState(bc) +func ensureExitReady(bc beacon.Client, ec rocketpool.ExecutionClient, pubkey types.ValidatorPubkey) (eth2.BeaconState, uint64, error) { + beaconState, slotTimestamp, err := services.GetHeadBeaconState(bc, ec) if err != nil { - return nil, err + return nil, 0, err + } + if err := checkExitProofState(bc, pubkey, beaconState); err != nil { + return nil, 0, err } + return beaconState, slotTimestamp, nil +} + +func checkExitProofState(bc beacon.Client, pubkey types.ValidatorPubkey, beaconState eth2.BeaconState) error { validators := beaconState.GetValidators() validatorIndexStr, err := bc.GetValidatorIndex(pubkey) if err != nil { - return nil, fmt.Errorf("error getting beacon index: %w", err) + return fmt.Errorf("error getting beacon index: %w", err) } validatorIndex, err := strconv.ParseUint(validatorIndexStr, 10, 64) if err != nil { - return nil, fmt.Errorf("error parsing beacon index %q: %w", validatorIndexStr, err) + return fmt.Errorf("error parsing beacon index %q: %w", validatorIndexStr, err) } if validatorIndex >= uint64(len(validators)) { - return nil, fmt.Errorf("%w: validator (beacon index %d) is not yet included in the finalized beacon state", errExitNotFinalized, validatorIndex) + return fmt.Errorf("%w: validator (beacon index %d) is not yet included in the selected beacon state", errExitNotReady, validatorIndex) } if validators[validatorIndex].WithdrawableEpoch >= farFutureEpoch { - return nil, fmt.Errorf("%w: validator (beacon index %d) withdrawable_epoch is still FAR_FUTURE on finalized state", errExitNotFinalized, validatorIndex) + return fmt.Errorf("%w: validator (beacon index %d) withdrawable_epoch is still FAR_FUTURE in the selected beacon state", errExitNotReady, validatorIndex) } - return beaconState, nil + return nil } func canNotifyValidatorExit(c *cli.Command, validatorId uint32) (*api.CanNotifyValidatorExitResponse, error) { @@ -106,23 +114,18 @@ func canNotifyValidatorExit(c *cli.Command, validatorId uint32) (*api.CanNotifyV pubkey := types.ValidatorPubkey(validatorInfo.Pubkey) - // Proofs use finalized state — do not build/submit until the exit is there. - beaconState, err := ensureExitFinalized(bc, pubkey) + // Check the exit is visible in the state selected for the proof. + beaconState, slotTimestamp, err := ensureExitReady(bc, rp.Client, pubkey) if err != nil { - if errors.Is(err, errExitNotFinalized) { - response.ExitNotFinalized = true + if errors.Is(err, errExitNotReady) { + response.ExitNotReady = true response.CanExit = false return &response, nil } return nil, err } - eth2Config, err := bc.GetEth2Config() - if err != nil { - return nil, err - } - - proof, slotTimestamp, slotProof, err := services.GetValidatorProof(c, 0, w, eth2Config, pubkey, beaconState) + proof, slotProof, err := services.GetValidatorProofFromState(bc, pubkey, beaconState) if err != nil { return nil, err } @@ -197,23 +200,18 @@ func notifyValidatorExit(c *cli.Command, validatorId uint32, t *snroute.Transact pubkey := types.ValidatorPubkey(validatorInfo.Pubkey) - beaconState, err := ensureExitFinalized(bc, pubkey) - if err != nil { - return nil, err - } - - eth2Config, err := bc.GetEth2Config() + beaconState, slotTimestamp, err := ensureExitReady(bc, rp.Client, pubkey) if err != nil { return nil, err } - validatorProof, slotTimetamp, slotProof, err := services.GetValidatorProof(c, 0, w, eth2Config, pubkey, beaconState) + validatorProof, slotProof, err := services.GetValidatorProofFromState(bc, pubkey, beaconState) if err != nil { return nil, err } // Notify the validator exit - tx, err := services.NotifyMegapoolExit(rp, megapoolAddress, validatorId, slotTimetamp, validatorProof, slotProof, opts) + tx, err := services.NotifyMegapoolExit(rp, megapoolAddress, validatorId, slotTimestamp, validatorProof, slotProof, opts) if err != nil { return nil, err } diff --git a/rocketpool/api/megapool/routes.go b/rocketpool/api/megapool/routes.go index 8e69be8f6..22cebd7de 100644 --- a/rocketpool/api/megapool/routes.go +++ b/rocketpool/api/megapool/routes.go @@ -5,9 +5,6 @@ import ( "math/big" "net/http" "strconv" - "strings" - - "github.com/ethereum/go-ethereum/common" "github.com/rocket-pool/smartnode/rocketpool/api/response" "github.com/rocket-pool/smartnode/rocketpool/api/snroute" @@ -91,58 +88,6 @@ func parseBool(r *http.Request, name string) (bool, error) { return v, nil } -// parseChallengePerformanceParams parses the shared parameters of the -// can-challenge-performance and challenge-performance routes. megapoolAddress -// is optional (the zero address means "use the node's own megapool"). -func parseChallengePerformanceParams(r *http.Request) (common.Address, uint32, uint64, []*big.Int, error) { - var megapoolAddr common.Address - if raw := r.URL.Query().Get("megapoolAddress"); raw != "" { - megapoolAddr = common.HexToAddress(raw) - } else if raw := r.FormValue("megapoolAddress"); raw != "" { - megapoolAddr = common.HexToAddress(raw) - } - validatorId, err := parseUint32(r, "validatorId") - if err != nil { - return common.Address{}, 0, 0, nil, err - } - startEpoch, err := parseUint64(r, "startEpoch") - if err != nil { - return common.Address{}, 0, 0, nil, err - } - participation, err := parseBigIntList(r, "participation") - if err != nil { - return common.Address{}, 0, 0, nil, err - } - return megapoolAddr, validatorId, startEpoch, participation, nil -} - -func parseBigIntList(r *http.Request, name string) ([]*big.Int, error) { - raw := r.URL.Query().Get(name) - if raw == "" { - raw = r.FormValue(name) - } - if raw == "" { - return nil, &response.BadRequestError{Err: fmt.Errorf("missing required parameter '%s'", name)} - } - parts := strings.Split(raw, ",") - values := make([]*big.Int, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - v, ok := new(big.Int).SetString(part, 10) - if !ok { - return nil, &response.BadRequestError{Err: fmt.Errorf("invalid %s entry: %s", name, part)} - } - values = append(values, v) - } - if len(values) == 0 { - return nil, &response.BadRequestError{Err: fmt.Errorf("no valid entries in parameter '%s'", name)} - } - return values, nil -} - func parseBigInt(r *http.Request, name string) (*big.Int, error) { raw := r.URL.Query().Get(name) if raw == "" { diff --git a/rocketpool/api/megapool/stake.go b/rocketpool/api/megapool/stake.go index b57d89f45..51665bb1b 100644 --- a/rocketpool/api/megapool/stake.go +++ b/rocketpool/api/megapool/stake.go @@ -85,12 +85,12 @@ func canStake(c *cli.Command, validatorId uint64) (*api.CanStakeResponse, error) return &response, nil } - eth2Config, err := bc.GetEth2Config() + beaconState, slotTimestamp, err := services.GetHeadBeaconState(bc, rp.Client) if err != nil { return nil, err } - validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(c, 0, w, eth2Config, types.ValidatorPubkey(validatorInfo.Pubkey), nil) + validatorProof, slotProof, err := services.GetValidatorProofFromState(bc, types.ValidatorPubkey(validatorInfo.Pubkey), beaconState) if err != nil { if strings.Contains(err.Error(), "index not found") { response.CanStake = false @@ -162,12 +162,12 @@ func stake(c *cli.Command, validatorId uint64, t *snroute.TransactOpts) (*api.St return nil, err } - eth2Config, err := bc.GetEth2Config() + beaconState, slotTimestamp, err := services.GetHeadBeaconState(bc, rp.Client) if err != nil { return nil, err } - validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(c, 0, w, eth2Config, types.ValidatorPubkey(validatorInfo.Pubkey), nil) + validatorProof, slotProof, err := services.GetValidatorProofFromState(bc, types.ValidatorPubkey(validatorInfo.Pubkey), beaconState) if err != nil { return nil, err } diff --git a/rocketpool/api/megapool/verify-performance.go b/rocketpool/api/megapool/verify-performance.go index d64a8bea8..d414eb167 100644 --- a/rocketpool/api/megapool/verify-performance.go +++ b/rocketpool/api/megapool/verify-performance.go @@ -89,12 +89,20 @@ func verifyPerformance( pubkeys[i] = pubkey } + head, err := bc.GetBeaconHead() + if err != nil { + return nil, err + } + if err := performance.ValidateMeasurementRange(head.Epoch, startEpoch, endEpoch); err != nil { + return nil, err + } + batch, err := performance.VerifyPerformanceBatch(rp, bc, pubkeys, startEpoch, endEpoch) if err != nil { return nil, err } - challengeable, err := performance.IsRangeChallengeable(rp, bc, startEpoch, endEpoch) + challengeParams, err := performance.GetChallengeParams(rp) if err != nil { return nil, err } @@ -103,7 +111,7 @@ func verifyPerformance( Results: make([]api.VerifyPerformanceResult, 0, len(validatorIds)), } for i, validatorId := range validatorIds { - if !batch[i].Active { + if !batch[i].Active && pubkeyErrs[i] == "" && batch[i].Err == nil { continue } result := api.VerifyPerformanceResult{ValidatorId: validatorId} @@ -114,7 +122,7 @@ func verifyPerformance( result.Error = batch[i].Err.Error() default: result.Performance = batch[i].Response - result.Performance.Challengeable = challengeable && performance.ExceedsChallengeThreshold(result.Performance) + performance.SetChallengeability(result.Performance, challengeParams, head.Epoch) } response.Results = append(response.Results, result) } diff --git a/rocketpool/api/minipool/routes.go b/rocketpool/api/minipool/routes.go index baef18604..c4c92fe1d 100644 --- a/rocketpool/api/minipool/routes.go +++ b/rocketpool/api/minipool/routes.go @@ -7,11 +7,14 @@ import ( "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/rocketpool/api/performance" "github.com/rocket-pool/smartnode/rocketpool/api/snroute" ) // RegisterRoutes registers the minipool module's HTTP routes onto router. func RegisterRoutes(router *snroute.Router) { + snroute.Read("/api/minipool/can-challenge-performance", performance.CanChallengeHandler(true)).RegisterTo(router) + snroute.Write("/api/minipool/challenge-performance", performance.ChallengeHandler(true)).RegisterTo(router) snroute.Read("/api/minipool/status", statusHandler).RegisterTo(router) snroute.Read("/api/minipool/can-refund", canRefundHandler).RegisterTo(router) snroute.Write("/api/minipool/refund", refundHandler).RegisterTo(router) diff --git a/rocketpool/api/minipool/verify-performance.go b/rocketpool/api/minipool/verify-performance.go index 797e85b05..404054958 100644 --- a/rocketpool/api/minipool/verify-performance.go +++ b/rocketpool/api/minipool/verify-performance.go @@ -52,21 +52,40 @@ func verifyPerformance( // failures are recorded and excluded from the beacon batch. pubkeys := make([]rptypes.ValidatorPubkey, len(addresses)) pubkeyErrs := make([]string, len(addresses)) + owners := make([]common.Address, len(addresses)) for i, address := range addresses { pubkey, err := minipool.GetMinipoolPubkey(rp, address, nil) if err != nil { pubkeyErrs[i] = fmt.Sprintf("error getting minipool %s pubkey: %s", address.Hex(), err.Error()) continue } + mp, err := minipool.NewMinipool(rp, address, nil) + if err != nil { + pubkeyErrs[i] = err.Error() + continue + } + owners[i], err = mp.GetNodeAddress(nil) + if err != nil { + pubkeyErrs[i] = err.Error() + continue + } pubkeys[i] = pubkey } + head, err := bc.GetBeaconHead() + if err != nil { + return nil, err + } + if err := performance.ValidateMeasurementRange(head.Epoch, startEpoch, endEpoch); err != nil { + return nil, err + } + batch, err := performance.VerifyPerformanceBatch(rp, bc, pubkeys, startEpoch, endEpoch) if err != nil { return nil, err } - challengeable, err := performance.IsRangeChallengeable(rp, bc, startEpoch, endEpoch) + challengeParams, err := performance.GetChallengeParams(rp) if err != nil { return nil, err } @@ -75,10 +94,10 @@ func verifyPerformance( Results: make([]api.VerifyPerformanceResult, 0, len(addresses)), } for i, address := range addresses { - if !batch[i].Active { + if !batch[i].Active && pubkeyErrs[i] == "" && batch[i].Err == nil { continue } - result := api.VerifyPerformanceResult{MinipoolAddress: address} + result := api.VerifyPerformanceResult{MinipoolAddress: address, NodeAddress: owners[i]} switch { case pubkeyErrs[i] != "": result.Error = pubkeyErrs[i] @@ -86,7 +105,7 @@ func verifyPerformance( result.Error = batch[i].Err.Error() default: result.Performance = batch[i].Response - result.Performance.Challengeable = challengeable && performance.ExceedsChallengeThreshold(result.Performance) + performance.SetChallengeability(result.Performance, challengeParams, head.Epoch) } response.Results = append(response.Results, result) } diff --git a/rocketpool/api/node/challenge-bonds.go b/rocketpool/api/node/challenge-bonds.go new file mode 100644 index 000000000..be0e4d687 --- /dev/null +++ b/rocketpool/api/node/challenge-bonds.go @@ -0,0 +1,161 @@ +package node + +import ( + "context" + "fmt" + "math/big" + "net/http" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/rocket-pool/smartnode/bindings/megapool" + rpnode "github.com/rocket-pool/smartnode/bindings/node" + "github.com/rocket-pool/smartnode/rocketpool/api/response" + "github.com/rocket-pool/smartnode/rocketpool/api/snroute" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/state" + "github.com/rocket-pool/smartnode/shared/types/api" + "github.com/urfave/cli/v3" +) + +func parseChallengeBondID(r *http.Request) (*big.Int, error) { + id, ok := new(big.Int).SetString(r.FormValue("challengeId"), 10) + if !ok || id.Sign() <= 0 || id.BitLen() > 256 { + return nil, &response.BadRequestError{Err: fmt.Errorf("challengeId must be a positive uint256")} + } + return id, nil +} + +// preflightChallengeBond is also called just before submission, even with a +// user-supplied gas limit, so already-settled bonds cannot bypass simulation. +func preflightChallengeBond(c *cli.Command, id *big.Int, claim bool, opts *bind.TransactOpts) (*api.CanSettleChallengeBondResponse, error) { + if err := services.RequireRocketStorage(c); err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + header, err := rp.Client.HeaderByNumber(context.Background(), nil) + if err != nil { + return nil, err + } + callOpts := &bind.CallOpts{BlockNumber: header.Number} + result := &api.CanSettleChallengeBondResponse{} + result.Saturn2Deployed, err = state.IsSaturn2Deployed(rp, callOpts) + if err != nil { + return nil, err + } + if !result.Saturn2Deployed { + result.Reason = "Challenge bond settlement is not available until Saturn 2 is deployed." + return result, nil + } + if claim { + if err := services.RequireNodeRegistered(c); err != nil { + return nil, err + } + } + status, err := megapool.GetPerformanceChallengeStatus(rp, id, callOpts) + if err != nil { + return nil, err + } + result.Proposer, result.Responder, result.BondAmount = status.Proposer, status.Responder, status.BondAmount + if status.BondSettled { + result.Reason = "The challenge bond has already been settled." + return result, nil + } + if claim && !status.CanClaimReward(opts.From) { + result.Reason = "Only the recorded defender can claim the reward of a defeated challenge." + return result, nil + } + if !claim && !status.CanReleaseBond(header.Time) { + result.Reason = "The challenge must be undefeated and its response deadline must have passed." + return result, nil + } + // Both contract methods are nonpayable. + opts.Value = nil + if claim { + stake, err := rpnode.GetNodeStakedRPL(rp, status.Proposer, callOpts) + if err != nil { + return nil, err + } + result.EstimatedReward, result.EstimatedBurn = challengeRewardAmounts(status.BondAmount, stake) + result.GasLimits, err = megapool.EstimateClaimChallengeRewardGas(rp, id, opts) + if err != nil { + return nil, err + } + } else { + result.GasLimits, err = megapool.EstimateReleaseChallengeBondGas(rp, id, opts) + } + if err != nil { + return nil, err + } + result.CanSettle = true + return result, nil +} + +func challengeRewardAmounts(bond, stake *big.Int) (*big.Int, *big.Int) { + recovered := new(big.Int).Set(bond) + if recovered.Cmp(stake) > 0 { + recovered.Set(stake) + } + burn := new(big.Int).Div(recovered, big.NewInt(5)) + return new(big.Int).Sub(recovered, burn), burn +} + +func canSettleChallengeBondHandler(claim bool) func(snroute.Context) { + return func(ctx snroute.Context) { + id, err := parseChallengeBondID(ctx.Request) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + w, err := services.GetWallet(ctx.Command()) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + opts, err := w.GetNodeAccountTransactor() + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + result, err := preflightChallengeBond(ctx.Command(), id, claim, opts) + response.WriteResponse(ctx.Writer, result, err) + } +} + +func settleChallengeBondHandler(claim bool) func(snroute.WriteContext) { + return func(ctx snroute.WriteContext) { + id, err := parseChallengeBondID(ctx.Request) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + transactor, err := ctx.Transactor() + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + preflight, err := preflightChallengeBond(ctx.Command(), id, claim, transactor.Opts()) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + if !preflight.CanSettle { + response.WriteErrorResponse(ctx.Writer, fmt.Errorf("%s", preflight.Reason)) + return + } + rp, err := services.GetRocketPool(ctx.Command()) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + result := &api.SettleChallengeBondResponse{} + if claim { + result.TxHash, err = megapool.ClaimChallengeReward(rp, id, transactor.Opts()) + } else { + result.TxHash, err = megapool.ReleaseChallengeBond(rp, id, transactor.Opts()) + } + response.WriteResponse(ctx.Writer, result, err) + } +} diff --git a/rocketpool/api/node/performance-challenges.go b/rocketpool/api/node/performance-challenges.go new file mode 100644 index 000000000..d6c623ae0 --- /dev/null +++ b/rocketpool/api/node/performance-challenges.go @@ -0,0 +1,198 @@ +package node + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/rocketpool/api/response" + "github.com/rocket-pool/smartnode/rocketpool/api/snroute" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/state" + "github.com/rocket-pool/smartnode/shared/types/api" + "github.com/urfave/cli/v3" +) + +func readPerformanceChallenges(c *cli.Command, id *big.Int) (*api.PerformanceChallengesResponse, error) { + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + header, err := rp.Client.HeaderByNumber(context.Background(), nil) + if err != nil { + return nil, err + } + opts := &bind.CallOpts{BlockNumber: header.Number} + result := &api.PerformanceChallengesResponse{} + result.Saturn2Deployed, err = state.IsSaturn2Deployed(rp, opts) + if err != nil { + return nil, err + } + if !result.Saturn2Deployed { + return result, nil + } + cfg, err := services.GetConfig(c) + if err != nil { + return nil, err + } + interval, err := cfg.GetEventLogInterval() + if err != nil { + return nil, err + } + challenges, err := megapool.GetPerformanceChallenges(rp, id, big.NewInt(int64(interval)), nil, header.Number, opts) + if err != nil { + return nil, err + } + for _, challenge := range challenges { + status, err := megapool.GetPerformanceChallengeStatus(rp, challenge.ChallengeId, opts) + if err != nil { + return nil, err + } + if id == nil && (status.Responded || status.Finalised) && status.BondSettled { + continue + } + result.Challenges = append(result.Challenges, api.PerformanceChallengeDetails{ + Challenge: challenge, + State: status, + }) + } + return result, nil +} + +func performanceChallengesHandler(ctx snroute.Context) { + var id *big.Int + var err error + if ctx.Request.FormValue("challengeId") != "" { + id, err = parseChallengeBondID(ctx.Request) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + } + result, err := readPerformanceChallenges(ctx.Command(), id) + response.WriteResponse(ctx.Writer, result, err) +} + +// Recheck eligibility and simulate on POST as well, including when the user +// supplies a gas limit. Finalisation and bond settlement are independent. +func preflightPerformanceChallenge(c *cli.Command, id *big.Int, defend bool, opts *bind.TransactOpts) (*api.CanActOnPerformanceChallengeResponse, megapool.PerformanceChallengeDefense, error) { + result := &api.CanActOnPerformanceChallengeResponse{} + var proof megapool.PerformanceChallengeDefense + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, proof, err + } + header, err := rp.Client.HeaderByNumber(context.Background(), nil) + if err != nil { + return nil, proof, err + } + callOpts := &bind.CallOpts{BlockNumber: header.Number} + deployed, err := state.IsSaturn2Deployed(rp, callOpts) + if err != nil { + return nil, proof, err + } + if !deployed { + result.Reason = "Performance challenges require Saturn 2." + return result, proof, nil + } + status, err := megapool.GetPerformanceChallengeStatus(rp, id, callOpts) + if err != nil { + return nil, proof, err + } + opts.Value = nil + if defend { + if err := services.RequireNodeRegistered(c); err != nil { + return nil, proof, err + } + if !status.CanDefend(opts.From, header.Time) { + result.Reason = "The challenge must be unresolved, within its response deadline, and proposed by another node." + return result, proof, nil + } + events, err := readPerformanceChallenges(c, id) + if err != nil { + return nil, proof, err + } + if len(events.Challenges) != 1 { + return nil, proof, fmt.Errorf("expected one event for challenge %s, found %d", id, len(events.Challenges)) + } + proof, err = services.BuildPerformanceChallengeDefense(c, events.Challenges[0].Challenge) + if err != nil { + return nil, proof, err + } + result.GasLimits, err = proof.EstimateGas(rp, opts) + if err != nil { + return nil, proof, err + } + } else { + if !status.CanFinalise(header.Time) { + result.Reason = "The challenge must be unresolved and past its response deadline." + return result, proof, nil + } + result.GasLimits, err = megapool.EstimateFinaliseChallengeGas(rp, id, opts) + if err != nil { + return nil, proof, err + } + } + result.CanAct = true + return result, proof, nil +} + +func canActOnPerformanceChallengeHandler(defend bool) func(snroute.Context) { + return func(ctx snroute.Context) { + id, err := parseChallengeBondID(ctx.Request) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + w, err := services.GetWallet(ctx.Command()) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + opts, err := w.GetNodeAccountTransactor() + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + result, _, err := preflightPerformanceChallenge(ctx.Command(), id, defend, opts) + response.WriteResponse(ctx.Writer, result, err) + } +} + +func actOnPerformanceChallengeHandler(defend bool) func(snroute.WriteContext) { + return func(ctx snroute.WriteContext) { + id, err := parseChallengeBondID(ctx.Request) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + transactor, err := ctx.Transactor() + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + preflight, proof, err := preflightPerformanceChallenge(ctx.Command(), id, defend, transactor.Opts()) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + if !preflight.CanAct { + response.WriteErrorResponse(ctx.Writer, fmt.Errorf("%s", preflight.Reason)) + return + } + rp, err := services.GetRocketPool(ctx.Command()) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + result := &api.PerformanceChallengeResponse{} + if defend { + result.TxHash, err = proof.Submit(rp, transactor.Opts()) + } else { + result.TxHash, err = megapool.FinaliseChallenge(rp, id, transactor.Opts()) + } + response.WriteResponse(ctx.Writer, result, err) + } +} diff --git a/rocketpool/api/node/routes.go b/rocketpool/api/node/routes.go index 8dc7ac41c..9cff403b9 100644 --- a/rocketpool/api/node/routes.go +++ b/rocketpool/api/node/routes.go @@ -13,6 +13,15 @@ import ( // RegisterRoutes registers the node module's HTTP routes onto router. func RegisterRoutes(router *snroute.Router) { + snroute.Read("/api/node/performance-challenges", performanceChallengesHandler).RegisterTo(router) + snroute.Read("/api/node/can-defend-performance-challenge", canActOnPerformanceChallengeHandler(true)).RegisterTo(router) + snroute.Write("/api/node/defend-performance-challenge", actOnPerformanceChallengeHandler(true)).RegisterTo(router) + snroute.Read("/api/node/can-finalise-performance-challenge", canActOnPerformanceChallengeHandler(false)).RegisterTo(router) + snroute.Write("/api/node/finalise-performance-challenge", actOnPerformanceChallengeHandler(false)).RegisterTo(router) + snroute.Read("/api/node/can-release-challenge-bond", canSettleChallengeBondHandler(false)).RegisterTo(router) + snroute.Write("/api/node/release-challenge-bond", settleChallengeBondHandler(false)).RegisterTo(router) + snroute.Read("/api/node/can-claim-challenge-reward", canSettleChallengeBondHandler(true)).RegisterTo(router) + snroute.Write("/api/node/claim-challenge-reward", settleChallengeBondHandler(true)).RegisterTo(router) snroute.Read("/api/node/status", statusHandler).RegisterTo(router) snroute.Read("/api/node/alerts", alertsHandler).RegisterTo(router) snroute.Read("/api/node/sync", syncHandler).RegisterTo(router) diff --git a/rocketpool/api/performance/challenges.go b/rocketpool/api/performance/challenges.go new file mode 100644 index 000000000..0421a6411 --- /dev/null +++ b/rocketpool/api/performance/challenges.go @@ -0,0 +1,129 @@ +package performance + +import ( + "fmt" + "math/big" + "net/http" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/rocketpool/api/response" + "github.com/rocket-pool/smartnode/rocketpool/api/snroute" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +func parseRequest(r *http.Request, minipools bool) (services.PerformanceChallengeRequest, error) { + var out services.PerformanceChallengeRequest + bad := func(err error) (services.PerformanceChallengeRequest, error) { + return out, &response.BadRequestError{Err: err} + } + var err error + out.StartEpoch, err = strconv.ParseUint(r.FormValue("startEpoch"), 10, 64) + if err != nil { + return bad(fmt.Errorf("invalid startEpoch: %w", err)) + } + if minipools { + for _, raw := range strings.Split(r.FormValue("minipoolAddresses"), ",") { + raw = strings.TrimSpace(raw) + if !common.IsHexAddress(raw) { + return bad(fmt.Errorf("invalid minipool address %q", raw)) + } + out.MinipoolAddresses = append(out.MinipoolAddresses, common.HexToAddress(raw)) + } + } else { + raw := strings.TrimSpace(r.FormValue("megapoolAddress")) + if raw != "" { + if !common.IsHexAddress(raw) { + return bad(fmt.Errorf("invalid megapool address")) + } + out.MegapoolAddress = common.HexToAddress(raw) + } + raw = r.FormValue("validatorIds") + if raw == "" { + raw = r.FormValue("validatorId") + } + for _, part := range strings.Split(raw, ",") { + id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32) + if err != nil { + return bad(fmt.Errorf("invalid validator ID %q", part)) + } + out.ValidatorIds = append(out.ValidatorIds, uint32(id)) + } + } + for _, part := range strings.Split(r.FormValue("participation"), ",") { + word, ok := new(big.Int).SetString(strings.TrimSpace(part), 10) + if !ok || word.Sign() < 0 || word.BitLen() > 256 { + return bad(fmt.Errorf("participation entries must be uint256 values")) + } + out.Participation = append(out.Participation, word) + } + if err := out.Validate(); err != nil { + return bad(err) + } + return out, nil +} + +func CanChallengeHandler(minipools bool) func(snroute.Context) { + return func(ctx snroute.Context) { + request, err := parseRequest(ctx.Request, minipools) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + w, err := services.GetWallet(ctx.Command()) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + opts, err := w.GetNodeAccountTransactor() + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + result, _, err := services.PreparePerformanceChallenge(ctx.Command(), request, opts) + response.WriteResponse(ctx.Writer, result, err) + } +} + +func ChallengeHandler(minipools bool) func(snroute.WriteContext) { + return func(ctx snroute.WriteContext) { + request, err := parseRequest(ctx.Request, minipools) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + maxBond, ok := new(big.Int).SetString(ctx.Request.FormValue("maxBond"), 10) + if !ok || maxBond.Sign() < 0 || maxBond.BitLen() > 256 { + response.WriteErrorResponse(ctx.Writer, &response.BadRequestError{Err: fmt.Errorf("maxBond must be the approved maximum bond in RPL wei")}) + return + } + tx, err := ctx.Transactor() + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + opts := tx.Opts() + result, prepared, err := services.PreparePerformanceChallenge(ctx.Command(), request, opts) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + if !result.CanChallenge { + response.WriteErrorResponse(ctx.Writer, fmt.Errorf("%s", result.Reason)) + return + } + if result.ChallengeBond.Cmp(maxBond) > 0 { + response.WriteErrorResponse(ctx.Writer, fmt.Errorf("challenge bond exceeds the approved maximum; estimate and confirm again")) + return + } + rp, err := services.GetRocketPool(ctx.Command()) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + hash, err := services.SubmitPerformanceChallenge(rp, prepared, opts) + response.WriteResponse(ctx.Writer, &api.ChallengeMegapoolPerformanceResponse{TxHash: hash}, err) + } +} diff --git a/rocketpool/api/performance/challenges_test.go b/rocketpool/api/performance/challenges_test.go new file mode 100644 index 000000000..7031e0752 --- /dev/null +++ b/rocketpool/api/performance/challenges_test.go @@ -0,0 +1,91 @@ +package performance + +import ( + "math/big" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestChallengeRequestParsing(t *testing.T) { + huge := new(big.Int).Lsh(big.NewInt(1), 255).String() + for _, test := range []struct { + name string + mini bool + values url.Values + valid bool + }{ + { + name: "megapool batch", + mini: false, + values: url.Values{"validatorIds": {"0,7,4294967295"}, "startEpoch": {"123"}, "participation": {huge + ",0"}}, + valid: true, + }, + { + name: "legacy scalar", + mini: false, + values: url.Values{"validatorId": {"0"}, "startEpoch": {"123"}, "participation": {"1"}}, + valid: true, + }, + { + name: "overflow id", + mini: false, + values: url.Values{"validatorIds": {"4294967296"}, "startEpoch": {"123"}, "participation": {"1"}}, + valid: false, + }, + { + name: "negative word", + mini: false, + values: url.Values{"validatorIds": {"0"}, "startEpoch": {"123"}, "participation": {"-1"}}, + valid: false, + }, + { + name: "overflow word", + mini: false, + values: url.Values{"validatorIds": {"0"}, "startEpoch": {"123"}, "participation": {new(big.Int).Lsh(big.NewInt(1), 256).String()}}, + valid: false, + }, + { + name: "duplicate id", + mini: false, + values: url.Values{"validatorIds": {"0,0"}, "startEpoch": {"123"}, "participation": {"1"}}, + valid: false, + }, + { + name: "missing epoch", + mini: false, + values: url.Values{"validatorIds": {"0"}, "participation": {"1"}}, + valid: false, + }, + { + name: "minipool list", + mini: true, + values: url.Values{"minipoolAddresses": {"0x0000000000000000000000000000000000000001,0x0000000000000000000000000000000000000002"}, "startEpoch": {"123"}, "participation": {"1"}}, + valid: true, + }, + { + name: "zero minipool", + mini: true, + values: url.Values{"minipoolAddresses": {"0x0000000000000000000000000000000000000000"}, "startEpoch": {"123"}, "participation": {"1"}}, + valid: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + for _, method := range []string{"GET", "POST"} { + request := httptest.NewRequest(method, "/?"+test.values.Encode(), nil) + if method == "POST" { + request = httptest.NewRequest(method, "/", strings.NewReader(test.values.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + got, err := parseRequest(request, test.mini) + if (err == nil) != test.valid { + t.Fatalf("%s: valid=%t, err=%v", method, test.valid, err) + } + if test.name == "megapool batch" && (len(got.ValidatorIds) != 3 || got.ValidatorIds[2] != ^uint32(0) || got.Participation[0].String() != huge) { + t.Fatalf("payload truncated: %+v", got) + } + } + }) + } +} diff --git a/rocketpool/node/check-megapool-exit-requests.go b/rocketpool/node/check-megapool-exit-requests.go index 682e2b2a3..f638047d3 100644 --- a/rocketpool/node/check-megapool-exit-requests.go +++ b/rocketpool/node/check-megapool-exit-requests.go @@ -1,6 +1,7 @@ package node import ( + "context" "fmt" "math/big" "strconv" @@ -28,6 +29,8 @@ import ( "github.com/rocket-pool/smartnode/shared/services/wallet" ) +const megapoolExitRetryInterval = 24 * time.Hour + // Check megapool exit requests task type checkMegapoolExitRequests struct { c *cli.Command @@ -156,7 +159,8 @@ func (t *checkMegapoolExitRequests) run(state *state.NetworkStateIndex) error { } toBlock := big.NewInt(int64(state.ElBlockNumber)) - exitRequests, err := network.GetMegapoolExitRequests(t.rp, t.intervalSize, fromBlock, toBlock, opts) + exitAddresses := t.cfg.Smartnode.GetPreviousRocketNetworkExitAddresses() + exitRequests, err := network.GetMegapoolExitRequests(t.rp, t.intervalSize, fromBlock, toBlock, exitAddresses, opts) if err != nil { return err } @@ -205,25 +209,75 @@ func (t *checkMegapoolExitRequests) run(state *state.NetworkStateIndex) error { continue } + // Enforcing other operators' exit requests is an opt-in duty. + if !t.cfg.Smartnode.EnableEnforcerTasks.Value.(bool) { + continue + } + mp, err := megapool.NewMegapool(t.rp, request.MegapoolAddress, opts) if err != nil { t.log.Printlnf("Error creating a binding for megapool %s: %s", request.MegapoolAddress.Hex(), err.Error()) continue } - // Skip requests still within the cooperative exit phase + if mp.GetVersion() < 2 { + continue + } + + validatorInfo, err := mp.GetValidatorInfo(request.ValidatorId, nil) + if err != nil { + t.log.Printlnf("Error getting megapool %s validator %d: %s", request.MegapoolAddress.Hex(), request.ValidatorId, err.Error()) + continue + } + if !validatorInfo.Staked || validatorInfo.Exited || validatorInfo.Dissolved { + continue + } + + // Use the current chain time and state when deciding whether to submit an exit. + header, err := t.rp.Client.HeaderByNumber(context.Background(), nil) + if err != nil { + t.log.Printlnf("Error getting the latest execution block: %s", err.Error()) + continue + } + now := time.Unix(int64(header.Time), 0) deadline := time.Unix(int64(request.RequestTimestamp), 0).Add(cooperativeExitPhase) - if time.Now().Before(deadline) { + if now.Before(deadline) { continue } - // Megapools from version 2 support a forced exit request - if mp.GetVersion() >= 2 { - t.log.Printlnf("Megapool %s (validator %d) uses version %d; submitting ForceExit", request.MegapoolAddress.Hex(), validatorIndex, mp.GetVersion()) - err := t.forceExitMegapoolValidator(request) + // Consensus ignores exit requests for validators that have not been active long enough. + head, err := t.bc.GetBeaconHead() + if err != nil { + t.log.Printlnf("Error getting the beacon head: %s", err.Error()) + continue + } + if status.ActivationEpoch > head.Epoch || head.Epoch-status.ActivationEpoch < state.BeaconConfig.ShardCommitteePeriod { + t.log.Printlnf("Validator %d has not been active long enough to exit; skipping EL exit submission.", validatorIndex) + continue + } + + action := "ForceExit" + if validatorInfo.Exiting { + // An exit submission from any enforcer will delay retries + lookback := uint64(megapoolExitRetryInterval / (time.Duration(state.BeaconConfig.SecondsPerSlot) * time.Second)) + from := new(big.Int).Sub(header.Number, new(big.Int).SetUint64(lookback)) + if from.Sign() < 0 { + from.SetUint64(0) + } + lastSubmission, err := network.GetLatestMegapoolExitSubmission(t.rp, request.MegapoolAddress, request.ValidatorId, t.intervalSize, from, header.Number) if err != nil { - t.log.Printlnf("Error force-exiting megapool %s validator %d: %s", request.MegapoolAddress.Hex(), request.ValidatorId, err.Error()) + t.log.Printlnf("Error checking megapool %s validator %d exit submissions: %s", request.MegapoolAddress.Hex(), request.ValidatorId, err.Error()) + continue } + if !lastSubmission.IsZero() && now.Before(lastSubmission.Add(megapoolExitRetryInterval)) { + t.log.Printlnf("Megapool %s validator %d exit request is still within the retry interval; skipping.", request.MegapoolAddress.Hex(), request.ValidatorId) + continue + } + action = "RetryExit" + } + t.log.Printlnf("Megapool %s (validator %d) uses version %d; submitting %s", request.MegapoolAddress.Hex(), validatorIndex, mp.GetVersion(), action) + if err := t.submitMegapoolExit(request, validatorInfo.Exiting); err != nil { + t.log.Printlnf("Error submitting %s for megapool %s validator %d: %s", action, request.MegapoolAddress.Hex(), request.ValidatorId, err.Error()) continue } } @@ -281,7 +335,15 @@ func (t *checkMegapoolExitRequests) exitOwnMegapoolValidator(state *state.Networ return nil } -func (t *checkMegapoolExitRequests) forceExitMegapoolValidator(request network.MegapoolExitRequest) error { +func (t *checkMegapoolExitRequests) submitMegapoolExit(request network.MegapoolExitRequest, retry bool) error { + estimate := network.EstimateForceMegapoolExitGas + submit := network.ForceMegapoolExit + action := "ForceExit" + if retry { + estimate = network.EstimateRetryMegapoolExitGas + submit = network.RetryMegapoolExit + action = "RetryExit" + } // Get transactor opts, err := t.w.GetNodeAccountTransactor() @@ -297,9 +359,9 @@ func (t *checkMegapoolExitRequests) forceExitMegapoolValidator(request network.M opts.Value = exitFee // Get the gas limit - gasInfo, err := network.EstimateForceMegapoolExitGas(t.rp, request.MegapoolAddress, request.ValidatorId, opts) + gasInfo, err := estimate(t.rp, request.MegapoolAddress, request.ValidatorId, opts) if err != nil { - return fmt.Errorf("could not estimate the gas required to force exit megapool %s validator %d: %w", request.MegapoolAddress.Hex(), request.ValidatorId, err) + return fmt.Errorf("could not estimate the gas required for %s on megapool %s validator %d: %w", action, request.MegapoolAddress.Hex(), request.ValidatorId, err) } var gas *big.Int if t.gasLimit != 0 { @@ -326,8 +388,8 @@ func (t *checkMegapoolExitRequests) forceExitMegapoolValidator(request network.M opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) opts.GasLimit = gas.Uint64() - // Force exit the validator via the megapool contract - hash, err := network.ForceMegapoolExit(t.rp, request.MegapoolAddress, request.ValidatorId, opts) + // Submit the EL exit request via the network exit contract + hash, err := submit(t.rp, request.MegapoolAddress, request.ValidatorId, opts) if err != nil { return err } @@ -339,7 +401,7 @@ func (t *checkMegapoolExitRequests) forceExitMegapoolValidator(request network.M } // Log - t.log.Printlnf("Successfully submitted ForceExit for megapool %s validator %d.", request.MegapoolAddress.Hex(), request.ValidatorId) + t.log.Printlnf("Successfully submitted %s for megapool %s validator %d.", action, request.MegapoolAddress.Hex(), request.ValidatorId) // Return return nil diff --git a/rocketpool/node/check-minipool-exit-requests.go b/rocketpool/node/check-minipool-exit-requests.go index 4d16ff02b..07d35bad9 100644 --- a/rocketpool/node/check-minipool-exit-requests.go +++ b/rocketpool/node/check-minipool-exit-requests.go @@ -166,7 +166,8 @@ func (t *checkMinipoolExitRequests) run(state *state.NetworkStateIndex) error { } toBlock := big.NewInt(int64(state.ElBlockNumber)) - exitRequests, err := network.GetMinipoolExitRequests(t.rp, t.intervalSize, fromBlock, toBlock, opts) + exitAddresses := t.cfg.Smartnode.GetPreviousRocketNetworkExitAddresses() + exitRequests, err := network.GetMinipoolExitRequests(t.rp, t.intervalSize, fromBlock, toBlock, exitAddresses, opts) if err != nil { return err } diff --git a/rocketpool/node/defend-challenge-exit.go b/rocketpool/node/defend-challenge-exit.go index 8940e1b26..8f3bb24a2 100644 --- a/rocketpool/node/defend-challenge-exit.go +++ b/rocketpool/node/defend-challenge-exit.go @@ -1,6 +1,7 @@ package node import ( + "fmt" "math/big" "github.com/docker/docker/client" @@ -135,7 +136,7 @@ func (t *defendChallengeExit) run(state *state.NetworkStateIndex) error { t.log.Printlnf("The validator %d was incorrectly challenged and needs a not-exiting proof", validatorInfo[i].ValidatorId) } - err := t.defendChallenge(t.rp, mp, validatorInfo[i].ValidatorId, state, types.ValidatorPubkey(validatorInfo[i].PubKey), exiting, opts) + err := t.defendChallenge(t.rp, mp, validatorInfo[i].ValidatorId, types.ValidatorPubkey(validatorInfo[i].PubKey), exiting) if err != nil { t.log.Printlnf("error defending the challenge: %v", err) } @@ -148,7 +149,7 @@ func (t *defendChallengeExit) run(state *state.NetworkStateIndex) error { } -func (t *defendChallengeExit) defendChallenge(rp *rocketpool.RocketPool, mp megapool.Megapool, validatorId uint32, state *state.NetworkStateIndex, validatorPubkey types.ValidatorPubkey, exiting bool, callopts *bind.CallOpts) error { +func (t *defendChallengeExit) defendChallenge(rp *rocketpool.RocketPool, mp megapool.Megapool, validatorId uint32, validatorPubkey types.ValidatorPubkey, exiting bool) error { // Get transactor opts, err := t.w.GetNodeAccountTransactor() @@ -158,11 +159,21 @@ func (t *defendChallengeExit) defendChallenge(rp *rocketpool.RocketPool, mp mega t.log.Printlnf("Crafting a validator proof.") - validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(t.c, 0, t.w, state.BeaconConfig, validatorPubkey, nil) + beaconState, slotTimestamp, err := services.GetHeadBeaconState(t.bc, rp.Client) + if err != nil { + return err + } + validatorProof, slotProof, err := services.GetValidatorProofFromState(t.bc, validatorPubkey, beaconState) if err != nil { t.log.Printlnf("[ERROR] There was an error during the proof creation process: %w", err) return err } + if exiting && validatorProof.Validator.WithdrawableEpoch >= FarFutureEpoch { + return fmt.Errorf("validator %d exit is not yet visible in the selected beacon state; will retry on next cycle", validatorId) + } + if !exiting && validatorProof.Validator.WithdrawableEpoch != FarFutureEpoch { + return fmt.Errorf("validator %d is exiting in the selected beacon state; will retry on next cycle", validatorId) + } t.log.Printlnf("The beacon state proof has been successfully created.") var gasLimits gaslimit.Limits diff --git a/rocketpool/node/defend-challenge-performance.go b/rocketpool/node/defend-challenge-performance.go index 04b1d8ddf..8a093bf82 100644 --- a/rocketpool/node/defend-challenge-performance.go +++ b/rocketpool/node/defend-challenge-performance.go @@ -1,9 +1,7 @@ package node import ( - "fmt" "math/big" - "strconv" "time" "github.com/docker/docker/client" @@ -13,9 +11,8 @@ import ( "github.com/rocket-pool/smartnode/bindings/megapool" "github.com/rocket-pool/smartnode/bindings/rocketpool" - "github.com/rocket-pool/smartnode/bindings/settings/protocol" "github.com/rocket-pool/smartnode/bindings/transactions" - "github.com/rocket-pool/smartnode/bindings/types" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" log "github.com/rocket-pool/smartnode/shared/logger" "github.com/rocket-pool/smartnode/shared/math" @@ -23,12 +20,11 @@ import ( "github.com/rocket-pool/smartnode/shared/services/beacon" "github.com/rocket-pool/smartnode/shared/services/config" rpgas "github.com/rocket-pool/smartnode/shared/services/gas" - "github.com/rocket-pool/smartnode/shared/services/performance" "github.com/rocket-pool/smartnode/shared/services/state" "github.com/rocket-pool/smartnode/shared/services/wallet" ) -// Stake megapool validator task +// Defend this node's validators and optionally enforce community challenges. type defendChallengePerformance struct { c *cli.Command log log.ColorLogger @@ -41,24 +37,21 @@ type defendChallengePerformance struct { maxFee *big.Int maxPriorityFee *big.Int gasLimit uint64 + intervalSize *big.Int + discovery performanceChallengeDiscovery + bondDiscovery challengeBondDiscovery } type megapoolPerformanceChallenge struct { - challengeId uint64 + challengeId *big.Int megapoolAddress common.Address - validatorId uint32 + nodeAddress common.Address + minipoolAddresses []common.Address + validatorIds []uint32 startEpoch uint64 participationCallData []*big.Int - challengeTimestamp time.Time -} - -// challengedValidator holds a challenged megapool validator's on-chain id -// alongside the beacon-chain identifiers needed to verify its target-vote -// participation. -type challengedValidator struct { - validatorId uint32 - pubkey types.ValidatorPubkey - index uint64 + responseDeadline *big.Int + proposer common.Address } // participationCallData word (a Solidity uint256). @@ -80,7 +73,7 @@ func (c *megapoolPerformanceChallenge) getChallengedEpochs() []uint64 { return challengedEpochs } -// Create stake megapool validator task +// Create the performance challenge defense task func newDefendChallengePerformance(c *cli.Command, logger log.ColorLogger) (*defendChallengePerformance, error) { // Get services @@ -126,6 +119,11 @@ func newDefendChallengePerformance(c *cli.Command, logger log.ColorLogger) (*def priorityFee = math.GweiToWei(priorityFeeGwei) } + eventLogInterval, err := cfg.GetEventLogInterval() + if err != nil { + return nil, err + } + // Return task return &defendChallengePerformance{ c: c, @@ -139,6 +137,7 @@ func newDefendChallengePerformance(c *cli.Command, logger log.ColorLogger) (*def maxFee: maxFee, maxPriorityFee: priorityFee, gasLimit: 0, + intervalSize: big.NewInt(int64(eventLogInterval)), }, nil } @@ -153,314 +152,156 @@ func (t *defendChallengePerformance) run(state *state.NetworkStateIndex) error { // Log t.log.Println("Checking for performance challenges ...") - // Get the latest state - opts := &bind.CallOpts{ - BlockNumber: big.NewInt(0).SetUint64(state.ElBlockNumber), - } - // Get node account nodeAccount, err := t.w.GetNodeAccount() if err != nil { return err } - - // Check if the megapool is deployed - deployed, err := megapool.GetMegapoolDeployed(t.rp, nodeAccount.Address, opts) - if err != nil { - return err - } - if !deployed { - return nil - } - - // Get the megapool address - megapoolAddress, err := megapool.GetMegapoolExpectedAddress(t.rp, nodeAccount.Address, opts) - if err != nil { - return err - } - - // Load the megapool - mp, err := megapool.NewMegaPoolV1(t.rp, megapoolAddress, nil) - if err != nil { - return err + // Settlement applies to challenges of either pool type, including those + // against other nodes. It must run even when this node has no megapool. + if err := t.settleChallengeBonds(nodeAccount.Address, state.ElBlockNumber); err != nil { + t.log.Printlnf("error checking performance challenge bonds: %v", err) } - // Get the performance challenge period - performanceChallengePeriod, err := protocol.GetPerformanceChallengePeriod(t.rp, opts) - if err != nil { - return err + ownMegapool := common.Address{} + if nodeDetails, ok := state.NodeDetailsByAddress[nodeAccount.Address]; ok && nodeDetails.MegapoolDeployed { + ownMegapool = nodeDetails.MegapoolAddress } - - // Get the performance measurement period (in epochs) - performancePeriod, err := protocol.GetPerformancePeriod(t.rp, opts) + challenges, timestamp, err := t.discovery.discover(onchainPerformanceChallenges{rp: t.rp, interval: t.intervalSize}, common.Address{}, state.ElBlockNumber) if err != nil { return err } - - // TODO: Fetch megapool challenges - - participationCallData := []*big.Int{new(big.Int).Sub( - new(big.Int).Lsh(big.NewInt(1), 200), - big.NewInt(1)), - } - // Use a megapool challenge stub for now - challenges := []megapoolPerformanceChallenge{ - { - challengeId: 0, - megapoolAddress: megapoolAddress, - validatorId: 0, - participationCallData: participationCallData, - startEpoch: 105000, - challengeTimestamp: time.Now(), - }, - } - + enforcer := t.cfg.Smartnode.EnableEnforcerTasks.Value.(bool) for _, challenge := range challenges { - - // Old challenges can be finalised - if time.Since(challenge.challengeTimestamp) > performanceChallengePeriod { - t.log.Printlnf("Challenge %d has been open for longer than the performance challenge period; finalising it.", challenge.challengeId) - if err := t.finaliseChallenge(challenge); err != nil { - t.log.Printlnf("error finalising performance challenge %d: %v", challenge.challengeId, err) - } + own := challenge.nodeAddress == nodeAccount.Address || (ownMegapool != (common.Address{}) && challenge.megapoolAddress == ownMegapool) + if !shouldHandlePerformanceChallenge(own, challenge.proposer == nodeAccount.Address, enforcer, challenge.expired(timestamp)) { continue } - - challengedEpochs := challenge.getChallengedEpochs() - t.log.Printlnf("Challenged epochs: %v", challengedEpochs) - - // Resolve the challenged validator's pubkey and beacon-chain index - pubkey, err := mp.GetValidatorPubkey(challenge.validatorId, opts) - if err != nil { - t.log.Printlnf("error getting pubkey for megapool validator %d: %v", challenge.validatorId, err) - continue + if len(challenge.minipoolAddresses) > 0 { + t.log.Printlnf("Challenge %s: node %s, minipools %v.", challenge.challengeId, challenge.nodeAddress, challenge.minipoolAddresses) + } else { + t.log.Printlnf("Challenge %s: megapool %s, validator IDs %v.", challenge.challengeId, challenge.megapoolAddress, challenge.validatorIds) } - beaconStatus, err := t.bc.GetValidatorStatus(pubkey, nil) - if err != nil { - t.log.Printlnf("error getting beacon status for megapool validator %d (%s): %v", challenge.validatorId, pubkey.Hex(), err) + t.log.Printlnf("Challenge %s: proposer %s, start epoch %d, %d claimed missed epochs, response deadline %s.", challenge.challengeId, challenge.proposer, challenge.startEpoch, len(challenge.getChallengedEpochs()), time.Unix(challenge.responseDeadline.Int64(), 0).UTC().Format(time.RFC3339)) + if challenge.expired(timestamp) { + t.log.Printlnf("Challenge %s: response deadline has passed; preparing finalisation.", challenge.challengeId) + if err := t.finaliseChallenge(challenge); err != nil { + t.log.Printlnf("error finalising challenge %s: %v", challenge.challengeId, err) + } continue } - if !beaconStatus.Exists || beaconStatus.Index == "" { - t.log.Printlnf("Megapool validator %d (%s) is not on the beacon chain yet, skipping.", challenge.validatorId, pubkey.Hex()) + if challenge.proposer == nodeAccount.Address { continue } - validatorIndex, err := strconv.ParseUint(beaconStatus.Index, 10, 64) + // Recheck live status before potentially expensive proofs; final submission + // is simulated again to catch responses mined before submission. + status, err := megapool.GetPerformanceChallengeStatus(t.rp, challenge.challengeId, nil) if err != nil { - t.log.Printlnf("error parsing beacon index %q for megapool validator %d: %v", beaconStatus.Index, challenge.validatorId, err) + t.log.Printlnf("error reading challenge %s: %v", challenge.challengeId, err) continue } - defender := challengedValidator{ - validatorId: challenge.validatorId, - pubkey: pubkey, - index: validatorIndex, + if !status.CanDefend(nodeAccount.Address, timestamp) { + t.log.Printlnf("Challenge %s: no longer eligible for defense; skipping.", challenge.challengeId) + continue } - - // Find an epoch in the challenged range where the validator made a - // timely target vote. A single (validator, epoch) proof is enough to - // defend the challenge - _, epoch, found, err := performance.FindFirstTimelyTargetVote(t.bc, state.BeaconConfig, []uint64{validatorIndex}, challengedEpochs) + t.log.Printlnf("Challenge %s: building a defense proof.", challenge.challengeId) + defense, err := services.BuildPerformanceChallengeDefense(t.c, challenge.binding()) if err != nil { - return fmt.Errorf("error verifying target-vote participation for challenged megapool validator %d: %w", challenge.validatorId, err) - } - if found { - t.log.Printlnf("Megapool validator %d made a timely target vote in epoch %d; defending the performance challenge.", defender.validatorId, epoch) - if err := t.defendChallenge(t.rp, challenge, defender, epoch); err != nil { - t.log.Printlnf("error defending performance challenge for megapool validator %d: %v", defender.validatorId, err) - } + t.log.Printlnf("challenge %s: %v", challenge.challengeId, err) continue } - - // No timely target vote found. If the validator was not staking for - // the entire challenge window, the challenge can still be defeated - // with a validator proof - if beaconStatus.ActivationEpoch > challenge.startEpoch || beaconStatus.WithdrawableEpoch <= challenge.startEpoch+performancePeriod { - t.log.Printlnf("Megapool validator %d was not staking during the challenge window; responding with a validator proof.", defender.validatorId) - if err := t.respondWithValidator(challenge, defender, state); err != nil { - t.log.Printlnf("error responding to performance challenge %d with a validator proof: %v", challenge.challengeId, err) - } - continue + if err := t.submitDefense(challenge, defense); err != nil { + t.log.Printlnf("error defending challenge %s: %v", challenge.challengeId, err) } - - t.log.Printlnf("No defense available for performance challenge %d: validator %d made no timely target vote in the challenged epochs and was staking during the challenge window.", challenge.challengeId, defender.validatorId) } - - // Return return nil - } -// finaliseChallenge settles a performance challenge that has been open for -// longer than the performance challenge period. -func (t *defendChallengePerformance) finaliseChallenge(challenge megapoolPerformanceChallenge) error { - - // Get transactor - opts, err := t.w.GetNodeAccountTransactor() - if err != nil { - return err - } - - // Get the gas limit - gasInfo, err := megapool.EstimateFinaliseChallengeGas(t.rp, challenge.challengeId, opts) - if err != nil { - return fmt.Errorf("could not estimate the gas required to finalise challenge %d: %w", challenge.challengeId, err) - } - gas := big.NewInt(int64(gasInfo.Safe)) - - // Get the max fee - maxFee := t.maxFee - if maxFee == nil || maxFee.Uint64() == 0 { - maxFee, err = rpgas.GetHeadlessMaxFeeWeiWithLatestBlock(t.cfg, t.rp) - if err != nil { - return err - } - } - - // Print the gas info - if !gasInfo.PrintAndCheck(true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { - return nil - } - - opts.GasFeeCap = maxFee - opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) - opts.GasLimit = gas.Uint64() - - // Finalise the challenge - txHash, err := megapool.FinaliseChallenge(t.rp, challenge.challengeId, opts) - if err != nil { - return err +func shouldHandlePerformanceChallenge(own, proposed, enforcer, expired bool) bool { + if expired { + return own || proposed || enforcer } + return !proposed && (own || enforcer) +} - // Print TX info and wait for it to be included in a block - err = transactions.PrintAndWaitForTransaction(t.cfg, txHash, t.rp.Client, &t.log) - if err != nil { - return err +func (c megapoolPerformanceChallenge) binding() megapool.PerformanceChallenge { + return megapool.PerformanceChallenge{ + ChallengeId: c.challengeId, + MegapoolAddress: c.megapoolAddress, + NodeAddress: c.nodeAddress, + MinipoolAddresses: c.minipoolAddresses, + ValidatorIds: c.validatorIds, + StartEpoch: c.startEpoch, + Participation: c.participationCallData, } - - // Log - t.log.Printlnf("Successfully finalised performance challenge %d.", challenge.challengeId) - - // Return - return nil } -// respondWithValidator responds to a performance challenge with a validator -// proof showing the defender was not staking during the challenge window. -func (t *defendChallengePerformance) respondWithValidator(challenge megapoolPerformanceChallenge, defender challengedValidator, state *state.NetworkStateIndex) error { - - // Get transactor +// finaliseChallenge requests exits after the challenge's stored response deadline. +func (t *defendChallengePerformance) finaliseChallenge(challenge megapoolPerformanceChallenge) error { opts, err := t.w.GetNodeAccountTransactor() if err != nil { return err } - - t.log.Printlnf("Creating a validator proof for megapool validator %d.", defender.validatorId) - - // Build a fresh validator proof against the head state to satisfy the - // contract's slot recency requirement - validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(t.c, 0, t.w, state.BeaconConfig, defender.pubkey, nil) - if err != nil { - return fmt.Errorf("error creating the validator proof: %w", err) - } - - gasInfo, err := megapool.EstimateRespondWithValidatorGas(t.rp, challenge.challengeId, slotTimestamp, validatorProof, slotProof, opts) - if err != nil { - return err - } - - gas := big.NewInt(int64(gasInfo.Safe)) - // Get the max fee - maxFee := t.maxFee - if maxFee == nil || maxFee.Uint64() == 0 { - maxFee, err = rpgas.GetHeadlessMaxFeeWeiWithLatestBlock(t.cfg, t.rp) - if err != nil { - return err - } - } - - // Print the gas info - if !gasInfo.PrintAndCheck(true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { - return nil - } - - opts.GasFeeCap = maxFee - opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) - opts.GasLimit = gas.Uint64() - - t.log.Printlnf("Responding to challenge %d with a validator proof for validator %d.", challenge.challengeId, defender.validatorId) - txHash, err := megapool.RespondWithValidator(t.rp, challenge.challengeId, slotTimestamp, validatorProof, slotProof, opts) - if err != nil { - return err - } - - // Print TX info and wait for it to be included in a block - err = transactions.PrintAndWaitForTransaction(t.cfg, txHash, t.rp.Client, &t.log) - if err != nil { - return err - } - - // Log - t.log.Printlnf("Successfully responded to the performance challenge for validator %d.", defender.validatorId) - - // Return - return nil + return t.submitChallengeTransaction(challenge.challengeId, "finalisation", opts, func() (gaslimit.Limits, error) { + return megapool.EstimateFinaliseChallengeGas(t.rp, challenge.challengeId, opts) + }, func() (common.Hash, error) { + return megapool.FinaliseChallenge(t.rp, challenge.challengeId, opts) + }) } -// defendChallenge responds to a performance challenge with a participation -// proof of the defender's timely target vote in challengeEpoch. -func (t *defendChallengePerformance) defendChallenge(rp *rocketpool.RocketPool, challenge megapoolPerformanceChallenge, defender challengedValidator, challengeEpoch uint64) error { - - // Get transactor +func (t *defendChallengePerformance) submitDefense(challenge megapoolPerformanceChallenge, defense megapool.PerformanceChallengeDefense) error { opts, err := t.w.GetNodeAccountTransactor() if err != nil { return err } - - t.log.Printlnf("Creating a participation proof that validator index %d made a timely target vote in epoch %d.", defender.index, challengeEpoch) - - proofs, err := services.GetParticipationProof(t.c, defender.index, defender.pubkey, challengeEpoch, challenge.startEpoch, challenge.participationCallData) - if err != nil { - return fmt.Errorf("error creating the participation proof: %w", err) + if defense.MinipoolAddress != (common.Address{}) { + t.log.Printlnf("Challenge %s: using minipool %s, beacon validator index %s.", defense.ChallengeId, defense.MinipoolAddress, defense.Validator.ValidatorIndex) + } else { + t.log.Printlnf("Challenge %s: using megapool validator %d, beacon validator index %s.", defense.ChallengeId, defense.ValidatorId, defense.Validator.ValidatorIndex) + } + if defense.Participation != nil { + t.log.Printlnf("Challenge %s: prepared a timely target participation proof for epoch %d, anchored at beacon slot %d, to defeat the entire challenge.", defense.ChallengeId, challenge.startEpoch+defense.Offset, defense.Slot.Slot) + } else { + t.log.Printlnf("Challenge %s: prepared an activation proof with activation epoch %d after start epoch %d, anchored at beacon slot %d, to defeat the entire challenge.", defense.ChallengeId, defense.Validator.Validator.ActivationEpoch, challenge.startEpoch, defense.Slot.Slot) } + return t.submitChallengeTransaction(defense.ChallengeId, "defense", opts, func() (gaslimit.Limits, error) { + return defense.EstimateGas(t.rp, opts) + }, func() (common.Hash, error) { + return defense.Submit(t.rp, opts) + }) +} - gasInfo, err := megapool.EstimateRespondWithParticipationGas(rp, challenge.challengeId, proofs.Offset, proofs.ChallengeLeaf, proofs.ChallengeWitness, proofs.SlotTimestamp, proofs.Validator, proofs.Participation, proofs.Slot, opts) +func (t *defendChallengePerformance) submitChallengeTransaction(id *big.Int, action string, opts *bind.TransactOpts, estimate func() (gaslimit.Limits, error), submit func() (common.Hash, error)) error { + opts.Value = nil + limits, err := estimate() if err != nil { return err } - - gas := big.NewInt(int64(gasInfo.Safe)) - // Get the max fee maxFee := t.maxFee - if maxFee == nil || maxFee.Uint64() == 0 { + if maxFee == nil || maxFee.Sign() == 0 { maxFee, err = rpgas.GetHeadlessMaxFeeWeiWithLatestBlock(t.cfg, t.rp) if err != nil { return err } } - - // Print the gas info - if !gasInfo.PrintAndCheck(true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + if !limits.PrintAndCheck(true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + t.log.Printlnf("Challenge %s: deferring %s because the gas price exceeds the configured threshold.", id, action) return nil } - opts.GasFeeCap = maxFee opts.GasTipCap = GetPriorityFee(t.maxPriorityFee, maxFee) - opts.GasLimit = gas.Uint64() - - t.log.Printlnf("Responding to challenge %d with the timely target vote of validator %d in epoch %d.", challenge.challengeId, defender.validatorId, challengeEpoch) - txHash, err := megapool.RespondWithParticipation(rp, challenge.challengeId, proofs.Offset, proofs.ChallengeLeaf, proofs.ChallengeWitness, proofs.SlotTimestamp, proofs.Validator, proofs.Participation, proofs.Slot, opts) + opts.GasLimit = limits.Safe + if t.gasLimit != 0 { + opts.GasLimit = t.gasLimit + } + t.log.Printlnf("Challenge %s: submitting %s.", id, action) + hash, err := submit() if err != nil { return err } - - // Print TX info and wait for it to be included in a block - err = transactions.PrintAndWaitForTransaction(t.cfg, txHash, t.rp.Client, &t.log) - if err != nil { + if err := transactions.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log); err != nil { return err } - - // Log - t.log.Printlnf("Successfully responded to the performance challenge for validator %d.", defender.validatorId) - - // Return + t.log.Printlnf("Challenge %s: successfully completed %s.", id, action) return nil } diff --git a/rocketpool/node/notify-final-balance.go b/rocketpool/node/notify-final-balance.go index 7fe301c39..58fdda568 100644 --- a/rocketpool/node/notify-final-balance.go +++ b/rocketpool/node/notify-final-balance.go @@ -6,7 +6,6 @@ import ( "strconv" "github.com/docker/docker/client" - "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/urfave/cli/v3" "github.com/rocket-pool/smartnode/bindings/megapool" @@ -92,11 +91,6 @@ func (t *notifyFinalBalance) run(state *state.NetworkStateIndex) error { // Log t.log.Println("Checking if there are megapool validators with a final balance withdrawn...") - // Get the latest state - opts := &bind.CallOpts{ - BlockNumber: big.NewInt(0).SetUint64(state.ElBlockNumber), - } - // Get node account nodeAccount, err := t.w.GetNodeAccount() if err != nil { @@ -147,25 +141,24 @@ func (t *notifyFinalBalance) run(state *state.NetworkStateIndex) error { if err != nil { return fmt.Errorf("error getting beacon head: %w", err) } - finalizedEpoch := head.FinalizedEpoch candidatePubkeys := make([]types.ValidatorPubkey, len(candidates)) for i, candidate := range candidates { candidatePubkeys[i] = candidate.pubkey } - finalizedStatuses, err := t.bc.GetValidatorStatuses(candidatePubkeys, &beacon.ValidatorStatusOptions{Epoch: &finalizedEpoch}) + statuses, err := t.bc.GetValidatorStatuses(candidatePubkeys, nil) if err != nil { - return fmt.Errorf("error getting finalized validator statuses: %w", err) + return fmt.Errorf("error getting validator statuses at beacon head: %w", err) } for _, candidate := range candidates { - finalizedStatus := finalizedStatuses[candidate.pubkey] - if !beacon.HasFinalBalanceWithdrawal(finalizedStatus) { - t.log.Printlnf("Validator id %d is not ready for a final balance proof on the finalized beacon state (epoch %d): %s. Will retry on next cycle.", candidate.id, finalizedEpoch, finalBalancePendingReason(finalizedStatus, finalizedEpoch)) + status := statuses[candidate.pubkey] + if !beacon.HasFinalBalanceWithdrawal(status) { + t.log.Printlnf("Validator id %d is not ready for a final balance proof at beacon head (epoch %d): %s. Will retry on next cycle.", candidate.id, head.Epoch, finalBalancePendingReason(status, head.Epoch)) continue } t.log.Printlnf("The validator id %d needs a final balance proof", candidate.id) - err := t.createFinalBalanceProof(t.rp, mp, state, candidate.id, finalizedStatus, opts) + err := t.createFinalBalanceProof(t.rp, mp, state, candidate.id, status) if err != nil { t.log.Printlnf("Error creating final balance proof for validator %d: %s", candidate.id, err) } @@ -176,7 +169,7 @@ func (t *notifyFinalBalance) run(state *state.NetworkStateIndex) error { } -func (t *notifyFinalBalance) createFinalBalanceProof(rp *rocketpool.RocketPool, mp megapool.Megapool, state *state.NetworkStateIndex, validatorId uint32, validatorDetails beacon.ValidatorStatus, callopts *bind.CallOpts) error { +func (t *notifyFinalBalance) createFinalBalanceProof(rp *rocketpool.RocketPool, mp megapool.Megapool, state *state.NetworkStateIndex, validatorId uint32, validatorDetails beacon.ValidatorStatus) error { // Get transactor opts, err := t.w.GetNodeAccountTransactor() @@ -197,7 +190,7 @@ func (t *notifyFinalBalance) createFinalBalanceProof(rp *rocketpool.RocketPool, } slot := validatorDetails.WithdrawableEpoch * slotsPerEpoch - proof, err := services.BuildMegapoolFinalBalanceProof(t.c, rp, mp.GetAddress(), slot, validatorIndex, validatorDetails.Pubkey, t.w) + proof, err := services.BuildMegapoolFinalBalanceProof(t.c, rp, slot, validatorIndex, validatorDetails.Pubkey) if err != nil { return fmt.Errorf("error getting withdrawal proof for validator 0x%s (index: %d): %w", validatorDetails.Pubkey.String(), validatorIndex, err) } @@ -250,11 +243,11 @@ func (t *notifyFinalBalance) createFinalBalanceProof(rp *rocketpool.RocketPool, func finalBalancePendingReason(status beacon.ValidatorStatus, currentEpoch uint64) string { if !status.Exists { - return "validator not yet included in the finalized beacon state" + return "validator not yet included in the beacon head" } withdrawableEpoch := status.WithdrawableEpoch if withdrawableEpoch == 0 || withdrawableEpoch == beacon.FarFutureEpoch { - return "withdrawable epoch not yet set on the finalized beacon state" + return "withdrawable epoch not yet set on the beacon head" } if currentEpoch < withdrawableEpoch { return fmt.Sprintf("waiting for withdrawable_epoch %d", withdrawableEpoch) diff --git a/rocketpool/node/notify-validator-exit.go b/rocketpool/node/notify-validator-exit.go index 90ce7d6e7..cc304f9f7 100644 --- a/rocketpool/node/notify-validator-exit.go +++ b/rocketpool/node/notify-validator-exit.go @@ -6,7 +6,6 @@ import ( "strconv" "github.com/docker/docker/client" - "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/urfave/cli/v3" "github.com/rocket-pool/smartnode/bindings/megapool" @@ -90,11 +89,6 @@ func (t *notifyValidatorExit) run(state *state.NetworkStateIndex) error { // Log t.log.Println("Checking if there are megapool validators exiting...") - // Get the latest state - opts := &bind.CallOpts{ - BlockNumber: big.NewInt(0).SetUint64(state.ElBlockNumber), - } - // Get node account nodeAccount, err := t.w.GetNodeAccount() if err != nil { @@ -148,11 +142,11 @@ func (t *notifyValidatorExit) run(state *state.NetworkStateIndex) error { return nil } - beaconState, err := services.GetBeaconState(t.bc) + beaconState, slotTimestamp, err := services.GetHeadBeaconState(t.bc, t.rp.Client) if err != nil { return err } - finalizedValidators := beaconState.GetValidators() + proofValidators := beaconState.GetValidators() for validatorId, validatorDetails := range validatorDetailsToProve { pubkey := types.ValidatorPubkey(validatorDetails.Pubkey) @@ -160,7 +154,7 @@ func (t *notifyValidatorExit) run(state *state.NetworkStateIndex) error { // Log t.log.Printlnf("The validator id %d needs an exit proof", validatorId) - // Check the exit is visible on the finalized state before proof generation + // Check the exit is visible on the selected state before proof generation validatorIndexStr, err := t.bc.GetValidatorIndex(pubkey) if err != nil { t.log.Printlnf("Error getting beacon index for validator id %d: %w", validatorId, err) @@ -171,17 +165,17 @@ func (t *notifyValidatorExit) run(state *state.NetworkStateIndex) error { t.log.Printlnf("Error parsing beacon index for validator id %d: %w", validatorId, err) continue } - if validatorIndex >= uint64(len(finalizedValidators)) { - t.log.Printlnf("Validator id %d (beacon index %d) is not yet included in the finalized beacon state. Will retry on next cycle.", validatorId, validatorIndex) + if validatorIndex >= uint64(len(proofValidators)) { + t.log.Printlnf("Validator id %d (beacon index %d) is not yet included in the selected beacon state. Will retry on next cycle.", validatorId, validatorIndex) continue } - if finalizedValidators[validatorIndex].WithdrawableEpoch >= FarFutureEpoch { - t.log.Printlnf("Validator id %d (beacon index %d) exit is not yet reflected in the finalized beacon state (withdrawable_epoch still FAR_FUTURE). Will retry on next cycle.", validatorId, validatorIndex) + if proofValidators[validatorIndex].WithdrawableEpoch >= FarFutureEpoch { + t.log.Printlnf("Validator id %d (beacon index %d) exit is not yet reflected in the selected beacon state (withdrawable_epoch still FAR_FUTURE). Will retry on next cycle.", validatorId, validatorIndex) continue } // Call Notify Exit - err = t.createExitProof(t.rp, beaconState, mp, validatorId, state, pubkey, opts) + err = t.createExitProof(t.rp, beaconState, slotTimestamp, mp, validatorId, pubkey) // dont return if there was an error, just log it so we can continue with the next validator if err != nil { t.log.Printlnf("Error creating exit proof for validator %d: %w", validatorId, err) @@ -192,7 +186,7 @@ func (t *notifyValidatorExit) run(state *state.NetworkStateIndex) error { } -func (t *notifyValidatorExit) createExitProof(rp *rocketpool.RocketPool, beaconState eth2.BeaconState, mp megapool.Megapool, validatorId uint32, state *state.NetworkStateIndex, validatorPubkey types.ValidatorPubkey, callopts *bind.CallOpts) error { +func (t *notifyValidatorExit) createExitProof(rp *rocketpool.RocketPool, beaconState eth2.BeaconState, slotTimestamp uint64, mp megapool.Megapool, validatorId uint32, validatorPubkey types.ValidatorPubkey) error { // Get transactor opts, err := t.w.GetNodeAccountTransactor() @@ -202,7 +196,7 @@ func (t *notifyValidatorExit) createExitProof(rp *rocketpool.RocketPool, beaconS t.log.Printlnf("Crafting an exit proof.") - validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(t.c, 0, t.w, state.BeaconConfig, validatorPubkey, beaconState) + validatorProof, slotProof, err := services.GetValidatorProofFromState(t.bc, validatorPubkey, beaconState) if err != nil { t.log.Printlnf("[ERROR] There was an error during the proof creation process: %w", err) return err diff --git a/rocketpool/node/performance-challenge-discovery.go b/rocketpool/node/performance-challenge-discovery.go new file mode 100644 index 000000000..958196d27 --- /dev/null +++ b/rocketpool/node/performance-challenge-discovery.go @@ -0,0 +1,110 @@ +package node + +import ( + "context" + "fmt" + "math/big" + + "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/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/rocketpool" +) + +type performanceChallengeSource interface { + header(uint64) (*types.Header, error) + events(common.Address, *big.Int, *big.Int) ([]megapool.PerformanceChallenge, error) + status(*big.Int, *big.Int) (megapool.PerformanceChallengeStatus, error) +} + +type onchainPerformanceChallenges struct { + rp *rocketpool.RocketPool + interval *big.Int +} + +func (s onchainPerformanceChallenges) header(block uint64) (*types.Header, error) { + return s.rp.Client.HeaderByNumber(context.Background(), new(big.Int).SetUint64(block)) +} + +func (s onchainPerformanceChallenges) events(address common.Address, from, to *big.Int) ([]megapool.PerformanceChallenge, error) { + return megapool.GetPerformanceChallenges(s.rp, nil, s.interval, from, to, &bind.CallOpts{BlockNumber: to}) +} + +func (s onchainPerformanceChallenges) status(id, block *big.Int) (megapool.PerformanceChallengeStatus, error) { + return megapool.GetPerformanceChallengeStatus(s.rp, id, &bind.CallOpts{BlockNumber: block}) +} + +// Retain unresolved challenges without a time cutoff. Restarting or detecting a +// reorg rebuilds the cache from historical events; ordinary runs scan new blocks. +type performanceChallengeDiscovery struct { + checkpoint *types.Header + address common.Address + unresolved []megapool.PerformanceChallenge +} + +func (d *performanceChallengeDiscovery) discover(source performanceChallengeSource, address common.Address, block uint64) ([]megapoolPerformanceChallenge, uint64, error) { + head, err := source.header(block) + if err != nil { + return nil, 0, err + } + var from *big.Int + var candidates []megapool.PerformanceChallenge + if d.checkpoint != nil && d.address == address && d.checkpoint.Number.Cmp(head.Number) <= 0 { + previous, err := source.header(d.checkpoint.Number.Uint64()) + if err != nil { + return nil, 0, err + } + if previous.Hash() == d.checkpoint.Hash() { + from = new(big.Int).Add(d.checkpoint.Number, big.NewInt(1)) + candidates = append(candidates, d.unresolved...) + } + } + if from == nil || from.Cmp(head.Number) <= 0 { + events, err := source.events(address, from, head.Number) + if err != nil { + return nil, 0, fmt.Errorf("error discovering performance challenges: %w", err) + } + candidates = append(candidates, events...) + } + active := make([]megapoolPerformanceChallenge, 0, len(candidates)) + unresolved := make([]megapool.PerformanceChallenge, 0, len(candidates)) + seen := make(map[string]bool) + for _, event := range candidates { + if (address != (common.Address{}) && event.MegapoolAddress != address && event.NodeAddress != address) || seen[event.ChallengeId.String()] { + continue + } + seen[event.ChallengeId.String()] = true + status, err := source.status(event.ChallengeId, head.Number) + if err != nil { + return nil, 0, err + } + if status.Responded || status.Finalised { + continue + } + unresolved = append(unresolved, event) + active = append(active, megapoolPerformanceChallenge{ + challengeId: event.ChallengeId, megapoolAddress: event.MegapoolAddress, + validatorIds: event.ValidatorIds, startEpoch: event.StartEpoch, + minipoolAddresses: event.MinipoolAddresses, nodeAddress: event.NodeAddress, + participationCallData: event.Participation, responseDeadline: status.ResponseDeadline, + proposer: status.Proposer, + }) + } + // Do not advance the cursor on a partial scan or a reorg during the reads. + confirmed, err := source.header(block) + if err != nil { + return nil, 0, err + } + if confirmed.Hash() != head.Hash() { + return nil, 0, fmt.Errorf("execution chain changed during performance challenge discovery") + } + d.checkpoint, d.address, d.unresolved = head, address, unresolved + return active, head.Time, nil +} + +func (c megapoolPerformanceChallenge) expired(timestamp uint64) bool { + // finaliseChallenge requires deadline < block.timestamp; a response is + // still valid at the exact deadline. + return c.responseDeadline.Cmp(new(big.Int).SetUint64(timestamp)) < 0 +} diff --git a/rocketpool/node/performance-challenge-discovery_test.go b/rocketpool/node/performance-challenge-discovery_test.go new file mode 100644 index 000000000..4646cdc07 --- /dev/null +++ b/rocketpool/node/performance-challenge-discovery_test.go @@ -0,0 +1,238 @@ +package node + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/rocket-pool/smartnode/bindings/megapool" +) + +type challengeDiscoverySource struct { + headers map[uint64]*types.Header + entries []megapool.PerformanceChallenge + statuses map[string]megapool.PerformanceChallengeStatus + ranges [][2]*big.Int + statusErr error + eventsErr error + statusAt *big.Int +} + +func (s *challengeDiscoverySource) header(block uint64) (*types.Header, error) { + return s.headers[block], nil +} +func (s *challengeDiscoverySource) events(_ common.Address, from, to *big.Int) ([]megapool.PerformanceChallenge, error) { + s.ranges = append(s.ranges, [2]*big.Int{from, to}) + return s.entries, s.eventsErr +} +func (s *challengeDiscoverySource) status(id, block *big.Int) (megapool.PerformanceChallengeStatus, error) { + s.statusAt = block + return s.statuses[id.String()], s.statusErr +} + +func discoveryFixture() (*challengeDiscoverySource, common.Address) { + address := common.HexToAddress("0x1234") + source := &challengeDiscoverySource{ + headers: map[uint64]*types.Header{ + 100: {Number: big.NewInt(100), Time: 10000}, + 110: {Number: big.NewInt(110), Time: 11000}, + }, + statuses: map[string]megapool.PerformanceChallengeStatus{}, + } + for _, id := range []int64{7, 8, 9} { + source.entries = append(source.entries, megapool.PerformanceChallenge{ + ChallengeId: big.NewInt(id), MegapoolAddress: address, ValidatorIds: []uint32{2, 6}, + StartEpoch: 42, Participation: []*big.Int{big.NewInt(5)}, + }) + source.statuses[big.NewInt(id).String()] = megapool.PerformanceChallengeStatus{ResponseDeadline: big.NewInt(10001)} + } + return source, address +} + +func TestDiscoverPerformanceChallenges(t *testing.T) { + source, address := discoveryFixture() + source.statuses["8"] = megapool.PerformanceChallengeStatus{Responded: true} + source.statuses["9"] = megapool.PerformanceChallengeStatus{Finalised: true} + // Duplicate events and another node's events must not create extra work. + source.entries = append(source.entries, source.entries[0], megapool.PerformanceChallenge{ + ChallengeId: big.NewInt(10), MegapoolAddress: common.HexToAddress("0x5678"), + }) + var discovery performanceChallengeDiscovery + active, timestamp, err := discovery.discover(source, address, 100) + if err != nil || len(active) != 1 || timestamp != 10000 { + t.Fatalf("got %v, %d, %v", active, timestamp, err) + } + if active[0].challengeId.Int64() != 7 || len(active[0].validatorIds) != 2 || active[0].startEpoch != 42 || active[0].participationCallData[0].Int64() != 5 { + t.Fatalf("challenge event data was not preserved: %+v", active[0]) + } + if source.ranges[0][0] != nil || source.ranges[0][1].Int64() != 100 || source.statusAt.Int64() != 100 { + t.Fatal("initial history and status must be read through the pinned block") + } + + // No new events: an old challenge is still returned after its deadline. + source.entries = nil + active, _, err = discovery.discover(source, address, 110) + if err != nil || len(active) != 1 || !active[0].expired(11000) || source.ranges[1][0].Int64() != 101 { + t.Fatalf("old unresolved challenge was lost: %v, %v", active, err) + } + // Repeating the same block rechecks status without scanning an invalid range. + source.statuses["7"] = megapool.PerformanceChallengeStatus{Finalised: true} + active, _, err = discovery.discover(source, address, 110) + if err != nil || len(active) != 0 || len(source.ranges) != 2 { + t.Fatalf("finalised challenge still active or unchanged block rescanned: %v, %v", active, err) + } +} + +func TestDiscoveryRetriesFailedScansAndRebuildsAfterReorg(t *testing.T) { + source, address := discoveryFixture() + var discovery performanceChallengeDiscovery + source.eventsErr = errors.New("log RPC failed") + if _, _, err := discovery.discover(source, address, 100); err == nil || discovery.checkpoint != nil { + t.Fatal("failed initial scan advanced cursor") + } + source.eventsErr = nil + if _, _, err := discovery.discover(source, address, 100); err != nil { + t.Fatal(err) + } + source.statusErr = errors.New("status RPC failed") + if _, _, err := discovery.discover(source, address, 110); err == nil || discovery.checkpoint.Number.Int64() != 100 { + t.Fatal("failed status lookup advanced cursor") + } + source.statusErr = nil + if _, _, err := discovery.discover(source, address, 110); err != nil { + t.Fatal(err) + } + if source.ranges[len(source.ranges)-1][0].Int64() != 101 { + t.Fatal("retry skipped unprocessed blocks") + } + // Orphaned challenges disappear, and previously resolved challenges can be + // rediscovered from the replacement chain instead of trusting the old cache. + source.headers[110] = &types.Header{Number: big.NewInt(110), Time: 11001, Extra: []byte("replacement")} + source.entries = source.entries[1:2] + active, _, err := discovery.discover(source, address, 110) + if err != nil || len(active) != 1 || active[0].challengeId.Int64() != 8 || source.ranges[len(source.ranges)-1][0] != nil { + t.Fatalf("reorg did not rebuild discovery: %v, %v", active, err) + } +} + +func TestDiscoveryWithoutChallenges(t *testing.T) { + source, address := discoveryFixture() + source.entries = nil + var discovery performanceChallengeDiscovery + active, _, err := discovery.discover(source, address, 100) + if err != nil || len(active) != 0 { + t.Fatalf("empty history generated challenge work: %v, %v", active, err) + } +} + +func TestChallengeDeadlineBoundary(t *testing.T) { + challenge := megapoolPerformanceChallenge{responseDeadline: big.NewInt(100)} + for _, timestamp := range []uint64{99, 100, 101} { + if challenge.expired(timestamp) != (timestamp > 100) { + t.Errorf("wrong finalisation eligibility at %d", timestamp) + } + } +} + +func TestDiscoverMinipoolsWithoutMegapool(t *testing.T) { + source, _ := discoveryFixture() + owner, member := common.Address{1}, common.Address{2} + source.entries = []megapool.PerformanceChallenge{ + { + ChallengeId: big.NewInt(7), + NodeAddress: owner, + MinipoolAddresses: []common.Address{member}, + StartEpoch: 42, + Participation: []*big.Int{big.NewInt(5)}, + }, + } + var discovery performanceChallengeDiscovery + got, _, err := discovery.discover(source, common.Address{}, 100) + if err != nil || len(got) != 1 { + t.Fatalf("minipool discovery failed: %v, %v", got, err) + } + binding := got[0].binding() + if binding.NodeAddress != owner || len(binding.MinipoolAddresses) != 1 || binding.MinipoolAddresses[0] != member { + t.Fatalf("lost minipool membership: %+v", binding) + } +} + +func TestPerformanceChallengeTaskScope(t *testing.T) { + for _, test := range []struct { + name string + own, proposed, enforcer, expired, want bool + }{ + { + name: "always defend own validators", + own: true, + proposed: false, + enforcer: false, + expired: false, + want: true, + }, + { + name: "do not defend own proposal", + own: true, + proposed: true, + enforcer: true, + expired: false, + want: false, + }, + { + name: "third-party defense opt out", + own: false, + proposed: false, + enforcer: false, + expired: false, + want: false, + }, + { + name: "third-party defense opt in", + own: false, + proposed: false, + enforcer: true, + expired: false, + want: true, + }, + { + name: "finalise own proposal", + own: false, + proposed: true, + enforcer: false, + expired: true, + want: true, + }, + { + name: "finalise own validators", + own: true, + proposed: false, + enforcer: false, + expired: true, + want: true, + }, + { + name: "third-party finalisation opt out", + own: false, + proposed: false, + enforcer: false, + expired: true, + want: false, + }, + { + name: "third-party finalisation opt in", + own: false, + proposed: false, + enforcer: true, + expired: true, + want: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + if got := shouldHandlePerformanceChallenge(test.own, test.proposed, test.enforcer, test.expired); got != test.want { + t.Fatalf("got %t, want %t", got, test.want) + } + }) + } +} diff --git a/rocketpool/node/settle-challenge-bonds.go b/rocketpool/node/settle-challenge-bonds.go new file mode 100644 index 000000000..79a6bf9ca --- /dev/null +++ b/rocketpool/node/settle-challenge-bonds.go @@ -0,0 +1,154 @@ +package node + +import ( + "fmt" + "math/big" + + "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/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/transactions" + rpgas "github.com/rocket-pool/smartnode/shared/services/gas" +) + +type challengeBondSource interface { + header(uint64) (*types.Header, error) + bondEvents(common.Address, *big.Int, *big.Int) ([]*big.Int, error) + status(*big.Int, *big.Int) (megapool.PerformanceChallengeStatus, error) +} + +func (s onchainPerformanceChallenges) bondEvents(node common.Address, from, to *big.Int) ([]*big.Int, error) { + return megapool.GetChallengeBondCandidates(s.rp, node, s.interval, from, to, &bind.CallOpts{BlockNumber: to}) +} + +type challengeBond struct { + id *big.Int + status megapool.PerformanceChallengeStatus +} + +type challengeBondDiscovery struct { + checkpoint *types.Header + node common.Address + pending []*big.Int +} + +// Bonds outlive challenge finalisation/defeat. Keep tracking them until settled +// and rebuild history on restart or reorg, without a deadline-based cutoff. +func (d *challengeBondDiscovery) discover(source challengeBondSource, node common.Address, block uint64) ([]challengeBond, uint64, error) { + head, err := source.header(block) + if err != nil { + return nil, 0, err + } + var from *big.Int + var candidates []*big.Int + if d.checkpoint != nil && d.node == node && d.checkpoint.Number.Cmp(head.Number) <= 0 { + previous, err := source.header(d.checkpoint.Number.Uint64()) + if err != nil { + return nil, 0, err + } + if previous.Hash() == d.checkpoint.Hash() { + from = new(big.Int).Add(d.checkpoint.Number, big.NewInt(1)) + candidates = append(candidates, d.pending...) + } + } + if from == nil || from.Cmp(head.Number) <= 0 { + ids, err := source.bondEvents(node, from, head.Number) + if err != nil { + return nil, 0, err + } + candidates = append(candidates, ids...) + } + seen := map[string]bool{} + pending := []*big.Int{} + bonds := []challengeBond{} + for _, id := range candidates { + if seen[id.String()] { + continue + } + seen[id.String()] = true + status, err := source.status(id, head.Number) + if err != nil { + return nil, 0, err + } + if !keepChallengeBond(status, node) { + continue + } + pending = append(pending, id) + bonds = append(bonds, challengeBond{id: id, status: status}) + } + confirmed, err := source.header(block) + if err != nil { + return nil, 0, err + } + if confirmed.Hash() != head.Hash() { + return nil, 0, fmt.Errorf("execution chain changed during challenge bond discovery") + } + d.checkpoint, d.node, d.pending = head, node, pending + return bonds, head.Time, nil +} + +func keepChallengeBond(status megapool.PerformanceChallengeStatus, node common.Address) bool { + if status.BondSettled { + return false + } + if status.Responded { + return status.Responder == node + } + return status.Proposer == node +} + +func (t *defendChallengePerformance) settleChallengeBonds(node common.Address, block uint64) error { + bonds, timestamp, err := t.bondDiscovery.discover(onchainPerformanceChallenges{rp: t.rp, interval: t.intervalSize}, node, block) + if err != nil { + return err + } + for _, bond := range bonds { + claim := bond.status.CanClaimReward(node) + if !claim && !bond.status.CanReleaseBond(timestamp) { + continue + } + if err := t.settleChallengeBond(bond.id, claim); err != nil { + t.log.Printlnf("error settling challenge %s bond: %v", bond.id, err) + } + } + return nil +} + +func (t *defendChallengePerformance) settleChallengeBond(id *big.Int, claim bool) error { + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + estimate := megapool.EstimateReleaseChallengeBondGas + submit := megapool.ReleaseChallengeBond + action := "release bond" + if claim { + estimate, submit, action = megapool.EstimateClaimChallengeRewardGas, megapool.ClaimChallengeReward, "claim defender reward as staked RPL" + } + gasInfo, err := estimate(t.rp, id, opts) + if err != nil { + return err + } + maxFee := t.maxFee + if maxFee == nil || maxFee.Sign() == 0 { + maxFee, err = rpgas.GetHeadlessMaxFeeWeiWithLatestBlock(t.cfg, t.rp) + if err != nil { + return err + } + } + if !gasInfo.PrintAndCheck(true, t.gasThreshold, &t.log, maxFee, t.gasLimit) { + return nil + } + opts.GasFeeCap, opts.GasTipCap, opts.GasLimit = maxFee, GetPriorityFee(t.maxPriorityFee, maxFee), gasInfo.Safe + t.log.Printlnf("Challenge %s: %s.", id, action) + hash, err := submit(t.rp, id, opts) + if err != nil { + return err + } + if err := transactions.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log); err != nil { + return err + } + t.log.Printlnf("Challenge %s: successfully completed %s.", id, action) + return nil +} diff --git a/rocketpool/node/stake-megapool-validator.go b/rocketpool/node/stake-megapool-validator.go index 6fe427791..040156f3a 100644 --- a/rocketpool/node/stake-megapool-validator.go +++ b/rocketpool/node/stake-megapool-validator.go @@ -141,7 +141,7 @@ func (t *stakeMegapoolValidator) run(state *state.NetworkStateIndex) error { } // Load the beacon state - beaconState, err := services.GetBeaconState(t.bc) + beaconState, slotTimestamp, err := services.GetHeadBeaconState(t.bc, t.rp.Client) if err != nil { return err } @@ -157,7 +157,7 @@ func (t *stakeMegapoolValidator) run(state *state.NetworkStateIndex) error { // Log t.log.Printlnf("The validator id %d needs to be staked", validatorId) - // Check if the validator is included in the finalized beacon state before attempting proof generation + // Check if the validator is included in the selected beacon state before attempting proof generation validatorIndexStr, err := t.bc.GetValidatorIndex(validatorPubkey) if err != nil { return err @@ -167,14 +167,14 @@ func (t *stakeMegapoolValidator) run(state *state.NetworkStateIndex) error { return err } if validatorIndex >= uint64(len(beaconState.GetValidators())) { - t.log.Printlnf("Validator id %d (beacon index %d) is not yet included in the finalized beacon state. Will retry on next cycle.", validatorId, validatorIndex) + t.log.Printlnf("Validator id %d (beacon index %d) is not yet included in the selected beacon state. Will retry on next cycle.", validatorId, validatorIndex) continue } // Call Stake - err = t.stakeValidator(t.rp, beaconState, mp, validatorId, state, validatorPubkey, opts) + err = t.stakeValidator(t.rp, beaconState, slotTimestamp, mp, validatorId, validatorPubkey) if err != nil { - t.log.Printlnf("Error staking validator %d: %w", validatorId, err) + t.log.Printlnf("Error staking validator %d: %v", validatorId, err) break } stakedValidators++ @@ -190,7 +190,7 @@ func (t *stakeMegapoolValidator) run(state *state.NetworkStateIndex) error { return nil } -func (t *stakeMegapoolValidator) stakeValidator(rp *rocketpool.RocketPool, beaconState eth2.BeaconState, mp megapool.Megapool, validatorId uint32, state *state.NetworkStateIndex, validatorPubkey types.ValidatorPubkey, callopts *bind.CallOpts) error { +func (t *stakeMegapoolValidator) stakeValidator(rp *rocketpool.RocketPool, beaconState eth2.BeaconState, slotTimestamp uint64, mp megapool.Megapool, validatorId uint32, validatorPubkey types.ValidatorPubkey) error { // Get transactor opts, err := t.w.GetNodeAccountTransactor() @@ -200,9 +200,9 @@ func (t *stakeMegapoolValidator) stakeValidator(rp *rocketpool.RocketPool, beaco t.log.Printlnf("Crafting a proof that the correct credentials were used on the first beacon chain deposit. This process can take several seconds and is CPU and memory intensive.") - validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(t.c, 0, t.w, state.BeaconConfig, validatorPubkey, beaconState) + validatorProof, slotProof, err := services.GetValidatorProofFromState(t.bc, validatorPubkey, beaconState) if err != nil { - t.log.Printlnf("There was an error during the proof creation process: %w", err) + t.log.Printlnf("There was an error during the proof creation process: %v", err) return err } @@ -211,7 +211,7 @@ func (t *stakeMegapoolValidator) stakeValidator(rp *rocketpool.RocketPool, beaco // Get the gas limit gasLimits, err := services.EstimateMegapoolStakeGas(rp, mp.GetAddress(), validatorId, slotTimestamp, validatorProof, slotProof, opts) if err != nil { - t.log.Printlnf("Could not estimate the gas required to stake megapool validator %d: %w", validatorId, err) + t.log.Printlnf("Could not estimate the gas required to stake megapool validator %d: %v", validatorId, err) return err } gas := big.NewInt(int64(gasLimits.Safe)) diff --git a/rocketpool/watchtower/dissolve-invalid-credentials.go b/rocketpool/watchtower/dissolve-invalid-credentials.go index 94e2fdb6b..d28af7078 100644 --- a/rocketpool/watchtower/dissolve-invalid-credentials.go +++ b/rocketpool/watchtower/dissolve-invalid-credentials.go @@ -155,13 +155,13 @@ func (t *dissolveInvalidCredentials) dissolveMegapoolValidator(validator megapoo return } - eth2Config, err := t.bc.GetEth2Config() + beaconState, slotTimestamp, err := services.GetHeadBeaconState(t.bc, t.rp.Client) if err != nil { - t.log.Printlnf("error getting the eth2 config: %v", err) + t.log.Printlnf("error getting the head beacon state: %v", err) return } - validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(t.c, 0, t.w, eth2Config, types.ValidatorPubkey(validator.Pubkey), nil) + validatorProof, slotProof, err := services.GetValidatorProofFromState(t.bc, types.ValidatorPubkey(validator.Pubkey), beaconState) if err != nil { t.log.Printlnf("error getting validator proof: %v", err) return diff --git a/shared/services/beacon/client.go b/shared/services/beacon/client.go index 4e6b8a374..183e75324 100644 --- a/shared/services/beacon/client.go +++ b/shared/services/beacon/client.go @@ -69,6 +69,7 @@ type BeaconBlock struct { type BeaconBlockHeader struct { Slot uint64 ProposerIndex string + StateRoot common.Hash // Root is the block root for this header. Used by RPIP-73 target-vote // verification to resolve the canonical target root at an epoch boundary. Root common.Hash diff --git a/shared/services/beacon/client/std-http-client.go b/shared/services/beacon/client/std-http-client.go index f501d841a..0f084beef 100644 --- a/shared/services/beacon/client/std-http-client.go +++ b/shared/services/beacon/client/std-http-client.go @@ -44,7 +44,7 @@ const ( RequestValidatorsPath = "/eth/v1/beacon/states/%s/validators" RequestValidatorBalancesPath = "/eth/v1/beacon/states/%s/validator_balances" RequestVoluntaryExitPath = "/eth/v1/beacon/pool/voluntary_exits" - RequestAttestationsPath = "/eth/v1/beacon/blocks/%s/attestations" + RequestAttestationsPath = "/eth/v2/beacon/blocks/%s/attestations" RequestBeaconBlockPath = "/eth/v2/beacon/blocks/%s" RequestBeaconBlockHeaderPath = "/eth/v1/beacon/headers/%s" RequestBeaconStatePath = "/eth/v2/debug/beacon/states/%d" @@ -751,6 +751,7 @@ func (c *StandardHttpClient) GetBeaconBlockHeader(blockId string) (beacon.Beacon Slot: uint64(block.Data.Header.Message.Slot), ProposerIndex: block.Data.Header.Message.ProposerIndex, Root: common.HexToHash(block.Data.Root), + StateRoot: common.HexToHash(block.Data.Header.Message.StateRoot), } return beaconBlock, true, nil } diff --git a/shared/services/beacon/client/types.go b/shared/services/beacon/client/types.go index cff7b0679..d399ad952 100644 --- a/shared/services/beacon/client/types.go +++ b/shared/services/beacon/client/types.go @@ -124,6 +124,7 @@ type BeaconBlockHeaderResponse struct { Message struct { Slot uinteger `json:"slot"` ProposerIndex string `json:"proposer_index"` + StateRoot string `json:"state_root"` } `json:"message"` } `json:"header"` } `json:"data"` diff --git a/shared/services/config/networks-config_test.go b/shared/services/config/networks-config_test.go index 4497d92b8..08b2f763f 100644 --- a/shared/services/config/networks-config_test.go +++ b/shared/services/config/networks-config_test.go @@ -3,11 +3,44 @@ package config import ( "os" "path/filepath" + "slices" "testing" + "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/shared/types/config" ) +func TestPreviousNetworkExitAddressesFromExtraNetwork(t *testing.T) { + dir := t.TempDir() + extra := []byte(` +version: 1 +networks: + - name: devnet + label: Devnet + description: Local exit upgrade test + chainID: 3151908 + beaconNetwork: hoodi + addresses: + storage: "0x1111111111111111111111111111111111111111" + previousRocketNetworkExit: + - "0x2222222222222222222222222222222222222222" + - "0x3333333333333333333333333333333333333333" +`) + if err := os.WriteFile(filepath.Join(dir, extraNetworksConfigPath), extra, 0644); err != nil { + t.Fatal(err) + } + cfg := mustNewRocketPoolConfig(t, dir, true) + cfg.ChangeNetwork(config.Network("devnet")) + want := []common.Address{ + common.HexToAddress("0x2222222222222222222222222222222222222222"), + common.HexToAddress("0x3333333333333333333333333333333333333333"), + } + if got := cfg.Smartnode.GetPreviousRocketNetworkExitAddresses(); !slices.Equal(got, want) { + t.Fatalf("previous exit addresses %v, want %v", got, want) + } +} + func TestLoadEmbeddedOfficialNetworks(t *testing.T) { networks, err := LoadNetworks("") if err != nil { diff --git a/shared/services/config/smartnode-config.go b/shared/services/config/smartnode-config.go index e14abd1f8..0968646d8 100644 --- a/shared/services/config/smartnode-config.go +++ b/shared/services/config/smartnode-config.go @@ -232,7 +232,7 @@ func NewSmartnodeConfig(cfg *RocketPoolConfig) *SmartnodeConfig { EnableEnforcerTasks: config.Parameter{ ID: "enableEnforcerTasks", Name: "Enable Enforcer tasks", - Description: "Check this box to opt into Enforcer duties. Your node will enforce other operators' minipool exit requests after the cooperative exit phase by submitting forced exits or proofs that legacy minipools did not exit. These tasks spend ETH from your node wallet on transaction fees and may require CPU and memory intensive proof generation.\n\nYour own validators will continue to exit voluntarily in response to exit requests when this is disabled.", + Description: "Check this box to opt into Enforcer duties. Your node will enforce other operators' minipool and megapool exit requests after the cooperative exit phase by submitting forced exits or proofs that legacy minipools did not exit. It will also defend and finalise performance challenges for other operators. These tasks spend ETH from your node wallet on transaction and exit request fees and may require CPU and memory intensive proof generation.\n\nYour own validators will continue to exit voluntarily in response to exit requests when this is disabled.", Type: config.ParameterType_Bool, Default: map[config.Network]interface{}{config.Network_All: false}, AffectsContainers: []config.ContainerID{config.ContainerID_Node}, @@ -664,6 +664,10 @@ func (cfg *SmartnodeConfig) GetPreviousRocketDAOProtocolVerifierAddresses() []co return hexAddresses(cfg.networkInfo().Addresses.PreviousDAOVerifiers) } +func (cfg *SmartnodeConfig) GetPreviousRocketNetworkExitAddresses() []common.Address { + return hexAddresses(cfg.networkInfo().Addresses.PreviousNetworkExits) +} + func (cfg *SmartnodeConfig) GetOptimismMessengerAddress() string { return cfg.networkInfo().Addresses.OptimismPriceMessenger } diff --git a/shared/services/final-balance-proofs_test.go b/shared/services/final-balance-proofs_test.go new file mode 100644 index 000000000..1330e3c6c --- /dev/null +++ b/shared/services/final-balance-proofs_test.go @@ -0,0 +1,80 @@ +package services + +import ( + "errors" + "math/big" + "slices" + "testing" + + "github.com/rocket-pool/smartnode/shared/services/beacon" +) + +func TestFinalBalanceProofVersionWithdrawalBoundary(t *testing.T) { + config := beacon.Eth2Config{GloasForkEpoch: 100, SlotsPerEpoch: 32} + for _, tt := range []struct { + name string + version int64 + withdrawalSlot uint64 + wantError bool + wantBoundary bool + }{ + {name: "v1 before Gloas", version: 1, withdrawalSlot: 3199}, + {name: "v1 at Gloas must switch to v2", version: 1, withdrawalSlot: 3200, wantError: true, wantBoundary: true}, + {name: "v1 after Gloas must switch to v2", version: 1, withdrawalSlot: 3201, wantError: true, wantBoundary: true}, + {name: "v2 before Gloas rejected", version: 2, withdrawalSlot: 3199, wantError: true}, + {name: "v2 at Gloas", version: 2, withdrawalSlot: 3200}, + {name: "v2 after Gloas", version: 2, withdrawalSlot: 3201}, + } { + t.Run(tt.name, func(t *testing.T) { + err := validateFinalBalanceProofVersion(big.NewInt(tt.version), tt.withdrawalSlot, config) + if (err != nil) != tt.wantError { + t.Fatalf("error = %v, want error = %t", err, tt.wantError) + } + if errors.Is(err, ErrGloasBoundaryReached) != tt.wantBoundary { + t.Fatalf("error = %v, want Gloas fallback = %t", err, tt.wantBoundary) + } + }) + } +} + +type finalBalanceBoundaryClient struct { + beacon.Client + requestedSlots []uint64 +} + +func (c *finalBalanceBoundaryClient) GetEth2Config() (beacon.Eth2Config, error) { + return beacon.Eth2Config{GloasForkEpoch: 100, SlotsPerEpoch: 32}, nil +} + +func (c *finalBalanceBoundaryClient) GetBeaconBlock(string) (beacon.BeaconBlock, bool, error) { + // Finality has advanced past Gloas, but the withdrawal hint may predate it. + return beacon.BeaconBlock{Slot: 3264, HasExecutionPayload: true}, true, nil +} + +func (c *finalBalanceBoundaryClient) GetBeaconBlockSSZ(slot uint64) (*beacon.BeaconBlockSSZ, bool, error) { + c.requestedSlots = append(c.requestedSlots, slot) + return nil, false, nil +} + +func TestFinalBalanceWithdrawalScanCrossesGloas(t *testing.T) { + for _, tt := range []struct { + name string + hint uint64 + wantSlots []uint64 + }{ + {name: "pre-Gloas hint crosses activation", hint: 3198, wantSlots: []uint64{3198, 3199}}, + {name: "hint at activation", hint: 3200}, + {name: "hint after activation", hint: 3201}, + } { + t.Run(tt.name, func(t *testing.T) { + bc := &finalBalanceBoundaryClient{} + _, _, _, _, _, err := FindWithdrawalBlockAndArrayPosition(tt.hint, 42, bc) + if !errors.Is(err, ErrGloasBoundaryReached) { + t.Fatalf("expected Gloas fallback, got %v", err) + } + if !slices.Equal(bc.requestedSlots, tt.wantSlots) { + t.Fatalf("requested slots = %v, want %v", bc.requestedSlots, tt.wantSlots) + } + }) + } +} diff --git a/shared/services/head-beacon-state.go b/shared/services/head-beacon-state.go new file mode 100644 index 000000000..d7024c006 --- /dev/null +++ b/shared/services/head-beacon-state.go @@ -0,0 +1,55 @@ +package services + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/types/eth2" + "github.com/rocket-pool/smartnode/shared/types/eth2/generic" +) + +// GetHeadBeaconState selects the latest beacon root already exposed by EIP-4788. +// The execution header's timestamp anchors proofs without waiting for finality +// or assuming that the next beacon slot has an execution payload. +func GetHeadBeaconState(bc beacon.Client, ec rocketpool.ExecutionClient) (eth2.BeaconState, uint64, error) { + header, err := ec.HeaderByNumber(context.Background(), nil) + if err != nil { + return nil, 0, fmt.Errorf("error getting execution head: %w", err) + } + if header == nil || header.ParentBeaconRoot == nil || *header.ParentBeaconRoot == (common.Hash{}) { + return nil, 0, fmt.Errorf("execution head has no parent beacon block root") + } + block, found, err := bc.GetBeaconBlockHeader(header.ParentBeaconRoot.Hex()) + if err != nil { + return nil, 0, fmt.Errorf("error getting head proof beacon block: %w", err) + } + if !found { + return nil, 0, fmt.Errorf("beacon block %s is not available", header.ParentBeaconRoot.Hex()) + } + if block.Root != *header.ParentBeaconRoot { + return nil, 0, fmt.Errorf("head proof beacon block root does not match execution head") + } + response, err := bc.GetBeaconStateSSZ(block.Slot) + if err != nil { + return nil, 0, fmt.Errorf("error getting head proof beacon state: %w", err) + } + beaconState, err := eth2.NewBeaconState(response.Data, response.Size, response.Fork) + if err != nil { + return nil, 0, err + } + if beaconState.GetSlot() != block.Slot { + return nil, 0, fmt.Errorf("head proof beacon state slot does not match beacon block") + } + stateRoot, err := generic.SSZ.HashTreeRoot(beaconState) + if err != nil { + return nil, 0, fmt.Errorf("error hashing head proof beacon state: %w", err) + } + if common.Hash(stateRoot) != block.StateRoot { + return nil, 0, fmt.Errorf("head proof beacon state root does not match beacon block; retry after beacon chain reorganisation") + } + return beaconState, header.Time, nil +} diff --git a/shared/services/head-beacon-state_test.go b/shared/services/head-beacon-state_test.go new file mode 100644 index 000000000..c8de82fa4 --- /dev/null +++ b/shared/services/head-beacon-state_test.go @@ -0,0 +1,134 @@ +package services + +import ( + "bytes" + "context" + "io" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + + "github.com/rocket-pool/smartnode/bindings/rocketpool" + rptypes "github.com/rocket-pool/smartnode/bindings/types" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/types/eth2/generic" +) + +type headProofExecutionClient struct { + rocketpool.ExecutionClient + header *ethtypes.Header +} + +func (c *headProofExecutionClient) HeaderByNumber(_ context.Context, number *big.Int) (*ethtypes.Header, error) { + if number != nil { + panic("head proof must use execution head") + } + return c.header, nil +} + +type headProofBeaconClient struct { + beacon.Client + header beacon.BeaconBlockHeader + state []byte + found bool + requestedRoot string + requestedSlot uint64 + index string +} + +func (c *headProofBeaconClient) GetBeaconBlockHeader(root string) (beacon.BeaconBlockHeader, bool, error) { + c.requestedRoot = root + return c.header, c.found, nil +} + +func (c *headProofBeaconClient) GetBeaconStateSSZ(slot uint64) (*beacon.BeaconStateSSZ, error) { + c.requestedSlot = slot + return &beacon.BeaconStateSSZ{Data: io.NopCloser(bytes.NewReader(c.state)), Size: int64(len(c.state)), Fork: "fulu"}, nil +} + +func (c *headProofBeaconClient) GetValidatorIndex(rptypes.ValidatorPubkey) (string, error) { + return c.index, nil +} + +func TestHeadStakeProofUsesExecutionAnchor(t *testing.T) { + // Slot 100 can be newer than finality and several slots behind beacon head. + // A timestamp from a later execution block also handles skipped payloads. + state := newProofTestFuluState(t, 1, 100) + root := common.BytesToHash(anchorBlockRoot(t, state)) + stateRoot, err := generic.SSZ.HashTreeRoot(state) + if err != nil { + t.Fatal(err) + } + data, err := generic.SSZ.MarshalSSZ(state) + if err != nil { + t.Fatal(err) + } + ec := &headProofExecutionClient{header: ðtypes.Header{ParentBeaconRoot: &root, Time: 2000}} + bc := &headProofBeaconClient{header: beacon.BeaconBlockHeader{Root: root, StateRoot: common.Hash(stateRoot), Slot: 100}, state: data, found: true, index: "0"} + anchor, timestamp, err := GetHeadBeaconState(bc, ec) + if err != nil { + t.Fatal(err) + } + if bc.requestedRoot != root.Hex() || bc.requestedSlot != 100 || timestamp != 2000 { + t.Fatalf("wrong anchor: root=%s slot=%d timestamp=%d", bc.requestedRoot, bc.requestedSlot, timestamp) + } + var pubkey rptypes.ValidatorPubkey + copy(pubkey[:], state.Validators[0].Pubkey) + validator, slot, err := GetValidatorProofFromState(bc, pubkey, anchor) + if err != nil { + t.Fatal(err) + } + if validator.ValidatorIndex.Uint64() != 0 || slot.Slot != 100 { + t.Fatal("staking proof did not preserve the selected state and timestamp") + } + // Restore the slot proof to the exact EIP-4788 block root. + var leaf [32]byte + leaf[0] = 100 + gindex := new(big.Int).SetUint64(fuluSlotProofGindex()) + witnesses := make([][]byte, len(slot.Witnesses)) + for i := range slot.Witnesses { + witnesses[i] = slot.Witnesses[i][:] + } + if !bytes.Equal(walkWitnessChain(t, leaf[:], gindex, witnesses), root[:]) { + t.Fatal("slot proof does not match execution head's parent beacon root") + } + bc.index = "1" + if _, _, err := GetValidatorProofFromState(bc, pubkey, anchor); err == nil || !strings.Contains(err.Error(), "index not found") { + t.Fatalf("expected missing validator to wait for the proof state, got %v", err) + } + bc.index = "0" + pubkey[0]++ + if _, _, err := GetValidatorProofFromState(bc, pubkey, anchor); err == nil || !strings.Contains(err.Error(), "pubkey does not match") { + t.Fatalf("expected changed validator identity to be rejected, got %v", err) + } + for _, tt := range []struct { + name string + header beacon.BeaconBlockHeader + found bool + want string + }{ + {name: "unavailable block", header: bc.header, found: false, want: "not available"}, + {name: "different block", header: beacon.BeaconBlockHeader{Root: common.HexToHash("0xff")}, found: true, want: "block root does not match"}, + {name: "state reorg", header: beacon.BeaconBlockHeader{Root: root, StateRoot: common.HexToHash("0xff"), Slot: 100}, found: true, want: "state root does not match"}, + {name: "different slot", header: beacon.BeaconBlockHeader{Root: root, StateRoot: common.Hash(stateRoot), Slot: 101}, found: true, want: "state slot does not match"}, + } { + t.Run(tt.name, func(t *testing.T) { + bc.header, bc.found = tt.header, tt.found + if _, _, err := GetHeadBeaconState(bc, ec); err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected %q, got %v", tt.want, err) + } + }) + } + ec.header.ParentBeaconRoot = nil + if _, _, err := GetHeadBeaconState(bc, ec); err == nil || !strings.Contains(err.Error(), "no parent beacon block root") { + t.Fatalf("expected missing root error, got %v", err) + } +} + +func fuluSlotProofGindex() uint64 { + // BeaconBlockHeader.state_root (11) followed by BeaconState.slot (66). + return (11 << 6) | 2 +} diff --git a/shared/services/megapool-proofs.go b/shared/services/megapool-proofs.go index dc55905f1..c8accaa83 100644 --- a/shared/services/megapool-proofs.go +++ b/shared/services/megapool-proofs.go @@ -14,13 +14,12 @@ import ( "github.com/rocket-pool/smartnode/bindings/rocketpool" "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" rptypes "github.com/rocket-pool/smartnode/bindings/types" - "github.com/rocket-pool/smartnode/shared/services/wallet" ) func encodeValidatorBundleIfNeeded(rp *rocketpool.RocketPool, validatorProof megapool.ValidatorProof, slotProof megapool.SlotProof) (bool, []byte, error) { useBundles, err := megapool.UsesProofBundles(rp, nil) if err != nil { - return false, nil, fmt.Errorf("error checking beacon state verifier version: %w", err) + return false, nil, fmt.Errorf("error checking megapool manager proof format: %w", err) } if !useBundles { return false, nil, nil @@ -132,13 +131,24 @@ type MegapoolFinalBalanceProof struct { slotProof megapool.SlotProof } -func BuildMegapoolFinalBalanceProof(c *cli.Command, rp *rocketpool.RocketPool, megapoolAddress common.Address, slotHint uint64, validatorIndex uint64, validatorPubkey rptypes.ValidatorPubkey, w wallet.Wallet) (*MegapoolFinalBalanceProof, error) { +func BuildMegapoolFinalBalanceProof(c *cli.Command, rp *rocketpool.RocketPool, slotHint uint64, validatorIndex uint64, validatorPubkey rptypes.ValidatorPubkey) (*MegapoolFinalBalanceProof, error) { useBundles, err := megapool.UsesProofBundles(rp, nil) if err != nil { - return nil, fmt.Errorf("error checking beacon state verifier version: %w", err) + return nil, fmt.Errorf("error checking megapool manager proof format: %w", err) + } + bc, err := GetBeaconClient(c) + if err != nil { + return nil, err + } + beaconState, slotTimestamp, err := GetHeadBeaconState(bc, rp.Client) + if err != nil { + return nil, err + } + if beaconState.GetSlot() == 0 { + return nil, fmt.Errorf("proof beacon state has no earlier slots for a withdrawal proof") } if useBundles { - proofVersion, proofData, slotTimestamp, err := GetFinalBalanceProofBundle(c, slotHint, validatorIndex, validatorPubkey, megapoolAddress, w) + proofVersion, proofData, err := GetFinalBalanceProofBundle(c, slotHint, validatorIndex, validatorPubkey, beaconState) if err != nil { return nil, err } @@ -149,7 +159,7 @@ func BuildMegapoolFinalBalanceProof(c *cli.Command, rp *rocketpool.RocketPool, m proofData: proofData, }, nil } - withdrawalProof, validatorProof, slotProof, slotTimestamp, err := GetFinalBalanceProofs(c, slotHint, validatorIndex, validatorPubkey, megapoolAddress, w) + withdrawalProof, validatorProof, slotProof, err := GetFinalBalanceProofs(c, slotHint, validatorIndex, validatorPubkey, beaconState) if err != nil { return nil, err } diff --git a/shared/services/megapools.go b/shared/services/megapools.go index 22a5d08a5..28bf5f13c 100644 --- a/shared/services/megapools.go +++ b/shared/services/megapools.go @@ -1,6 +1,7 @@ package services import ( + "bytes" "context" "crypto/sha256" "encoding/json" @@ -68,31 +69,51 @@ func GetValidatorProof(c *cli.Command, slot uint64, wallet wallet.Wallet, eth2Co return megapool.ValidatorProof{}, 0, megapool.SlotProof{}, err } + if beaconState == nil { + beaconState, err = GetBeaconState(bc) + if err != nil { + return megapool.ValidatorProof{}, 0, megapool.SlotProof{}, err + } + } + validatorProof, slotProof, err := GetValidatorProofFromState(bc, validatorPubkey, beaconState) + if err != nil { + return megapool.ValidatorProof{}, 0, megapool.SlotProof{}, err + } + slotTimestamp, err := GetChildBlockTimestampForSlot(c, beaconState.GetSlot()) + if err != nil { + return megapool.ValidatorProof{}, 0, megapool.SlotProof{}, fmt.Errorf("Error getting the slotTimestamp: %w", err) + } + return validatorProof, slotTimestamp, slotProof, nil +} + +// GetValidatorProofFromState builds validator and slot proofs from an explicit beacon state. +func GetValidatorProofFromState(bc beacon.Client, validatorPubkey types.ValidatorPubkey, beaconState eth2.BeaconState) (megapool.ValidatorProof, megapool.SlotProof, error) { // Get the validator index on the beacon chain validatorIndex, err := bc.GetValidatorIndex(validatorPubkey) if err != nil { - return megapool.ValidatorProof{}, 0, megapool.SlotProof{}, err + return megapool.ValidatorProof{}, megapool.SlotProof{}, err } validatorIndex64, err := strconv.ParseUint(validatorIndex, 10, 64) if err != nil { - return megapool.ValidatorProof{}, 0, megapool.SlotProof{}, err + return megapool.ValidatorProof{}, megapool.SlotProof{}, err } if beaconState == nil { - beaconState, err = GetBeaconState(bc) - if err != nil { - return megapool.ValidatorProof{}, 0, megapool.SlotProof{}, err - } + return megapool.ValidatorProof{}, megapool.SlotProof{}, fmt.Errorf("beacon state is required") + } + validators := beaconState.GetValidators() + if validatorIndex64 >= uint64(len(validators)) { + return megapool.ValidatorProof{}, megapool.SlotProof{}, fmt.Errorf("validator index not found in proof beacon state") + } + if !bytes.Equal(validators[validatorIndex64].Pubkey, validatorPubkey[:]) { + return megapool.ValidatorProof{}, megapool.SlotProof{}, fmt.Errorf("validator pubkey does not match proof beacon state") } - // Build the validator and slot proofs from a single state proof tree proofBytes, slotProofBytes, err := beaconState.ValidatorAndSlotProof(validatorIndex64) if err != nil { - return megapool.ValidatorProof{}, 0, megapool.SlotProof{}, err + return megapool.ValidatorProof{}, megapool.SlotProof{}, err } - validators := beaconState.GetValidators() - // Convert [][]byte to [][32]byte proofWithFixedSize := ConvertToFixedSize(proofBytes) @@ -122,12 +143,7 @@ func GetValidatorProof(c *cli.Command, slot uint64, wallet wallet.Wallet, eth2Co Witnesses: ConvertToFixedSize(slotProofBytes), } - slotTimestamp, err := GetChildBlockTimestampForSlot(c, beaconState.GetSlot()) - if err != nil { - return megapool.ValidatorProof{}, 0, megapool.SlotProof{}, fmt.Errorf("Error getting the slotTimestamp: %w", err) - } - - return proof, slotTimestamp, slotProof, err + return proof, slotProof, nil } // PerformanceDefenseProofs bundles everything the RespondWithParticipation @@ -992,17 +1008,17 @@ func calculatePositionInQueue(rp *rocketpool.RocketPool, queueDetails api.QueueD return new(big.Int).SetUint64(overallPosition), err } -func GetWithdrawalProofForSlotFromAPI(c *cli.Command, finalizedSlot uint64, withdrawalSlot uint64, validatorIndex uint64, network cfgtypes.Network) (megapool.FinalBalanceProof, uint64, error) { +func GetWithdrawalProofForSlotFromAPI(c *cli.Command, proofSlot uint64, withdrawalSlot uint64, validatorIndex uint64, network cfgtypes.Network) (megapool.FinalBalanceProof, uint64, error) { /* API calls follow this format: - https://api.rocketpool.net//withdrawals/proofs/// + https://api.rocketpool.net//withdrawals/proofs/// An example API response: {"slot":2277023,"withdrawalSlot":2244697,"withdrawalNum":5,"withdrawal":{"index":33227778,"validatorIndex":1265923,"withdrawalCredentials":"0xf24c70772544b8ae60af28ddd34f13cc9c428ee7","amountInGwei":31995839800},"witnesses":["0x3410c5feb39b3f702d9b56634136cfb904a1f86c8f367e16fe2d4f969c898b0f","0xe2ba28dfa59acd70227d134be96c2e77e9ea1ccf7d27f94b7e3aff50eb344a53","0xf217264812ed46e949aaa1d8006b05409bbdb1438daa70cef95938140e412062","0x7fb96870d35763e6ab832250c9f8b1091f2383cde611d161b8564437f7910fc4","0x1000000000000000000000000000000000000000000000000000000000000000","0x0000100000000000000000000000000000000000000000000000000000000000","0x3f700c8ef3d83fe50eeb18e4f3df37ad98fe6f380f72c238c90d14c2cb18eefd","0x1085626fa4323b8bbe2acad06da450d8a9edc3cfcc0efe5b2830718a06d66a10","0x9c9bd327030be8c943838fd7083cb0d93d5f9c8408f78f3cd339e0f28e41c370","0xca7e70f57e9b781e937fa905ad6f090076d01db09959e31aa5b181a0d1c8569a","0x8ea7caad7a8238c943f26be0b020fb17ff83b12de0eb5977d763d2a11a21fc2c","0xaa3e128e91825440f40be45202d472ac6b70275a7999d5aa09e7b03a7f10e1d7","0x6dd3b9955d892d92338b19976fd07084bfe88a76c3063482b7f30ee60feb2a58","0xd7a3d1a54f5271259f8a74bad234ebbd73c9ce97727177da2d2a244a114c7852","0x0000000000000000000000000000000000000000000000000000000000000000","0xf5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b","0xdd165f288f4232e65aa7873f0bd8c12cefbea56dc663a30c06d3190922b1fd24","0x4dd9e68fabfffa19a6765e8aa9a1130a4c89aa587b8fe3a94026ca1b8523f679","0x1854ce5a71bcceb3c268c12bbeefbe5668bcb3f4a7e81a4856a9f43c10049859","0xbca9d545006824d4eb061f4e0b4908edffc6a50c25fbed6133e64141b86bc8b3","0xf13045a0e245b3959fa463a60028529f1a558ddf8d971c867d6a15218dd8279a","0xcc50292a5848698d71cae30bf5e6f897c8bef60dfc3e02ad6f1942e2044d8c69","0xec92853cdf50b7de0437d100cdf905e1114edd570f787eacf99fbd597c4300c1","0x982a2bee4c6798f97538986ed8bdf4327aa3ed5559421e693ca1c5513cc6d20b","0x06feb5a3ce20048e0b918726ed5cf15c4a098d4271035b1b58e975fde1cc8381","0xcd846f1c9978a061d010eb7eec8b3bd11a85b791a27e045eaf92f0ec3d7453f7","0xc33805f67b05f7f76a3db10096d2399f19cbf68be4d83e03c44fe8bc4c7f8620","0x8f9d27bbc7ec8fc85f61ac7f3bdbdcf9e7489bc3d55014dc7cdabc79d489cfe4","0xa97d47c50569d287b611735eeeb108004c0a4b64fc35b5c9d28adaa78c42b434","0x25a68400f09df83d1f6db6e9eea1db5b834bb26eb411dddf30f970994a278c8a","0xc80d5f6f2940ee052aac33fe0078ff5b52a64112c08617c8f71313a5e5f6b3de","0x114ef5c4f4bbc789f42d3367156029782916ab844a9ba92b5eac4bc7c0a1e6ae","0x5816ed9efb563c976395c126fd210ab89b7ba9f3b6336f830d4d052833fecbde","0xf9c4052c9567a598edae2a270cf8933b4b24323e49c01e1a3071ec3c56396de8","0xc78009fdf07fc56a11f122370658a353aaa542ed63e44c4bc15ff4cd105ab33c","0xa6c0f739071376d5fae6e5713a77f43bfb900e7e926a272a1fce2c046eb1af15","0x9efde052aa15429fae05bad4d0b1d7c64da64d03d7a1854a588c2cb8430c0d30","0xd88ddfeed400a8755596b21942c1497e114c302e6118290f91e6772976041fa1","0x87eb0ddba57e35f6d286673802a4af5975e22506c7cf4c64bb6be5ee11527f2c","0x92700a3530f06301b0ea071690a88c7723d5ebde9a5505c994351821be155309","0x506d86582d252405b840018792cad2bf1259f1ef5aa5f887e13cb2f0094f51e1","0xffff0ad7e659772f9534c195c815efc4014ef1e1daed4404c06385d11192e92b","0x6cf04127db05441cd833107a52be852868890e4317e6a02ab47683aa75964220","0xb7d05f875f140027ef5118a2247bbb84ce8f2f0f1123623085daf7960c329f5f","0xdf6af5f5bbdb6be9ef8aa618e4bf8073960867171e29676f8b284dea6a08a85e","0xb58d900f5e182e3c50ef74969ea16c7726c549757cc23523c369587da7293784","0xd49a7502ffcfb0340b1d7885688500ca308161a7f96b62df9d083b71fcc8f2bb","0x8fe6b1689256c0d385f42f5bbe2027a22c1996e110ba97c171d3e5948de92beb","0x8d0d63c39ebade8509e0ae3c9c3876fb5fa112be18f905ecacfecb92057603ab","0x95eec8b2e541cad4e91de38385f2e046619f54496c2382cb6cacd5b98c26f5a4","0xf893e908917775b62bff23294dbbe3a1cd8e6cc1c35b4801887b646a6f81f17f","0xcddba7b592e3133393c16194fac7431abf2f5485ed711db282183c819e08ebaa","0x8a8d7fe3af8caa085a7639a832001457dfb9128a8061142ad0335629ff23ff9c","0xfeb3c337d7a51a6fbf00b9e34c52e1c9195c969bd4e7a0bfd51d5c5bed9c1167","0xe71f0aa83cc32edfbefa9f4d3e0174ca85182eec9f3a09f6a6c0df6377a510d7","0x1501000000000000000000000000000000000000000000000000000000000000","0xbb4a0d0000000000000000000000000000000000000000000000000000000000","0x675afa2a5607e4b52f90aa67641f4d2a87d1d37e894912832fcb7879dd43b2bd","0xe0a37ef350f23459204e32961caab1b635c7349b8c21d0b170e64c760af8ccb7","0x95bee184cc5eab4b78d31c50ee452486edfedf473864c7fac86cef1de9b56d21","0x639a4850fe20dbbb573079ef58139feb5f6e7fd4d6e5319c4bd54448402a4c4a","0x91a46d0e5f2f963abc96b45ebb1867df4044298fa87235091e392b6c02aa9584","0x6c9497c4b3fb8b1a7e499db515726638d9468baad37c8b712f57636afb20b40d","0x44ce5bfa4c2c3c9e3f04c74c3cdb56404fb6b7a83bbf386f11e7305dc9aade0d","0x664471f37724d6eff852d4a3bc3b39aa1d3b37334e6184805a5364530433f860"]}/ */ - url := fmt.Sprintf(apiURL, network, finalizedSlot, withdrawalSlot, validatorIndex) + url := fmt.Sprintf(apiURL, network, proofSlot, withdrawalSlot, validatorIndex) response, err := http.Get(url) if err != nil { return megapool.FinalBalanceProof{}, 0, err @@ -1021,6 +1037,13 @@ func GetWithdrawalProofForSlotFromAPI(c *cli.Command, finalizedSlot uint64, with return megapool.FinalBalanceProof{}, 0, err } + if withdrawalProofResponse.Slot != proofSlot || withdrawalProofResponse.WithdrawalSlot >= proofSlot { + return megapool.FinalBalanceProof{}, 0, fmt.Errorf("withdrawal proof API did not return a proof for the selected beacon state at slot %d", proofSlot) + } + if withdrawalProofResponse.Withdrawal.ValidatorIndex != validatorIndex { + return megapool.FinalBalanceProof{}, 0, fmt.Errorf("withdrawal proof API returned a different validator index") + } + // Convert []common.Hash to [][32]byte witnesses := make([][32]byte, len(withdrawalProofResponse.Witnesses)) for i, w := range withdrawalProofResponse.Witnesses { @@ -1039,32 +1062,18 @@ func GetWithdrawalProofForSlotFromAPI(c *cli.Command, finalizedSlot uint64, with }, withdrawalProofResponse.Slot, nil } -func GetWithdrawalProofForSlot(c *cli.Command, slot uint64, validatorIndex uint64) (megapool.FinalBalanceProof, uint64, eth2.BeaconState, error) { +func getWithdrawalProofForSlot(bc beacon.Client, eth2Config beacon.Eth2Config, beaconState eth2.BeaconState, slot uint64, validatorIndex uint64) (megapool.FinalBalanceProof, error) { // Create a new response response := megapool.FinalBalanceProof{} response.ValidatorIndex = validatorIndex - // Get services - if err := RequireNodeRegistered(c); err != nil { - return megapool.FinalBalanceProof{}, 0, nil, err - } - bc, err := GetBeaconClient(c) - if err != nil { - return megapool.FinalBalanceProof{}, 0, nil, err - } - - eth2Config, err := bc.GetEth2Config() - if err != nil { - return megapool.FinalBalanceProof{}, 0, nil, err - } capellaOffset := eth2Config.HistoricalSummaryOffset() - withdrawalSlot, block, indexInWithdrawalsArray, withdrawal, finalizedBlock, err := FindWithdrawalBlockAndArrayPosition(slot, validatorIndex, bc) + withdrawalSlot, block, indexInWithdrawalsArray, withdrawal, err := findWithdrawalBlockAndArrayPosition(slot, validatorIndex, bc, eth2Config, beaconState.GetSlot()-1) if err != nil { - return megapool.FinalBalanceProof{}, 0, nil, err + return megapool.FinalBalanceProof{}, err } response.WithdrawalSlot = withdrawalSlot - response.Amount = ConvertWithdrawalAmount(withdrawal.Amount) response.Amount = big.NewInt(0).SetUint64(withdrawal.Amount) response.IndexInWithdrawalsArray = uint(indexInWithdrawalsArray) response.WithdrawalIndex = withdrawal.Index @@ -1073,151 +1082,127 @@ func GetWithdrawalProofForSlot(c *cli.Command, slot uint64, validatorIndex uint6 // Start by proving from the withdrawal to the block_root withdrawalProof, err := block.ProveWithdrawal(uint64(response.IndexInWithdrawalsArray)) if err != nil { - return megapool.FinalBalanceProof{}, 0, nil, err - } - - // Get beacon state - stateResponse, err := bc.GetBeaconStateSSZ(finalizedBlock.Slot) - if err != nil { - return megapool.FinalBalanceProof{}, 0, nil, err - } - - beaconState, err := eth2.NewBeaconState(stateResponse.Data, stateResponse.Size, stateResponse.Fork) - if err != nil { - return megapool.FinalBalanceProof{}, 0, nil, err + return megapool.FinalBalanceProof{}, err } pastRootProof, err := pastRootWitnesses(bc, beaconState, withdrawalSlot, false, capellaOffset) if err != nil { - return megapool.FinalBalanceProof{}, 0, nil, err + return megapool.FinalBalanceProof{}, err } blockHeaderProof, err := beaconState.BlockHeaderProof() if err != nil { - return megapool.FinalBalanceProof{}, 0, nil, err + return megapool.FinalBalanceProof{}, err } finalProof := concatProofs(withdrawalProof, pastRootProof, blockHeaderProof) response.Witnesses = ConvertToFixedSize(finalProof) - return response, finalizedBlock.Slot, beaconState, nil -} - -// GetFinalizedBlockSlotAndFork resolves the slot and consensus fork version of -// the most recent finalized beacon block, walking back over skipped slots. The -// JSON block endpoint is intentionally avoided: it dereferences the execution -// payload, which no longer exists in Gloas blocks. -func GetFinalizedBlockSlotAndFork(bc beacon.Client) (uint64, string, error) { - head, err := bc.GetBeaconHead() - if err != nil { - return 0, "", err - } - eth2Config, err := bc.GetEth2Config() - if err != nil { - return 0, "", err - } - - candidate := head.FinalizedEpoch * eth2Config.SlotsPerEpoch - const maxAttempts = 64 // Two epochs - for attempts := 0; attempts < maxAttempts; attempts++ { - blockResponse, found, err := bc.GetBeaconBlockSSZ(candidate) - if err != nil { - return 0, "", err - } - if found { - fork := blockResponse.Fork - _ = blockResponse.Data.Close() - return candidate, fork, nil - } - if candidate == 0 { - break - } - candidate-- - } - return 0, "", fmt.Errorf("failed to find a finalized beacon block within %d slots of finalized epoch %d", maxAttempts, head.FinalizedEpoch) + return response, nil } // GetFinalBalanceProofs builds the 1.4.0 structured proofs for // RocketMegapoolManager.notifyFinalBalance. Gloas withdrawals are not supported // on 1.4.0 contracts. -func GetFinalBalanceProofs(c *cli.Command, slotHint uint64, validatorIndex uint64, validatorPubkey types.ValidatorPubkey, megapoolAddress common.Address, w wallet.Wallet) (megapool.WithdrawalProof, megapool.ValidatorProof, megapool.SlotProof, uint64, error) { - withdrawalProof, validatorProof, slotProof, slotTimestamp, err := getPreGloasFinalBalanceProofs(c, slotHint, validatorIndex, validatorPubkey, megapoolAddress, w) +func GetFinalBalanceProofs(c *cli.Command, slotHint uint64, validatorIndex uint64, validatorPubkey types.ValidatorPubkey, beaconState eth2.BeaconState) (megapool.WithdrawalProof, megapool.ValidatorProof, megapool.SlotProof, error) { + withdrawalProof, validatorProof, slotProof, err := getPreGloasFinalBalanceProofs(c, slotHint, validatorIndex, validatorPubkey, beaconState) if err != nil { if errors.Is(err, ErrGloasBoundaryReached) { - return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, 0, fmt.Errorf("1.4.0 contracts cannot verify Gloas final-balance proofs: %w", err) + return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, fmt.Errorf("1.4.0 contracts cannot verify Gloas final-balance proofs: %w", err) } - return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, 0, err + return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, err } - return withdrawalProof, validatorProof, slotProof, slotTimestamp, nil + return withdrawalProof, validatorProof, slotProof, nil } // GetFinalBalanceProofBundle builds the versioned proof payload for -// RocketMegapoolManager.notifyFinalBalance on 1.4.1+. Version 1 is used for -// pre-Gloas withdrawals; version 2 for post-Gloas withdrawals. -func GetFinalBalanceProofBundle(c *cli.Command, slotHint uint64, validatorIndex uint64, validatorPubkey types.ValidatorPubkey, megapoolAddress common.Address, w wallet.Wallet) (*big.Int, []byte, uint64, error) { - withdrawalProof, validatorProof, slotProof, slotTimestamp, err := getPreGloasFinalBalanceProofs(c, slotHint, validatorIndex, validatorPubkey, megapoolAddress, w) +// RocketMegapoolManager.notifyFinalBalance on 1.4.1+. The actual withdrawal +// slot determines the version: 1 below Gloas activation, 2 at or above it. +func GetFinalBalanceProofBundle(c *cli.Command, slotHint uint64, validatorIndex uint64, validatorPubkey types.ValidatorPubkey, beaconState eth2.BeaconState) (*big.Int, []byte, error) { + withdrawalProof, validatorProof, slotProof, err := getPreGloasFinalBalanceProofs(c, slotHint, validatorIndex, validatorPubkey, beaconState) if err != nil { if errors.Is(err, ErrGloasBoundaryReached) { bc, bcErr := GetBeaconClient(c) if bcErr != nil { - return nil, nil, 0, bcErr + return nil, nil, bcErr } eth2Config, cfgErr := bc.GetEth2Config() if cfgErr != nil { - return nil, nil, 0, cfgErr + return nil, nil, cfgErr } - return getGloasFinalBalanceProofBundle(c, bc, eth2Config, slotHint, validatorIndex, validatorPubkey, megapoolAddress, w) + ec, ecErr := GetEthClient(c) + if ecErr != nil { + return nil, nil, ecErr + } + return getGloasFinalBalanceProofBundle(bc, ec, eth2Config, slotHint, validatorIndex, validatorPubkey, beaconState) } - return nil, nil, 0, err + return nil, nil, err } encoded, err := megapool.EncodeFinalBalanceProofBundleV1(withdrawalProof, validatorProof, slotProof) if err != nil { - return nil, nil, 0, err + return nil, nil, err + } + return megapool.FinalBalanceProofVersion1, encoded, nil +} + +func validateFinalBalanceProofVersion(version *big.Int, withdrawalSlot uint64, eth2Config beacon.Eth2Config) error { + slotGloas := eth2Config.GloasActivationSlot() + switch { + case version.Cmp(megapool.FinalBalanceProofVersion1) == 0: + if withdrawalSlot >= slotGloas { + return fmt.Errorf("version 1 final balance proofs require withdrawal slot %d < Gloas activation slot %d: %w", withdrawalSlot, slotGloas, ErrGloasBoundaryReached) + } + case version.Cmp(megapool.FinalBalanceProofVersion2) == 0: + if withdrawalSlot < slotGloas { + return fmt.Errorf("version 2 final balance proofs require withdrawal slot %d >= Gloas activation slot %d", withdrawalSlot, slotGloas) + } + default: + return fmt.Errorf("unsupported final balance proof version %s", version) } - return megapool.FinalBalanceProofVersion1, encoded, slotTimestamp, nil + return nil } -func getPreGloasFinalBalanceProofs(c *cli.Command, slotHint uint64, validatorIndex uint64, validatorPubkey types.ValidatorPubkey, megapoolAddress common.Address, w wallet.Wallet) (megapool.WithdrawalProof, megapool.ValidatorProof, megapool.SlotProof, uint64, error) { +func getPreGloasFinalBalanceProofs(c *cli.Command, slotHint uint64, validatorIndex uint64, validatorPubkey types.ValidatorPubkey, beaconState eth2.BeaconState) (megapool.WithdrawalProof, megapool.ValidatorProof, megapool.SlotProof, error) { bc, err := GetBeaconClient(c) if err != nil { - return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, 0, err + return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, err } eth2Config, err := bc.GetEth2Config() if err != nil { - return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, 0, err + return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, err } - rawProof, proofSlot, stateUsed, err := GetWithdrawalProofForSlot(c, slotHint, validatorIndex) + rawProof, err := getWithdrawalProofForSlot(bc, eth2Config, beaconState, slotHint, validatorIndex) if err != nil { if errors.Is(err, ErrGloasBoundaryReached) { - return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, 0, err + return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, err } cfg, cfgErr := GetConfig(c) if cfgErr != nil { - return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, 0, err + return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, err } fmt.Printf("An error occurred while getting the withdrawal proof: %s\n", err) - head, headErr := bc.GetBeaconHead() - if headErr != nil { - return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, 0, err - } - finalizedSlot := head.FinalizedEpoch * eth2Config.SlotsPerEpoch network := cfg.Smartnode.Network.Value.(cfgtypes.Network) var apiErr error - rawProof, proofSlot, apiErr = GetWithdrawalProofForSlotFromAPI(c, finalizedSlot, slotHint, validatorIndex, network) + rawProof, _, apiErr = GetWithdrawalProofForSlotFromAPI(c, beaconState.GetSlot(), slotHint, validatorIndex, network) if apiErr != nil { fmt.Printf("An error occurred while getting the withdrawal proof from the Rocket Pool API: %s\n", apiErr) - return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, 0, err + return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, err } - stateUsed = nil } - validatorProof, slotTimestamp, slotProof, err := GetValidatorProof(c, proofSlot, w, eth2Config, validatorPubkey, stateUsed) + // Apply the verifier's slot boundary to local and API proofs alike. Returning + // the boundary error lets the bundle builder switch to the Gloas proof path. + if err := validateFinalBalanceProofVersion(megapool.FinalBalanceProofVersion1, rawProof.WithdrawalSlot, eth2Config); err != nil { + return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, err + } + + validatorProof, slotProof, err := GetValidatorProofFromState(bc, validatorPubkey, beaconState) if err != nil { - return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, 0, err + return megapool.WithdrawalProof{}, megapool.ValidatorProof{}, megapool.SlotProof{}, err } - return finalBalanceWithdrawalProof(rawProof, validatorIndex), validatorProof, slotProof, slotTimestamp, nil + return finalBalanceWithdrawalProof(rawProof, validatorIndex), validatorProof, slotProof, nil } func finalBalanceWithdrawalProof(proof megapool.FinalBalanceProof, validatorIndex uint64) megapool.WithdrawalProof { @@ -1234,45 +1219,43 @@ func finalBalanceWithdrawalProof(proof megapool.FinalBalanceProof, validatorInde } } -func getGloasFinalBalanceProofBundle(c *cli.Command, bc beacon.Client, eth2Config beacon.Eth2Config, slotHint uint64, validatorIndex uint64, validatorPubkey types.ValidatorPubkey, megapoolAddress common.Address, w wallet.Wallet) (*big.Int, []byte, uint64, error) { - ec, err := GetEthClient(c) - if err != nil { - return nil, nil, 0, err - } - finalizedSlot, _, err := GetFinalizedBlockSlotAndFork(bc) - if err != nil { - return nil, nil, 0, err - } +func getGloasFinalBalanceProofBundle(bc beacon.Client, ec withdrawalExecutionClient, eth2Config beacon.Eth2Config, slotHint uint64, validatorIndex uint64, validatorPubkey types.ValidatorPubkey, proofState eth2.BeaconState) (*big.Int, []byte, error) { capellaOffset := eth2Config.HistoricalSummaryOffset() - withdrawalSlot, indexInWithdrawalsArray, withdrawal, err := FindGloasWithdrawalSlotAndArrayPosition(slotHint, validatorIndex, ec, eth2Config) + // A pre-Gloas hint can reach this path when the beacon scan crosses the fork. + // Only withdrawals at or after activation are eligible for version 2. + startSlot := max(slotHint, eth2Config.GloasActivationSlot()) + withdrawalSlot, indexInWithdrawalsArray, withdrawal, err := findGloasWithdrawalSlotAndArrayPosition(startSlot, validatorIndex, ec, eth2Config, proofState.GetSlot()-1) if err != nil { - return nil, nil, 0, err + return nil, nil, err } if withdrawalSlot == 0 { - return nil, nil, 0, fmt.Errorf("withdrawal slot must be greater than zero") + return nil, nil, fmt.Errorf("withdrawal slot must be greater than zero") + } + if err := validateFinalBalanceProofVersion(megapool.FinalBalanceProofVersion2, withdrawalSlot, eth2Config); err != nil { + return nil, nil, err } withdrawalStateResponse, err := bc.GetBeaconStateSSZ(withdrawalSlot) if err != nil { - return nil, nil, 0, err + return nil, nil, err } withdrawalState, err := eth2.NewBeaconState(withdrawalStateResponse.Data, withdrawalStateResponse.Size, withdrawalStateResponse.Fork) if err != nil { - return nil, nil, 0, err + return nil, nil, err } gloasWithdrawalState, ok := withdrawalState.(*gloas.BeaconState) if !ok { - return nil, nil, 0, fmt.Errorf("withdrawal slot %d is in the %s fork; version 2 final balance proofs require Gloas", withdrawalSlot, withdrawalStateResponse.Fork) + return nil, nil, fmt.Errorf("withdrawal slot %d is in the %s fork; version 2 final balance proofs require Gloas", withdrawalSlot, withdrawalStateResponse.Fork) } if err := verifyExpectedWithdrawal(gloasWithdrawalState, indexInWithdrawalsArray, withdrawal); err != nil { - return nil, nil, 0, err + return nil, nil, err } if uint64(len(gloasWithdrawalState.Balances)) <= validatorIndex { - return nil, nil, 0, fmt.Errorf("validator index %d is outside balances length %d", validatorIndex, len(gloasWithdrawalState.Balances)) + return nil, nil, fmt.Errorf("validator index %d is outside balances length %d", validatorIndex, len(gloasWithdrawalState.Balances)) } if gloasWithdrawalState.Balances[validatorIndex] != 0 { - return nil, nil, 0, fmt.Errorf("validator %d balance is %d gwei at withdrawal slot %d; expected zero", validatorIndex, gloasWithdrawalState.Balances[validatorIndex], withdrawalSlot) + return nil, nil, fmt.Errorf("validator %d balance is %d gwei at withdrawal slot %d; expected zero", validatorIndex, gloasWithdrawalState.Balances[validatorIndex], withdrawalSlot) } matching := 0 @@ -1282,75 +1265,67 @@ func getGloasFinalBalanceProofBundle(c *cli.Command, bc beacon.Client, eth2Confi } } if matching != 1 { - return nil, nil, 0, fmt.Errorf("expected exactly one payload_expected_withdrawals entry for validator %d at slot %d, found %d", validatorIndex, withdrawalSlot, matching) + return nil, nil, fmt.Errorf("expected exactly one payload_expected_withdrawals entry for validator %d at slot %d, found %d", validatorIndex, withdrawalSlot, matching) } withdrawalLeafProof, err := gloasWithdrawalState.ProveExpectedWithdrawal(uint64(indexInWithdrawalsArray)) if err != nil { - return nil, nil, 0, err + return nil, nil, err } balanceLeafProof, balanceChunk, err := gloasWithdrawalState.ProveValidatorBalanceChunk(validatorIndex) if err != nil { - return nil, nil, 0, err + return nil, nil, err } if !gloas.IsZeroValidatorBalance(balanceChunk, validatorIndex) { - return nil, nil, 0, fmt.Errorf("validator %d balance chunk is not zero at withdrawal slot %d", validatorIndex, withdrawalSlot) + return nil, nil, fmt.Errorf("validator %d balance chunk is not zero at withdrawal slot %d", validatorIndex, withdrawalSlot) } previousSlot := withdrawalSlot - 1 previousStateResponse, err := bc.GetBeaconStateSSZ(previousSlot) if err != nil { - return nil, nil, 0, err + return nil, nil, err } previousState, err := eth2.NewBeaconState(previousStateResponse.Data, previousStateResponse.Size, previousStateResponse.Fork) if err != nil { - return nil, nil, 0, err + return nil, nil, err } nextWithdrawalIndex, nextWithdrawalIndexLeafProof, err := proveNextWithdrawalIndex(previousState) if err != nil { - return nil, nil, 0, err + return nil, nil, err } if withdrawal.Index != nextWithdrawalIndex+uint64(indexInWithdrawalsArray) { - return nil, nil, 0, fmt.Errorf("withdrawal index %d is stale relative to preceding next_withdrawal_index %d and withdrawal number %d", withdrawal.Index, nextWithdrawalIndex, indexInWithdrawalsArray) + return nil, nil, fmt.Errorf("withdrawal index %d is stale relative to preceding next_withdrawal_index %d and withdrawal number %d", withdrawal.Index, nextWithdrawalIndex, indexInWithdrawalsArray) } - proofStateResponse, err := bc.GetBeaconStateSSZ(finalizedSlot) - if err != nil { - return nil, nil, 0, err - } - proofState, err := eth2.NewBeaconState(proofStateResponse.Data, proofStateResponse.Size, proofStateResponse.Fork) - if err != nil { - return nil, nil, 0, err - } if _, ok := proofState.(*gloas.BeaconState); !ok { - return nil, nil, 0, fmt.Errorf("expected gloas.BeaconState for the proof slot, got %T", proofState) + return nil, nil, fmt.Errorf("expected gloas.BeaconState for the proof slot, got %T", proofState) } withdrawalPastRoot, err := pastRootWitnesses(bc, proofState, withdrawalSlot, true, capellaOffset) if err != nil { - return nil, nil, 0, err + return nil, nil, err } previousPastRoot, err := pastRootWitnesses(bc, proofState, previousSlot, true, capellaOffset) if err != nil { - return nil, nil, 0, err + return nil, nil, err } blockHeaderProof, err := proofState.BlockHeaderProof() if err != nil { - return nil, nil, 0, err + return nil, nil, err } - validatorProof, slotTimestamp, slotProof, err := GetValidatorProof(c, finalizedSlot, w, eth2Config, validatorPubkey, proofState) + validatorProof, slotProof, err := GetValidatorProofFromState(bc, validatorPubkey, proofState) if err != nil { - return nil, nil, 0, err + return nil, nil, err } withdrawalEpoch := withdrawalSlot / eth2Config.SlotsPerEpoch validators := proofState.GetValidators() if validatorIndex >= uint64(len(validators)) { - return nil, nil, 0, fmt.Errorf("validator index %d is outside validators length %d", validatorIndex, len(validators)) + return nil, nil, fmt.Errorf("validator index %d is outside validators length %d", validatorIndex, len(validators)) } if withdrawalEpoch < validators[validatorIndex].WithdrawableEpoch { - return nil, nil, 0, fmt.Errorf("withdrawal epoch %d is earlier than validator withdrawable epoch %d", withdrawalEpoch, validators[validatorIndex].WithdrawableEpoch) + return nil, nil, fmt.Errorf("withdrawal epoch %d is earlier than validator withdrawable epoch %d", withdrawalEpoch, validators[validatorIndex].WithdrawableEpoch) } encoded, err := megapool.EncodeFinalBalanceProofBundleV2( @@ -1377,9 +1352,9 @@ func getGloasFinalBalanceProofBundle(c *cli.Command, bc beacon.Client, eth2Confi }, ) if err != nil { - return nil, nil, 0, err + return nil, nil, err } - return megapool.FinalBalanceProofVersion2, encoded, slotTimestamp, nil + return megapool.FinalBalanceProofVersion2, encoded, nil } func concatProofs(parts ...[][]byte) [][]byte { @@ -1495,11 +1470,24 @@ func verifyExpectedWithdrawal(state *gloas.BeaconState, indexInWithdrawalsArray // (identical to its position in the state's payload_expected_withdrawals) and // the withdrawal itself. func FindGloasWithdrawalSlotAndArrayPosition(slot uint64, validatorIndex uint64, ec *ExecutionClientManager, eth2Config beacon.Eth2Config) (uint64, int, *generic.Withdrawal, error) { + return findGloasWithdrawalSlotAndArrayPosition(slot, validatorIndex, ec, eth2Config, slot+MAX_WITHDRAWAL_SLOT_DISTANCE) +} + +type withdrawalExecutionClient interface { + BlockNumber(context.Context) (uint64, error) + HeaderByNumber(context.Context, *big.Int) (*ethtypes.Header, error) + BlockByNumber(context.Context, *big.Int) (*ethtypes.Block, error) +} + +func findGloasWithdrawalSlotAndArrayPosition(slot uint64, validatorIndex uint64, ec withdrawalExecutionClient, eth2Config beacon.Eth2Config, endSlot uint64) (uint64, int, *generic.Withdrawal, error) { + if endSlot < slot { + return 0, 0, nil, fmt.Errorf("withdrawal slot %d is not yet included in the proof state", slot) + } ctx := context.Background() // Binary search the EL for the first block at or after the start slot. startTime := uint64(eth2Config.GetSlotTime(slot).Unix()) - endTime := uint64(eth2Config.GetSlotTime(slot + MAX_WITHDRAWAL_SLOT_DISTANCE).Unix()) + endTime := uint64(eth2Config.GetSlotTime(min(endSlot, slot+MAX_WITHDRAWAL_SLOT_DISTANCE) + 1).Unix()) blockNumber, err := findExecutionBlockByTime(ec, startTime) if err != nil { return 0, 0, nil, err @@ -1518,7 +1506,7 @@ func FindGloasWithdrawalSlotAndArrayPosition(slot uint64, validatorIndex uint64, if blockTime < startTime { continue } - if blockTime > endTime { + if blockTime >= endTime { break } for i, elWithdrawal := range block.Withdrawals() { @@ -1586,7 +1574,7 @@ func slotOfExecutionBlock(block *ethtypes.Block, eth2Config beacon.Eth2Config) u // findExecutionBlockByTime returns the number of the first execution block with // a timestamp greater than or equal to the given time. -func findExecutionBlockByTime(ec *ExecutionClientManager, timestamp uint64) (uint64, error) { +func findExecutionBlockByTime(ec withdrawalExecutionClient, timestamp uint64) (uint64, error) { ctx := context.Background() latest, err := ec.BlockNumber(ctx) if err != nil { @@ -1629,7 +1617,6 @@ func FindWithdrawalBlockAndArrayPosition(slot uint64, validatorIndex uint64, bc if err != nil { return 0, nil, 0, nil, nil, err } - gloasActivationSlot := eth2Config.GloasActivationSlot() // cant use head here as we need to grab the next slot timestamp blockToRequest := "finalized" @@ -1650,22 +1637,32 @@ func FindWithdrawalBlockAndArrayPosition(slot uint64, validatorIndex uint64, bc blockToRequest = fmt.Sprintf("%d", finalizedBlock.Slot-1) } + withdrawalSlot, block, position, withdrawal, err := findWithdrawalBlockAndArrayPosition(slot, validatorIndex, bc, eth2Config, slot+MAX_WITHDRAWAL_SLOT_DISTANCE) + return withdrawalSlot, block, position, withdrawal, &finalizedBlock, err +} + +func findWithdrawalBlockAndArrayPosition(slot uint64, validatorIndex uint64, bc beacon.Client, eth2Config beacon.Eth2Config, endSlot uint64) (uint64, eth2.SignedBeaconBlock, int, *generic.Withdrawal, error) { + gloasActivationSlot := eth2Config.GloasActivationSlot() + if slot >= gloasActivationSlot { + return 0, nil, 0, nil, ErrGloasBoundaryReached + } + // Find the most recent withdrawal to slot. // Keep track of 404s- if we get 24 missing slots in a row, assume we don't have full history. notFounds := 0 - for candidateSlot := slot; candidateSlot <= slot+MAX_WITHDRAWAL_SLOT_DISTANCE; candidateSlot++ { + for candidateSlot := slot; candidateSlot <= min(endSlot, slot+MAX_WITHDRAWAL_SLOT_DISTANCE); candidateSlot++ { if candidateSlot >= gloasActivationSlot { - return 0, nil, 0, nil, nil, fmt.Errorf("no withdrawal found for validator index %d between slot %d and gloas activation at slot %d: %w", validatorIndex, slot, gloasActivationSlot, ErrGloasBoundaryReached) + return 0, nil, 0, nil, fmt.Errorf("no withdrawal found for validator index %d between slot %d and gloas activation at slot %d: %w", validatorIndex, slot, gloasActivationSlot, ErrGloasBoundaryReached) } // Get the block at the candidate slot. blockResponse, found, err := bc.GetBeaconBlockSSZ(candidateSlot) if err != nil { - return 0, nil, 0, nil, nil, err + return 0, nil, 0, nil, err } if !found { notFounds++ if notFounds >= 64 { - return 0, nil, 0, nil, nil, fmt.Errorf("2 epochs of missing slots detected. It is likely that the Beacon Client was checkpoint synced after the most recent withdrawal to slot %d, and does not have the history required to generate a withdrawal proof", slot) + return 0, nil, 0, nil, fmt.Errorf("2 epochs of missing slots detected. It is likely that the Beacon Client was checkpoint synced after the most recent withdrawal to slot %d, and does not have the history required to generate a withdrawal proof", slot) } continue } @@ -1673,7 +1670,7 @@ func FindWithdrawalBlockAndArrayPosition(slot uint64, validatorIndex uint64, bc beaconBlock, err := eth2.NewSignedBeaconBlock(blockResponse.Data, blockResponse.Size, blockResponse.Fork) if err != nil { - return 0, nil, 0, nil, nil, err + return 0, nil, 0, nil, err } if !beaconBlock.HasExecutionPayload() { @@ -1686,10 +1683,10 @@ func FindWithdrawalBlockAndArrayPosition(slot uint64, validatorIndex uint64, bc continue } - return candidateSlot, beaconBlock, i, withdrawal, &finalizedBlock, nil + return candidateSlot, beaconBlock, i, withdrawal, nil } } - return 0, nil, 0, nil, nil, fmt.Errorf("no withdrawal found for validator index %d within %d slots of slot %d", validatorIndex, MAX_WITHDRAWAL_SLOT_DISTANCE, slot) + return 0, nil, 0, nil, fmt.Errorf("no withdrawal found for validator index %d within %d slots of slot %d", validatorIndex, MAX_WITHDRAWAL_SLOT_DISTANCE, slot) } func GetChildBlockTimestampForSlot(c *cli.Command, slot uint64) (uint64, error) { diff --git a/shared/services/performance-challenges.go b/shared/services/performance-challenges.go new file mode 100644 index 000000000..894a290e5 --- /dev/null +++ b/shared/services/performance-challenges.go @@ -0,0 +1,351 @@ +package services + +import ( + "errors" + "fmt" + "math/big" + "strconv" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/minipool" + "github.com/rocket-pool/smartnode/bindings/node" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/bindings/types" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/performance" + "github.com/rocket-pool/smartnode/shared/services/state" + "github.com/rocket-pool/smartnode/shared/types/api" + "github.com/urfave/cli/v3" +) + +type PerformanceChallengeRequest struct { + MegapoolAddress common.Address + ValidatorIds []uint32 + MinipoolAddresses []common.Address + StartEpoch uint64 + Participation []*big.Int +} + +// PreparedPerformanceChallenge contains the payload and slot proof used for submission. +type PreparedPerformanceChallenge struct { + Request PerformanceChallengeRequest + SlotTimestamp uint64 + Slot megapool.SlotProof +} + +func (r PerformanceChallengeRequest) Validate() error { + if len(r.MinipoolAddresses) > 0 { + if len(r.ValidatorIds) > 0 || r.MegapoolAddress != (common.Address{}) { + return fmt.Errorf("choose either minipools or megapool validators") + } + return validateChallengeMembers(r.MinipoolAddresses) + } + if len(r.ValidatorIds) == 0 || len(r.ValidatorIds) > megapool.MaxPerformanceChallengeValidators { + return fmt.Errorf("a challenge requires 1 to 32 validators") + } + seen := map[uint32]bool{} + for _, id := range r.ValidatorIds { + if seen[id] { + return fmt.Errorf("duplicate validator ID %d", id) + } + seen[id] = true + } + return nil +} + +func validateChallengeMembers(members []common.Address) error { + if len(members) == 0 || len(members) > megapool.MaxPerformanceChallengeValidators { + return fmt.Errorf("a challenge requires 1 to 32 minipools") + } + seen := map[common.Address]bool{} + for _, address := range members { + if address == (common.Address{}) || seen[address] { + return fmt.Errorf("zero or duplicate minipool address %s", address) + } + seen[address] = true + } + return nil +} + +// AvailablePerformanceBond mirrors RocketNodeStaking.lockRPL without underflow. +func AvailablePerformanceBond(staked, locked *big.Int) *big.Int { + available := new(big.Int).Sub(staked, locked) + if available.Sign() < 0 { + available.SetInt64(0) + } + return available +} + +// PreparePerformanceChallenge validates the exact payload and estimates at current +// state for both preflight and submission. Contracts remain authoritative for +// membership, staking status, same-node minipools and outstanding exit requests. +func PreparePerformanceChallenge(c *cli.Command, request PerformanceChallengeRequest, opts *bind.TransactOpts) (*api.CanChallengeMegapoolPerformanceResponse, *PreparedPerformanceChallenge, error) { + result := &api.CanChallengeMegapoolPerformanceResponse{} + if err := request.Validate(); err != nil { + return nil, nil, err + } + if err := RequireNodeRegistered(c); err != nil { + return nil, nil, err + } + if err := RequireBeaconClientSynced(c); err != nil { + return nil, nil, err + } + rp, err := GetRocketPool(c) + if err != nil { + return nil, nil, err + } + deployed, err := state.IsSaturn2Deployed(rp, nil) + if err != nil { + return nil, nil, err + } + if !deployed { + return nil, nil, fmt.Errorf("performance challenges require Saturn 2") + } + result.ChallengeBond, err = protocol.GetPerformanceChallengeBond(rp, nil) + if err != nil { + return nil, nil, err + } + stake, err := node.GetNodeStakedRPL(rp, opts.From, nil) + if err != nil { + return nil, nil, err + } + locked, err := node.GetNodeLockedRPL(rp, opts.From, nil) + if err != nil { + return nil, nil, err + } + result.RplBalance = AvailablePerformanceBond(stake, locked) + result.RplLockingAllowed, err = node.GetRPLLockedAllowed(rp, opts.From, nil) + if err != nil { + return nil, nil, err + } + if !result.RplLockingAllowed { + result.Reason = "Enable RPL locking before submitting a performance challenge." + } + if result.RplBalance.Cmp(result.ChallengeBond) < 0 { + result.InsufficientRplBalance = true + result.Reason = "Insufficient unlocked staked RPL for the challenge bond." + } + if result.Reason != "" { + return result, nil, nil + } + var pubkey types.ValidatorPubkey + if len(request.MinipoolAddresses) > 0 { + for _, address := range request.MinipoolAddresses { + mp, err := minipool.NewMinipool(rp, address, nil) + if err != nil { + return nil, nil, err + } + status, err := mp.GetStatus(nil) + if err != nil { + return nil, nil, err + } + if status != types.Staking { + result.Reason = fmt.Sprintf("Minipool %s is not staking.", address.Hex()) + return result, nil, nil + } + } + pubkey, err = minipool.GetMinipoolPubkey(rp, request.MinipoolAddresses[0], nil) + } else { + if request.MegapoolAddress == (common.Address{}) { + request.MegapoolAddress, err = node.GetMegapoolAddress(rp, opts.From, nil) + if err != nil { + return nil, nil, err + } + } + var mp megapool.Megapool + mp, err = megapool.NewMegapool(rp, request.MegapoolAddress, nil) + if err == nil { + for _, validatorId := range request.ValidatorIds { + validator, err := mp.GetValidatorInfo(validatorId, nil) + if err != nil { + return nil, nil, err + } + if !validator.Staked { + result.Reason = fmt.Sprintf("Validator %d is not staked.", validatorId) + return result, nil, nil + } + } + pubkey, err = mp.GetValidatorPubkey(request.ValidatorIds[0], nil) + } + } + if err != nil { + return nil, nil, err + } + bc, err := GetBeaconClient(c) + if err != nil { + return nil, nil, err + } + timestamp, slot, err := buildPerformanceChallengeSlotProof(bc, rp.Client, pubkey) + if err != nil { + return nil, nil, err + } + params, err := performance.GetChallengeParams(rp) + if err != nil { + return nil, nil, err + } + if err := performance.ValidateChallengeBitmap(params, slot.Slot/32, request.StartEpoch, request.Participation); err != nil { + return nil, nil, err + } + opts.Value = nil + if len(request.MinipoolAddresses) > 0 { + result.GasLimits, err = megapool.EstimateChallengeMinipoolsGas(rp, request.MinipoolAddresses, request.StartEpoch, request.Participation, timestamp, slot, opts) + } else { + result.GasLimits, err = megapool.EstimateChallengeMegapoolGas(rp, request.MegapoolAddress, request.ValidatorIds, request.StartEpoch, request.Participation, timestamp, slot, opts) + } + if err != nil { + return nil, nil, err + } + result.CanChallenge = true + prepared := &PreparedPerformanceChallenge{ + Request: request, + SlotTimestamp: timestamp, + Slot: slot, + } + return result, prepared, nil +} + +// Use a root already published by the execution head, so recently measured +// epochs do not become future bitmap bits relative to an older finalized state. +func buildPerformanceChallengeSlotProof(bc beacon.Client, ec rocketpool.ExecutionClient, pubkey types.ValidatorPubkey) (uint64, megapool.SlotProof, error) { + beaconState, timestamp, err := GetHeadBeaconState(bc, ec) + if err != nil { + return 0, megapool.SlotProof{}, err + } + _, slot, err := GetValidatorProofFromState(bc, pubkey, beaconState) + return timestamp, slot, err +} + +func SubmitPerformanceChallenge(rp *rocketpool.RocketPool, challenge *PreparedPerformanceChallenge, opts *bind.TransactOpts) (common.Hash, error) { + opts.Value = nil + request := challenge.Request + if len(request.MinipoolAddresses) > 0 { + return megapool.ChallengeMinipools(rp, request.MinipoolAddresses, request.StartEpoch, request.Participation, challenge.SlotTimestamp, challenge.Slot, opts) + } + return megapool.ChallengeMegapool(rp, request.MegapoolAddress, request.ValidatorIds, request.StartEpoch, request.Participation, challenge.SlotTimestamp, challenge.Slot, opts) +} + +// BuildPerformanceChallengeDefense tries all members, using an activation +// proof when possible before fetching historical data. A failed member must +// not hide another valid defense. +func BuildPerformanceChallengeDefense(c *cli.Command, challenge megapool.PerformanceChallenge) (megapool.PerformanceChallengeDefense, error) { + rp, err := GetRocketPool(c) + if err != nil { + return megapool.PerformanceChallengeDefense{}, err + } + bc, err := GetBeaconClient(c) + if err != nil { + return megapool.PerformanceChallengeDefense{}, err + } + cfg, err := bc.GetEth2Config() + if err != nil { + return megapool.PerformanceChallengeDefense{}, err + } + w, err := GetWallet(c) + if err != nil { + return megapool.PerformanceChallengeDefense{}, err + } + epochs := []uint64{} + for wordIndex, word := range challenge.Participation { + for bit := 0; bit < word.BitLen(); bit++ { + if word.Bit(bit) != 0 { + epochs = append(epochs, challenge.StartEpoch+uint64(wordIndex)*256+uint64(bit)) + } + } + } + count := len(challenge.ValidatorIds) + if len(challenge.MinipoolAddresses) > 0 { + count = len(challenge.MinipoolAddresses) + } + var failures []error + for i := 0; i < count; i++ { + defense := megapool.PerformanceChallengeDefense{ChallengeId: challenge.ChallengeId} + var pubkey types.ValidatorPubkey + if len(challenge.MinipoolAddresses) > 0 { + defense.MinipoolAddress = challenge.MinipoolAddresses[i] + pubkey, err = minipool.GetMinipoolPubkey(rp, defense.MinipoolAddress, nil) + } else { + defense.ValidatorId = challenge.ValidatorIds[i] + var mp megapool.Megapool + mp, err = megapool.NewMegapool(rp, challenge.MegapoolAddress, nil) + if err == nil { + pubkey, err = mp.GetValidatorPubkey(defense.ValidatorId, nil) + } + } + if err != nil { + failures = append(failures, err) + continue + } + status, statusErr := bc.GetValidatorStatus(pubkey, nil) + if statusErr != nil { + failures = append(failures, statusErr) + continue + } + if !status.Exists { + continue + } + if status.ActivationEpoch > challenge.StartEpoch { + defense.Validator, defense.SlotTimestamp, defense.Slot, err = GetValidatorProof(c, 0, w, cfg, pubkey, nil) + if err == nil { + return defense, nil + } + failures = append(failures, err) + continue + } + index, parseErr := strconv.ParseUint(status.Index, 10, 64) + if parseErr != nil { + failures = append(failures, parseErr) + continue + } + // Missing historical data for one epoch must not hide a later defense. + proofs, searchErr := findParticipationDefense(epochs, func(epoch uint64) (bool, error) { + _, _, found, err := performance.FindFirstTimelyTargetVote(bc, cfg, []uint64{index}, []uint64{epoch}) + return found, err + }, func(epoch uint64) (PerformanceDefenseProofs, error) { + return GetParticipationProof(c, index, pubkey, epoch, challenge.StartEpoch, challenge.Participation) + }) + if searchErr != nil { + failures = append(failures, searchErr) + continue + } + if proofs != nil { + defense.SlotTimestamp = proofs.SlotTimestamp + defense.Validator = proofs.Validator + defense.Slot = proofs.Slot + defense.Participation = &proofs.Participation + defense.Offset = proofs.Offset + defense.ChallengeLeaf = proofs.ChallengeLeaf + defense.ChallengeWitness = proofs.ChallengeWitness + return defense, nil + } + } + if len(failures) > 0 { + return megapool.PerformanceChallengeDefense{}, fmt.Errorf("could not complete defense search: %w", errors.Join(failures...)) + } + return megapool.PerformanceChallengeDefense{}, fmt.Errorf("no activation or timely-target defense found for challenge %s", challenge.ChallengeId) +} + +// findParticipationDefense keeps searching when a beacon endpoint lacks one +// epoch or a proof cannot be built. A positive vote alone is not a usable proof. +func findParticipationDefense(epochs []uint64, find func(uint64) (bool, error), build func(uint64) (PerformanceDefenseProofs, error)) (*PerformanceDefenseProofs, error) { + var failures []error + for _, epoch := range epochs { + found, err := find(epoch) + if err != nil { + failures = append(failures, fmt.Errorf("epoch %d: %w", epoch, err)) + continue + } + if !found { + continue + } + proof, err := build(epoch) + if err != nil { + failures = append(failures, fmt.Errorf("epoch %d: %w", epoch, err)) + continue + } + return &proof, nil + } + return nil, errors.Join(failures...) +} diff --git a/shared/services/performance-challenges_test.go b/shared/services/performance-challenges_test.go new file mode 100644 index 000000000..6fcba1afb --- /dev/null +++ b/shared/services/performance-challenges_test.go @@ -0,0 +1,116 @@ +package services + +import ( + "fmt" + "math/big" + "reflect" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestPerformanceChallengeRequestValidation(t *testing.T) { + valid32 := make([]uint32, 32) + for i := range valid32 { + valid32[i] = uint32(i) + } + for _, test := range []struct { + name string + request PerformanceChallengeRequest + valid bool + }{ + { + name: "empty", + request: PerformanceChallengeRequest{}, + valid: false, + }, + { + name: "validator zero", + request: PerformanceChallengeRequest{ValidatorIds: []uint32{0}}, + valid: true, + }, + { + name: "32 validators", + request: PerformanceChallengeRequest{ValidatorIds: valid32}, + valid: true, + }, + { + name: "33 validators", + request: PerformanceChallengeRequest{ValidatorIds: append(append([]uint32{}, valid32...), 32)}, + valid: false, + }, + { + name: "duplicate validator", + request: PerformanceChallengeRequest{ValidatorIds: []uint32{0, 0}}, + valid: false, + }, + { + name: "minipool", + request: PerformanceChallengeRequest{MinipoolAddresses: []common.Address{{1}}}, + valid: true, + }, + { + name: "zero minipool", + request: PerformanceChallengeRequest{MinipoolAddresses: []common.Address{{}}}, + valid: false, + }, + { + name: "duplicate minipool", + request: PerformanceChallengeRequest{MinipoolAddresses: []common.Address{{1}, {1}}}, + valid: false, + }, + { + name: "mixed types", + request: PerformanceChallengeRequest{ValidatorIds: []uint32{0}, MinipoolAddresses: []common.Address{{1}}}, + valid: false, + }, + { + name: "mixed addresses", + request: PerformanceChallengeRequest{MegapoolAddress: common.Address{1}, MinipoolAddresses: []common.Address{{1}}}, + valid: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + if err := test.request.Validate(); (err == nil) != test.valid { + t.Fatalf("valid=%t, err=%v", test.valid, err) + } + }) + } +} + +func TestAvailablePerformanceBondUsesUnlockedStake(t *testing.T) { + for _, test := range []struct{ stake, locked, want int64 }{{100, 40, 60}, {100, 100, 0}, {90, 100, 0}, {0, 0, 0}} { + stake, locked := big.NewInt(test.stake), big.NewInt(test.locked) + got := AvailablePerformanceBond(stake, locked) + if got.Int64() != test.want || stake.Int64() != test.stake || locked.Int64() != test.locked { + t.Fatalf("incorrect available stake for %+v: %s", test, got) + } + } +} + +func TestDefenseSearchContinuesPastUnavailableHistory(t *testing.T) { + var attempted []uint64 + proof, err := findParticipationDefense([]uint64{10, 11, 12, 13}, func(epoch uint64) (bool, error) { + if epoch == 10 { + return false, fmt.Errorf("pruned block") + } + return epoch != 11, nil + }, func(epoch uint64) (PerformanceDefenseProofs, error) { + attempted = append(attempted, epoch) + if epoch == 12 { + return PerformanceDefenseProofs{}, fmt.Errorf("pruned SSZ state") + } + return PerformanceDefenseProofs{Offset: 3}, nil + }) + if err != nil || proof == nil || proof.Offset != 3 || !reflect.DeepEqual(attempted, []uint64{12, 13}) { + t.Fatalf("later valid proof lost: %+v, %v, %v", proof, err, attempted) + } + proof, err = findParticipationDefense([]uint64{10}, func(uint64) (bool, error) { return false, fmt.Errorf("pruned") }, nil) + if proof != nil || err == nil { + t.Fatal("missing history must not be reported as proven absence of a defense") + } + proof, err = findParticipationDefense([]uint64{10}, func(uint64) (bool, error) { return false, nil }, nil) + if proof != nil || err != nil { + t.Fatal("valid search with no defense must be distinguishable from unavailable data") + } +} diff --git a/shared/services/performance/challenge-rules_test.go b/shared/services/performance/challenge-rules_test.go new file mode 100644 index 000000000..5a84dafec --- /dev/null +++ b/shared/services/performance/challenge-rules_test.go @@ -0,0 +1,187 @@ +package performance + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/prysmaticlabs/go-bitfield" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +func missedWords(period, misses uint64) []*big.Int { + words := make([]*big.Int, (period+255)/256) + for i := range words { + words[i] = new(big.Int) + } + for i := uint64(0); i < misses; i++ { + words[i/256].SetBit(words[i/256], int(i%256), 1) + } + return words +} + +func TestValidateChallengeBitmapContractBoundaries(t *testing.T) { + params := ChallengeParams{ExitsEnabled: true, PeriodEpochs: 1000, ProofBufferEpochs: 225, ThresholdWei: big.NewInt(940000000000000000)} + for _, test := range []struct { + name string + current, start uint64 + words []*big.Int + valid bool + }{ + { + name: "exact minimum in partial period", + current: 160, + start: 100, + words: missedWords(1000, 60), + valid: true, + }, + { + name: "one below minimum", + current: 160, + start: 100, + words: missedWords(1000, 59), + valid: false, + }, + { + name: "current epoch marked missed", + current: 159, + start: 100, + words: missedWords(1000, 60), + valid: false, + }, + { + name: "start equals current", + current: 100, + start: 100, + words: missedWords(1000, 60), + valid: false, + }, + { + name: "future start", + current: 99, + start: 100, + words: missedWords(1000, 60), + valid: false, + }, + { + name: "last valid buffer epoch", + current: 1324, + start: 100, + words: missedWords(1000, 60), + valid: true, + }, + { + name: "buffer boundary", + current: 1325, + start: 100, + words: missedWords(1000, 60), + valid: false, + }, + { + name: "truncated partial period bitmap", + current: 160, + start: 100, + words: missedWords(60, 60), + valid: false, + }, + { + name: "padding bit", + current: 1200, + start: 100, + words: missedWords(1000, 1001), + valid: false, + }, + { + name: "missing word", + current: 1200, + start: 100, + words: []*big.Int{nil, big.NewInt(0), big.NewInt(0), big.NewInt(0)}, + valid: false, + }, + { + name: "negative word", + current: 1200, + start: 100, + words: []*big.Int{big.NewInt(-1), big.NewInt(0), big.NewInt(0), big.NewInt(0)}, + valid: false, + }, + { + name: "uint256 overflow", + current: 1200, + start: 100, + words: []*big.Int{new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(0), big.NewInt(0), big.NewInt(0)}, + valid: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + if err := ValidateChallengeBitmap(params, test.current, test.start, test.words); (err == nil) != test.valid { + t.Fatalf("valid=%t, err=%v", test.valid, err) + } + }) + } + params.PeriodEpochs = 1001 // ceil(60.06) = 61, not 60 + if ValidateChallengeBitmap(params, 200, 100, missedWords(1001, 60)) == nil { + t.Fatal("fractional minimum rounded down") + } + if err := ValidateChallengeBitmap(params, 200, 100, missedWords(1001, 61)); err != nil { + t.Fatal(err) + } + params.ExitsEnabled = false + if ValidateChallengeBitmap(params, 200, 100, missedWords(1001, 61)) == nil { + t.Fatal("exits disabled") + } +} + +func TestPartialWindowUsesFullPeriodThreshold(t *testing.T) { + params := ChallengeParams{ExitsEnabled: true, PeriodEpochs: 1000, ProofBufferEpochs: 225, ThresholdWei: big.NewInt(940000000000000000)} + for _, misses := range []uint64{59, 60} { + resp := &api.VerifyPerformanceResponse{StartEpoch: 100, EndEpoch: 199, Participation: missedWords(100, misses)} + SetChallengeability(resp, params, 202) + if resp.Challengeable != (misses == 60) || len(resp.Participation) != 4 { + t.Fatalf("incorrect partial window (%d misses): %+v", misses, resp) + } + resp.InactiveEpochs = 1 + SetChallengeability(resp, params, 202) + if resp.Challengeable { + t.Fatal("inactive member would allow activation defense") + } + } +} + +func TestMeasurementWaitsForFollowingEpoch(t *testing.T) { + for _, test := range []struct { + head, start, end uint64 + valid bool + }{{12, 10, 10, true}, {11, 10, 10, false}, {10, 10, 10, false}, {0, 0, 0, false}, {12, 10, 9, false}, {^uint64(0), 0, ^uint64(0), false}} { + if err := ValidateMeasurementRange(test.head, test.start, test.end); (err == nil) != test.valid { + t.Fatalf("%+v: %v", test, err) + } + } +} + +func TestDenebTargetInclusionWindow(t *testing.T) { + for _, inclusion := range []uint64{321, 352, 360, 383, 384} { + c := newEpochCache(nil, beacon.Eth2Config{SlotsPerEpoch: 32}, nil, nil) + root := common.HexToHash("0x01") + duty := attestationDuty{slot: 320, committeeIndex: 0, position: 0, committeeSizesAtDay: map[uint64]int{0: 1}} + c.epochDuties[10] = map[string]attestationDuty{"1": duty} + c.targetRoots[10] = rootResult{root: root} + for slot := uint64(321); slot <= 384; slot++ { + c.attestations[slot] = cachedAttestations{} + } + bits := bitfield.NewBitlist(1) + bits.SetBitAt(0, true) + att := beacon.AttestationInfo{SlotIndex: 320, TargetEpoch: 10, TargetRoot: root, AggregationBits: bits} + att.Committees = bitfield.NewBitvector64() + att.Committees.SetBitAt(0, true) + c.attestations[inclusion] = cachedAttestations{exists: true, attestations: []beacon.AttestationInfo{att}} + got, err := c.evaluateEpoch("1", 1, 10) + if err != nil { + t.Fatal(err) + } + if (got == epochResultTimely) != (inclusion < 384) { + t.Fatalf("inclusion %d: got %v", inclusion, got) + } + } +} diff --git a/shared/services/performance/target-performance.go b/shared/services/performance/target-performance.go index 0841f0ddc..67c03a35e 100644 --- a/shared/services/performance/target-performance.go +++ b/shared/services/performance/target-performance.go @@ -8,7 +8,7 @@ // 1. included in a block (which implies source-checkpoint matching), AND // 2. voting for the correct target root, i.e. data.target.root equals the // canonical block root at the first slot of epoch E, AND -// 3. included within SLOTS_PER_EPOCH slots of data.slot. +// 3. included before the end of the following epoch (Deneb and later). package performance import ( @@ -64,6 +64,7 @@ type ChallengeParams struct { ExitsEnabled bool PeriodEpochs uint64 ProofBufferEpochs uint64 + ThresholdWei *big.Int } // GetChallengeParams fetches the pDAO performance-challenge settings. @@ -87,7 +88,12 @@ func GetChallengeParams(rp *rocketpool.RocketPool) (ChallengeParams, error) { if err != nil { return ChallengeParams{}, err } + threshold, err := protocol.GetPerformanceThreshold(rp, nil) + if err != nil { + return ChallengeParams{}, err + } return ChallengeParams{ + ThresholdWei: threshold, ExitsEnabled: exitsEnabled, PeriodEpochs: periodEpochs, ProofBufferEpochs: proofBufferEpochs, @@ -118,33 +124,129 @@ func IsRangeChallengeable(rp *rocketpool.RocketPool, bc challengeBeaconClient, s // ExceedsChallengeThreshold reports whether the validator missed enough // target votes for a challenge to succeed: the missed share of the checked -// period must be higher than the allowed slack (100% - performance_threshold). +// period must be at least the allowed slack (100% - performance_threshold). func ExceedsChallengeThreshold(resp *api.VerifyPerformanceResponse) bool { if resp.TotalEpochs == 0 { return false } - missedPct := float64(resp.MissedEpochs) / float64(resp.TotalEpochs) * 100.0 - return missedPct > 100.0-resp.PerformanceThresholdPct + // Decimal conversion avoids binary floating-point errors at an exact threshold. + threshold, ok := new(big.Rat).SetString(strconv.FormatFloat(resp.PerformanceThresholdPct, 'f', -1, 64)) + if !ok { + return false + } + missed := new(big.Rat).SetInt(new(big.Int).SetUint64(resp.MissedEpochs)) + required := new(big.Rat).Sub(big.NewRat(100, 1), threshold) + required.Mul(required, new(big.Rat).SetInt(new(big.Int).SetUint64(resp.TotalEpochs))) + missed.Mul(missed, big.NewRat(100, 1)) + return missed.Cmp(required) >= 0 } // IsChallengeable reports whether a performance check over the inclusive // range [startEpoch, endEpoch] could back an on-chain challenge: performance -// exits must be enabled, the range must cover exactly one performance period, +// exits must be enabled, the range must cover at most one performance period, // and it must be recent enough that the proof buffer has not elapsed // (startEpoch > currentEpoch - period - proofBuffer). func IsChallengeable(params ChallengeParams, currentEpoch, startEpoch, endEpoch uint64) bool { - if !params.ExitsEnabled { + if !params.ExitsEnabled || params.PeriodEpochs == 0 { return false } - if endEpoch != startEpoch+params.PeriodEpochs-1 { + if endEpoch < startEpoch || endEpoch >= currentEpoch { return false } - window := params.PeriodEpochs + params.ProofBufferEpochs - if currentEpoch <= window { - // The whole chain history is still within the challenge window. - return true + if endEpoch-startEpoch >= params.PeriodEpochs { + return false + } + // Avoid overflow when evaluating the contract's start + period + buffer bound. + age := new(big.Int).SetUint64(currentEpoch - startEpoch) + window := new(big.Int).Add(new(big.Int).SetUint64(params.PeriodEpochs), new(big.Int).SetUint64(params.ProofBufferEpochs)) + return age.Cmp(window) < 0 +} + +// ValidateMeasurementRange requires the entire inclusion window to have closed. +// Epoch E can still acquire a timely-target vote throughout epoch E+1. +func ValidateMeasurementRange(currentEpoch, startEpoch, endEpoch uint64) error { + if endEpoch < startEpoch { + return fmt.Errorf("end epoch must not precede start epoch") + } + if currentEpoch < 2 || endEpoch > currentEpoch-2 { + return fmt.Errorf("target-vote measurement requires a completed following epoch; latest measurable epoch is two epochs before the beacon head") + } + return nil +} + +// ValidateChallengeBitmap mirrors the contract's fixed-period bitmap, elapsed-epoch +// padding and ceil(period * (1 - threshold)) minimum. currentEpoch is the epoch +// of the actual slot proof, not a separately fetched beacon head. +func ValidateChallengeBitmap(params ChallengeParams, currentEpoch, startEpoch uint64, words []*big.Int) error { + if !params.ExitsEnabled || params.PeriodEpochs == 0 || startEpoch >= currentEpoch { + return fmt.Errorf("performance exits disabled or invalid challenge start") + } + if !IsChallengeable(params, currentEpoch, startEpoch, startEpoch) { + return fmt.Errorf("challenge start is outside the proof buffer") + } + wordCount := params.PeriodEpochs / 256 + if params.PeriodEpochs%256 != 0 { + wordCount++ + } + if uint64(len(words)) != wordCount { + return fmt.Errorf("participation bitmap requires %d words", wordCount) + } + elapsed := currentEpoch - startEpoch + if elapsed > params.PeriodEpochs { + elapsed = params.PeriodEpochs + } + var missed uint64 + for i, word := range words { + if word == nil || word.Sign() < 0 || word.BitLen() > 256 { + return fmt.Errorf("participation word %d is not a uint256", i) + } + for bit := 0; bit < word.BitLen(); bit++ { + if word.Bit(bit) == 0 { + continue + } + if uint64(i)*256+uint64(bit) >= elapsed { + return fmt.Errorf("participation bitmap contains future or padding bits") + } + missed++ + } + } + base := big.NewInt(1e18) + if params.ThresholdWei == nil || params.ThresholdWei.Sign() < 0 || params.ThresholdWei.Cmp(base) > 0 { + return fmt.Errorf("invalid performance threshold") + } + required := new(big.Int).Sub(base, params.ThresholdWei) + required.Mul(required, new(big.Int).SetUint64(params.PeriodEpochs)) + required.Add(required, new(big.Int).Sub(base, big.NewInt(1))) + required.Div(required, base) + if new(big.Int).SetUint64(missed).Cmp(required) < 0 { + return fmt.Errorf("challenge needs at least %s missed epochs", required) + } + return nil +} + +// SetChallengeability pads partial measurement windows to the contract period. +func SetChallengeability(resp *api.VerifyPerformanceResponse, params ChallengeParams, currentEpoch uint64) { + resp.Challengeable = false + if !IsChallengeable(params, currentEpoch, resp.StartEpoch, resp.EndEpoch) || resp.InactiveEpochs > 0 { + return + } + words := params.PeriodEpochs / 256 + if params.PeriodEpochs%256 != 0 { + words++ + } + // Settings are contract-bounded, but avoid an allocation from an invalid RPC response. + if words > 1<<20 { + return + } + bitmap := make([]*big.Int, int(words)) + for i := range bitmap { + bitmap[i] = new(big.Int) + if i < len(resp.Participation) { + bitmap[i].Set(resp.Participation[i]) + } } - return startEpoch > currentEpoch-window + resp.Participation = bitmap + resp.Challengeable = ValidateChallengeBitmap(params, currentEpoch, resp.StartEpoch, bitmap) == nil } // farFutureEpoch is the spec's FAR_FUTURE_EPOCH sentinel (2^64-1). @@ -184,7 +286,7 @@ type PerformanceSummary struct { type PerformanceBeaconClient interface { GetEth2Config() (beacon.Eth2Config, error) GetHistoricalCommitteesForEpoch(epoch uint64) (beacon.Committees, error) - GetBeaconBlock(blockId string) (beacon.BeaconBlock, bool, error) + GetAttestations(blockId string) ([]beacon.AttestationInfo, bool, error) GetBeaconBlockHeader(blockId string) (beacon.BeaconBlockHeader, bool, error) GetValidatorStatusByIndex(index string, opts *beacon.ValidatorStatusOptions) (beacon.ValidatorStatus, error) } @@ -275,7 +377,7 @@ func isActiveValidatorState(state beacon.ValidatorState) bool { // VerifyPerformanceBatch verifies the RPIP-73 target-vote performance of many // validators over the same inclusive epoch range in a single pass. All // per-epoch beacon data (target roots, committee assignments, inclusion-window -// blocks) is fetched once via a shared epochCache and reused for every +// attestations) is fetched once via a shared epochCache and reused for every // validator, and the validators' indices and statuses are resolved in a single // batched beacon call. // @@ -486,11 +588,11 @@ type attestationDuty struct { committeeSizesAtDay map[uint64]int // committee_index -> validator count, for all committees at duty.slot } -// cachedBlock memoizes a single GetBeaconBlock lookup, including the "missing" +// cachedAttestations memoizes a single GetAttestations lookup, including the "missing" // (skipped slot) case. -type cachedBlock struct { - block beacon.BeaconBlock - exists bool +type cachedAttestations struct { + attestations []beacon.AttestationInfo + exists bool } // rootResult memoizes a target-root resolution, including its error. @@ -507,10 +609,10 @@ type epochCache struct { cfg beacon.Eth2Config indexSet map[string]struct{} - targetRoots map[uint64]rootResult // epoch -> target root - epochDuties map[uint64]map[string]attestationDuty // epoch -> validator index string -> duty - blocks map[uint64]cachedBlock // slot -> block - statuses map[string]beacon.ValidatorStatus // validator index string -> status + targetRoots map[uint64]rootResult // epoch -> target root + epochDuties map[uint64]map[string]attestationDuty // epoch -> validator index string -> duty + attestations map[uint64]cachedAttestations // slot -> attestations + statuses map[string]beacon.ValidatorStatus // validator index string -> status } // newEpochCache creates a cache that tracks the supplied validator index set. @@ -527,13 +629,13 @@ func newEpochCache( statuses = map[string]beacon.ValidatorStatus{} } return &epochCache{ - bc: bc, - cfg: cfg, - indexSet: indexSet, - targetRoots: map[uint64]rootResult{}, - epochDuties: map[uint64]map[string]attestationDuty{}, - blocks: map[uint64]cachedBlock{}, - statuses: statuses, + bc: bc, + cfg: cfg, + indexSet: indexSet, + targetRoots: map[uint64]rootResult{}, + epochDuties: map[uint64]map[string]attestationDuty{}, + attestations: map[uint64]cachedAttestations{}, + statuses: statuses, } } @@ -607,18 +709,18 @@ func (c *epochCache) evaluateEpoch(indexStr string, indexU64 uint64, epoch uint6 } // Scan the inclusion window for a matching attestation. The inclusion - // window for the target flag is up to SLOTS_PER_EPOCH slots after the duty - // slot. Return as soon as we find a match. - inclusionEndExclusive := duty.slot + 1 + c.cfg.SlotsPerEpoch + // window for the target flag extends through the following epoch under + // Deneb/EIP-7045. Return as soon as we find a match. + inclusionEndExclusive := (epoch + 2) * c.cfg.SlotsPerEpoch for slot := duty.slot + 1; slot < inclusionEndExclusive; slot++ { - block, exists, err := c.block(slot) + attestations, exists, err := c.attestationsForSlot(slot) if err != nil { - return epochResultMissed, fmt.Errorf("error getting block at slot %d: %w", slot, err) + return epochResultMissed, fmt.Errorf("error getting attestations at slot %d: %w", slot, err) } if !exists { continue } - if matchesDuty(block.Attestations, duty, epoch, targetRoot) { + if matchesDuty(attestations, duty, epoch, targetRoot) { return epochResultTimely, nil } } @@ -636,19 +738,18 @@ func (c *epochCache) targetRoot(epoch uint64) (common.Hash, error) { return root, err } -// block returns the beacon block at the given slot, memoized per slot. The -// returned bool reports whether a block exists at that slot (false for a -// skipped slot). -func (c *epochCache) block(slot uint64) (beacon.BeaconBlock, bool, error) { - if b, ok := c.blocks[slot]; ok { - return b.block, b.exists, nil +// attestationsForSlot fetches only attestations, avoiding execution payloads. +// Results are shared across validators, including slots without a block. +func (c *epochCache) attestationsForSlot(slot uint64) ([]beacon.AttestationInfo, bool, error) { + if cached, ok := c.attestations[slot]; ok { + return cached.attestations, cached.exists, nil } - block, exists, err := c.bc.GetBeaconBlock(strconv.FormatUint(slot, 10)) + attestations, exists, err := c.bc.GetAttestations(strconv.FormatUint(slot, 10)) if err != nil { - return beacon.BeaconBlock{}, false, err + return nil, false, err } - c.blocks[slot] = cachedBlock{block: block, exists: exists} - return block, exists, nil + c.attestations[slot] = cachedAttestations{attestations: attestations, exists: exists} + return attestations, exists, nil } // status returns the validator status for the given index string, memoized and diff --git a/shared/services/performance/target-performance_test.go b/shared/services/performance/target-performance_test.go index 3a524c8bd..69348532a 100644 --- a/shared/services/performance/target-performance_test.go +++ b/shared/services/performance/target-performance_test.go @@ -50,12 +50,12 @@ func TestIsChallengeable(t *testing.T) { want: false, }, { - name: "range one epoch too short", + name: "partial period is allowed", params: params, currentEpoch: 10000, startEpoch: 9000, endEpoch: 9998, - want: false, + want: true, }, { name: "start epoch just inside the window", @@ -122,7 +122,7 @@ func TestExceedsChallengeThreshold(t *testing.T) { totalEpochs: 256, missedEpochs: 16, thresholdPct: 93.75, - want: false, + want: true, }, { name: "missed share above the allowed slack", diff --git a/shared/services/rocketpool/challenge-bonds.go b/shared/services/rocketpool/challenge-bonds.go new file mode 100644 index 000000000..d1c0895ee --- /dev/null +++ b/shared/services/rocketpool/challenge-bonds.go @@ -0,0 +1,24 @@ +package rocketpool + +import ( + "math/big" + "net/url" + + "github.com/rocket-pool/smartnode/shared/types/api" +) + +func (c *Client) CanReleaseChallengeBond(id *big.Int) (api.CanSettleChallengeBondResponse, error) { + return c.callAPI[api.CanSettleChallengeBondResponse]("GET", "/api/node/can-release-challenge-bond", url.Values{"challengeId": {id.String()}}, "Could not check challenge bond release") +} + +func (c *Client) ReleaseChallengeBond(id *big.Int) (api.SettleChallengeBondResponse, error) { + return c.callAPI[api.SettleChallengeBondResponse]("POST", "/api/node/release-challenge-bond", url.Values{"challengeId": {id.String()}}, "Could not release challenge bond") +} + +func (c *Client) CanClaimChallengeReward(id *big.Int) (api.CanSettleChallengeBondResponse, error) { + return c.callAPI[api.CanSettleChallengeBondResponse]("GET", "/api/node/can-claim-challenge-reward", url.Values{"challengeId": {id.String()}}, "Could not check challenge reward claim") +} + +func (c *Client) ClaimChallengeReward(id *big.Int) (api.SettleChallengeBondResponse, error) { + return c.callAPI[api.SettleChallengeBondResponse]("POST", "/api/node/claim-challenge-reward", url.Values{"challengeId": {id.String()}}, "Could not claim challenge reward") +} diff --git a/shared/services/rocketpool/megapool.go b/shared/services/rocketpool/megapool.go index 0c97eb9b4..1d4c685f9 100644 --- a/shared/services/rocketpool/megapool.go +++ b/shared/services/rocketpool/megapool.go @@ -47,13 +47,17 @@ func (c *Client) VerifyMegapoolValidatorPerformance(megapoolAddress common.Addre // challengePerformanceValues builds the shared parameters of the // can-challenge-performance and challenge-performance calls. participation is // serialized as comma-separated decimal strings. -func challengePerformanceValues(megapoolAddress common.Address, validatorId uint32, startEpoch uint64, participation []*big.Int) url.Values { +func challengePerformanceValues(megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int) url.Values { words := make([]string, len(participation)) for i, word := range participation { words[i] = word.String() } + ids := make([]string, len(validatorIds)) + for i, id := range validatorIds { + ids[i] = strconv.FormatUint(uint64(id), 10) + } values := url.Values{ - "validatorId": {strconv.FormatUint(uint64(validatorId), 10)}, + "validatorIds": {strings.Join(ids, ",")}, "startEpoch": {strconv.FormatUint(startEpoch, 10)}, "participation": {strings.Join(words, ",")}, } @@ -64,45 +68,24 @@ func challengePerformanceValues(megapoolAddress common.Address, validatorId uint } // CanChallengeMegapoolPerformance checks whether the node can challenge the -// target-vote performance of a megapool validator, returning the -// challenge bond, the node's RPL balance, and the gas estimate. If +// target-vote performance of a megapool validator list, returning the +// challenge bond, unlocked staked RPL, and the gas estimate. If // megapoolAddress is the zero address the daemon resolves the node's own // megapool. This call has no client-side deadline because the gas estimate // requires downloading a beacon state to build the slot proof. -func (c *Client) CanChallengeMegapoolPerformance(megapoolAddress common.Address, validatorId uint32, startEpoch uint64, participation []*big.Int) (api.CanChallengeMegapoolPerformanceResponse, error) { - values := challengePerformanceValues(megapoolAddress, validatorId, startEpoch, participation) - responseBytes, err := c.callHTTPAPICtx(context.Background(), "GET", "/api/megapool/can-challenge-performance", values) - if err != nil { - return api.CanChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not get can challenge megapool performance status: %w", err) - } - var response api.CanChallengeMegapoolPerformanceResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return api.CanChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not decode can challenge megapool performance response: %w", err) - } - if response.Error != "" { - return api.CanChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not get can challenge megapool performance status: %s", response.Error) - } - return response, nil +func (c *Client) CanChallengeMegapoolPerformance(megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int) (api.CanChallengeMegapoolPerformanceResponse, error) { + values := challengePerformanceValues(megapoolAddress, validatorIds, startEpoch, participation) + return c.callAPICtx[api.CanChallengeMegapoolPerformanceResponse](context.Background(), "GET", "/api/megapool/can-challenge-performance", values, "Could not get can challenge megapool performance status") } // ChallengeMegapoolPerformance submits a target-vote performance challenge -// against a megapool validator. This call has no client-side +// against a megapool validator list. This call has no client-side // deadline because the transaction requires downloading a beacon state to // build the slot proof. -func (c *Client) ChallengeMegapoolPerformance(megapoolAddress common.Address, validatorId uint32, startEpoch uint64, participation []*big.Int) (api.ChallengeMegapoolPerformanceResponse, error) { - values := challengePerformanceValues(megapoolAddress, validatorId, startEpoch, participation) - responseBytes, err := c.callHTTPAPICtx(context.Background(), "POST", "/api/megapool/challenge-performance", values) - if err != nil { - return api.ChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not challenge megapool performance: %w", err) - } - var response api.ChallengeMegapoolPerformanceResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return api.ChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not decode challenge megapool performance response: %w", err) - } - if response.Error != "" { - return api.ChallengeMegapoolPerformanceResponse{}, fmt.Errorf("Could not challenge megapool performance: %s", response.Error) - } - return response, nil +func (c *Client) ChallengeMegapoolPerformance(megapoolAddress common.Address, validatorIds []uint32, startEpoch uint64, participation []*big.Int, maxBond *big.Int) (api.ChallengeMegapoolPerformanceResponse, error) { + values := challengePerformanceValues(megapoolAddress, validatorIds, startEpoch, participation) + values.Set("maxBond", maxBond.String()) + return c.callAPICtx[api.ChallengeMegapoolPerformanceResponse](context.Background(), "POST", "/api/megapool/challenge-performance", values, "Could not challenge megapool performance") } // MegapoolDeficit returns the deficit and the first eligible validators needed diff --git a/shared/services/rocketpool/performance-challenges.go b/shared/services/rocketpool/performance-challenges.go new file mode 100644 index 000000000..aec93b2f9 --- /dev/null +++ b/shared/services/rocketpool/performance-challenges.go @@ -0,0 +1,33 @@ +package rocketpool + +import ( + "context" + "math/big" + "net/url" + + "github.com/rocket-pool/smartnode/shared/types/api" +) + +func (c *Client) GetPerformanceChallenges(id *big.Int) (api.PerformanceChallengesResponse, error) { + values := url.Values{} + if id != nil { + values.Set("challengeId", id.String()) + } + return c.callAPICtx[api.PerformanceChallengesResponse](context.Background(), "GET", "/api/node/performance-challenges", values, "Could not inspect performance challenges") +} + +func (c *Client) CanDefendPerformanceChallenge(id *big.Int) (api.CanActOnPerformanceChallengeResponse, error) { + return c.callAPICtx[api.CanActOnPerformanceChallengeResponse](context.Background(), "GET", "/api/node/can-defend-performance-challenge", url.Values{"challengeId": {id.String()}}, "Could not check performance challenge defense") +} + +func (c *Client) DefendPerformanceChallenge(id *big.Int) (api.PerformanceChallengeResponse, error) { + return c.callAPICtx[api.PerformanceChallengeResponse](context.Background(), "POST", "/api/node/defend-performance-challenge", url.Values{"challengeId": {id.String()}}, "Could not defend performance challenge") +} + +func (c *Client) CanFinalisePerformanceChallenge(id *big.Int) (api.CanActOnPerformanceChallengeResponse, error) { + return c.callAPICtx[api.CanActOnPerformanceChallengeResponse](context.Background(), "GET", "/api/node/can-finalise-performance-challenge", url.Values{"challengeId": {id.String()}}, "Could not check performance challenge finalisation") +} + +func (c *Client) FinalisePerformanceChallenge(id *big.Int) (api.PerformanceChallengeResponse, error) { + return c.callAPICtx[api.PerformanceChallengeResponse](context.Background(), "POST", "/api/node/finalise-performance-challenge", url.Values{"challengeId": {id.String()}}, "Could not finalise performance challenge") +} diff --git a/shared/services/rocketpool/performance-minipools.go b/shared/services/rocketpool/performance-minipools.go new file mode 100644 index 000000000..b4733f402 --- /dev/null +++ b/shared/services/rocketpool/performance-minipools.go @@ -0,0 +1,37 @@ +package rocketpool + +import ( + "context" + "math/big" + "net/url" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +func challengeMinipoolValues(addresses []common.Address, start uint64, participation []*big.Int) url.Values { + values := challengePerformanceValues(common.Address{}, nil, start, participation) + values.Del("validatorIds") + members := make([]string, len(addresses)) + for i, address := range addresses { + members[i] = address.Hex() + } + values.Set("minipoolAddresses", strings.Join(members, ",")) + return values +} + +func (c *Client) CanChallengeMinipoolPerformance(addresses []common.Address, startEpoch uint64, participation []*big.Int) (api.CanChallengeMegapoolPerformanceResponse, error) { + values := challengeMinipoolValues(addresses, startEpoch, participation) + return c.callAPICtx[api.CanChallengeMegapoolPerformanceResponse](context.Background(), "GET", "/api/minipool/can-challenge-performance", values, "Could not get can challenge minipool performance status") +} + +// ChallengeMinipoolPerformance submits a target-vote performance challenge +// against a same-node minipool list. This call has no client-side +// deadline because the transaction requires downloading a beacon state to +// build the slot proof. +func (c *Client) ChallengeMinipoolPerformance(addresses []common.Address, startEpoch uint64, participation []*big.Int, maxBond *big.Int) (api.ChallengeMegapoolPerformanceResponse, error) { + values := challengeMinipoolValues(addresses, startEpoch, participation) + values.Set("maxBond", maxBond.String()) + return c.callAPICtx[api.ChallengeMegapoolPerformanceResponse](context.Background(), "POST", "/api/minipool/challenge-performance", values, "Could not challenge minipool performance") +} diff --git a/shared/types/api/challenge-bonds.go b/shared/types/api/challenge-bonds.go new file mode 100644 index 000000000..d49bade21 --- /dev/null +++ b/shared/types/api/challenge-bonds.go @@ -0,0 +1,26 @@ +package api + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" +) + +type CanSettleChallengeBondResponse struct { + APIResponse + Saturn2Deployed bool `json:"saturn2Deployed"` + CanSettle bool `json:"canSettle"` + Reason string `json:"reason"` + Proposer common.Address `json:"proposer"` + Responder common.Address `json:"responder"` + BondAmount *big.Int `json:"bondAmount"` + EstimatedReward *big.Int `json:"estimatedReward"` + EstimatedBurn *big.Int `json:"estimatedBurn"` + GasLimits gaslimit.Limits `json:"gasLimits"` +} + +type SettleChallengeBondResponse struct { + APIResponse + TxHash common.Hash `json:"txHash"` +} diff --git a/shared/types/api/minipool.go b/shared/types/api/minipool.go index d296c6b2c..1080f14e4 100644 --- a/shared/types/api/minipool.go +++ b/shared/types/api/minipool.go @@ -320,13 +320,13 @@ type VerifyPerformanceResponse struct { PassesThreshold bool `json:"passesThreshold"` MissedEpochList []uint64 `json:"missedEpochList"` TimelyEpochList []uint64 `json:"timelyEpochList"` - // Participation is the challengeMegapool participation calldata (uint256[]): - // the epochs [StartEpoch, EndEpoch] as a bitset, 1 = not-timely target vote. + // Participation is challenge calldata (uint256[]): measured epochs as a + // bitset, 1 = missed target vote, padded to the configured challenge period. Participation []*big.Int `json:"participation"` // Challengeable reports whether this check could back an on-chain challenge: - // performance exits are enabled, the range is exactly one performance period, + // performance exits are enabled, the bitmap covers the configured performance period, // it is recent enough to be within the proof buffer, and the validator's - // missed share of the period exceeds 100% - performance_threshold. + // missed count meets ceil(period * (1 - performance_threshold)). Challengeable bool `json:"challengeable"` } @@ -336,6 +336,7 @@ type VerifyPerformanceResponse struct { // check succeeded; Error is populated instead when that single target failed so // one bad target does not abort the rest of the batch. type VerifyPerformanceResult struct { + NodeAddress common.Address `json:"nodeAddress"` MinipoolAddress common.Address `json:"minipoolAddress"` ValidatorId uint32 `json:"validatorId"` Performance *VerifyPerformanceResponse `json:"performance"` diff --git a/shared/types/api/node.go b/shared/types/api/node.go index 40381f9f2..22ad94c37 100644 --- a/shared/types/api/node.go +++ b/shared/types/api/node.go @@ -733,12 +733,12 @@ type ExitValidatorResponse struct { type CanNotifyValidatorExitResponse struct { APIResponse - CanExit bool `json:"canExit"` - InvalidStatus bool `json:"invalidStatus"` - AlreadyExiting bool `json:"alreadyExiting"` - AlreadyExited bool `json:"alreadyExited"` - ExitNotFinalized bool `json:"exitNotFinalized"` - GasLimits gaslimit.Limits `json:"gasLimits"` + CanExit bool `json:"canExit"` + InvalidStatus bool `json:"invalidStatus"` + AlreadyExiting bool `json:"alreadyExiting"` + AlreadyExited bool `json:"alreadyExited"` + ExitNotReady bool `json:"exitNotReady"` + GasLimits gaslimit.Limits `json:"gasLimits"` } type NotifyValidatorExitResponse struct { APIResponse @@ -746,17 +746,19 @@ type NotifyValidatorExitResponse struct { } type CanChallengeMegapoolPerformanceResponse struct { - Status string `json:"status"` - Error string `json:"error"` - CanChallenge bool `json:"canChallenge"` - InsufficientRplBalance bool `json:"insufficientRplBalance"` - ChallengeBond *big.Int `json:"challengeBond"` - RplBalance *big.Int `json:"rplBalance"` - GasLimits gaslimit.Limits `json:"gasLimits"` + APIResponse + RplLockingAllowed bool `json:"rplLockingAllowed"` + Reason string `json:"reason"` + CanChallenge bool `json:"canChallenge"` + InsufficientRplBalance bool `json:"insufficientRplBalance"` + ChallengeBond *big.Int `json:"challengeBond"` + // RplBalance is available unlocked staked RPL, not the liquid wallet balance. + RplBalance *big.Int `json:"rplBalance"` + GasLimits gaslimit.Limits `json:"gasLimits"` } + type ChallengeMegapoolPerformanceResponse struct { - Status string `json:"status"` - Error string `json:"error"` + APIResponse TxHash common.Hash `json:"txHash"` } diff --git a/shared/types/api/performance-challenges.go b/shared/types/api/performance-challenges.go new file mode 100644 index 000000000..febdae07b --- /dev/null +++ b/shared/types/api/performance-challenges.go @@ -0,0 +1,30 @@ +package api + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" +) + +type PerformanceChallengeDetails struct { + Challenge megapool.PerformanceChallenge `json:"challenge"` + State megapool.PerformanceChallengeStatus `json:"state"` +} + +type PerformanceChallengesResponse struct { + APIResponse + Saturn2Deployed bool `json:"saturn2Deployed"` + Challenges []PerformanceChallengeDetails `json:"challenges"` +} + +type CanActOnPerformanceChallengeResponse struct { + APIResponse + CanAct bool `json:"canAct"` + Reason string `json:"reason"` + GasLimits gaslimit.Limits `json:"gasLimits"` +} + +type PerformanceChallengeResponse struct { + APIResponse + TxHash common.Hash `json:"txHash"` +} diff --git a/shared/types/config/network.go b/shared/types/config/network.go index cc5a116d4..9de289c1b 100644 --- a/shared/types/config/network.go +++ b/shared/types/config/network.go @@ -93,6 +93,7 @@ type NetworkAddresses struct { V1_2_0_NetworkBalances string `yaml:"v1_2_0_NetworkBalances"` PreviousRewardsPools []string `yaml:"previousRewardsPools"` PreviousDAOVerifiers []string `yaml:"previousRocketDAOProtocolVerifier"` + PreviousNetworkExits []string `yaml:"previousRocketNetworkExit"` OptimismPriceMessenger string `yaml:"optimismPriceMessenger"` PolygonPriceMessenger string `yaml:"polygonPriceMessenger"` ArbitrumPriceMessenger string `yaml:"arbitrumPriceMessenger"`