diff --git a/bindings/megapool/megapool-constructor.go b/bindings/megapool/megapool-constructor.go new file mode 100644 index 000000000..4f240094b --- /dev/null +++ b/bindings/megapool/megapool-constructor.go @@ -0,0 +1,29 @@ +package megapool + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/rocketpool" +) + +// Create a megapool binding for the contract version deployed at the given address +func NewMegapool(rp *rocketpool.RocketPool, address common.Address, opts *bind.CallOpts) (Megapool, error) { + + // Get the contract version + version, err := rocketpool.GetContractVersion(rp, address, opts) + if err != nil { + return nil, fmt.Errorf("error getting megapool contract version: %w", err) + } + + switch version { + case 1: + return NewMegaPoolV1(rp, address, opts) + case 2: + return NewMegaPoolV2(rp, address, opts) + default: + return nil, fmt.Errorf("unexpected megapool contract version [%d]", version) + } +} diff --git a/bindings/megapool/megapool-contract-v2.go b/bindings/megapool/megapool-contract-v2.go new file mode 100644 index 000000000..a1c900f39 --- /dev/null +++ b/bindings/megapool/megapool-contract-v2.go @@ -0,0 +1,73 @@ +package megapool + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" +) + +type MegapoolV2 interface { + Megapool + EstimateForceExitGas(validatorIds []uint32, feeLimit *big.Int, opts *bind.TransactOpts) (gaslimit.Limits, error) + ForceExit(validatorIds []uint32, feeLimit *big.Int, opts *bind.TransactOpts) (common.Hash, error) +} + +// Megapool contract from delegate version 2, which adds support for EL-triggered forced exits +type megapoolV2 struct { + megapoolV1 +} + +const ( + megapoolV2EncodedAbi string = "eJztWktv2zgQ/isLn4M9tLs99OYmbhHATVMn3j0EgUFLY5sITaokZcco9r/vUC/rZYuyJEOtc0pkksN58ZsZDp9+DggXfLcWvhp8XBCm4GpAuedr/Hz6if+68ApuakiD5IQ97jwYfBz4+P3u7w+DqwEna/MDQUJc47fOTvjvqj4tTfFPkdJzMuErLIknBPskuDsB13eQeDIfNoCMmH1/fQFvYK5vuSOBqN9ZxN/ahveAVPRu6HmMNpFRS9+CrQ1h1CVayFv3zHL+E+88VIou+UWIegM/fPAvQ1SqlGCbi5B1xDsz6/t3FpLipDMJ+kr1ZVgUBaV8eQGSjoXzchEmfdDkMgSdctbQpn1MjyawJdJV14zgzO5l48KFYYvybQSOt0lQgl61Sc+TQgsH82si2iRra1dMF7Skc/9AeMlY8viOs4Nuud+UYHK9ixJtnCh8HZHHKUoTDV99TeaUURw2vsA9siNzlpJk4XNHU8EtuEuH8NnRGJ5iMMiJP/vcVdX8WTK3p+4Q5vgMyaAKXAxzkQ0yO1Ur2hyR/cpSX7E4E40oJE47/NaIjjlMhwmUKn1DYXuSO9R21sRab2Y6o5lOObXOijAGfAkmfWwTV1JbmPjXDWkXPAkOUuolIrph6dxP3qJaN0nBOjJQEiO7ob8EPcQJKTmuI3CqwJu0zkr1dNq5zHIW3RIhbzkvqMaNNsEiw5S5jOwLL1HJ2kfbjV49KomZ9Bimg71Q2JgonaSdfeMtqOD6aMs72CZshf2UHz6VsAYbBs+jvDtTu7muBFWRK5FkUoGleKhFloy2+qSi78GdZZ+4alSOdMbVBHBSv+LOVIG8Jh7VhPWJpdCjesZYT0C0tWQzLdItX4icRI5Ye4IjGlttyjAGhpmVAfDHwmVN0lmopGLiACi9T9CaUYlgqTYRLGKEotqaibkQbL9a5e6Ig9HKVZDrititovx7VNDUXHaPgcnwWZ9Nz4S0qarPq1ts5tmrJtNHsVuWv8A+tMrY9cNf2e0+EUa4kzM+TrJYHm5bPAVm9XPhrhMzR9/Rf0zMIh3fwj/gqSRLGJMdHso/8we1ACva97BC6yGqDLl7789fIHs9+gYvb/DyBi99hxcafmUhpkQnOw2p2seLj7tOD/cMmkox6bhc7UmUYelfqleuJFvCriW4iG6UsIqSJdi/PL2MhzrtNcwR9I739o6cmZmvYBQe8UdqXNLi0GUNsbf3fbmr1aDwgCGFaF+CLZGM40UofkM0mQihy80Qt09S9x2D/lw+V6PNbBt7KHIz8oSzOgWzZhIcdO4D61NqEpoudm33PbpXUth/u+VftkBt9FO4Mpo5pucj92uTm6M6JmJnMtBnipT2IewXMpRiIkggkcu1Vy3ondBnccba3VwZvF0u3Lq11sSRgEsKfYkm7wZaC+RxWtmB1BuQygyf/c6pyaOjApYI5t4Aw1RPwyE8qUcQg1cjgvWf88TbTb2lJG7r7+0KEi6kWLcj2llfs430yrypcIA2eiFdzk6h6NTlxVzLMmHlN8atlJ56CAblYtmAEMbTOXFeDj9TyHhYN3gS9HaTg9OLFlLMT9BMhYqeTdYF8tZvqau7WEDwXKBvmkoc0Y6xjnRlkTryTKOyRBlJQU9NeUlVtzlbruArBY5UNK9S9GnsWSCEDHHTKkt5ei7PU3IPyIKJNk/QZguAMV3T40neQkinlUdgz/8DcNqKAw==" +) + +// The decoded ABI for v2 megapools +var megapoolV2Abi *abi.ABI + +// Create new megapool contract +func NewMegaPoolV2(rp *rocketpool.RocketPool, address common.Address, opts *bind.CallOpts) (MegapoolV2, error) { + + var contract *rocketpool.Contract + var err error + if megapoolV2Abi == nil { + // Get contract + contract, err = createMegapoolContractFromEncodedAbi(rp, address, megapoolV2EncodedAbi) + } else { + contract, err = createMegapoolContractFromAbi(rp, address, megapoolV2Abi) + } + if err != nil { + return nil, err + } else if megapoolV2Abi == nil { + megapoolV2Abi = contract.ABI + } + + // Create and return + return &megapoolV2{ + megapoolV1: megapoolV1{ + Address: address, + Version: 2, + Contract: contract, + RocketPool: rp, + }, + }, nil +} + +// Estimate the gas of ForceExit +func (mp *megapoolV2) EstimateForceExitGas(validatorIds []uint32, feeLimit *big.Int, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return mp.Contract.GetTransactionGasInfo(opts, "forceExit", validatorIds, feeLimit) +} + +// Force exit megapool validators that failed to exit within the cooperative exit phase +func (mp *megapoolV2) ForceExit(validatorIds []uint32, feeLimit *big.Int, opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "forceExit", validatorIds, feeLimit) + if err != nil { + return common.Hash{}, fmt.Errorf("error force exiting megapool %s validators: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} diff --git a/bindings/megapool/megapool-contract.go b/bindings/megapool/megapool-contract.go index f22e04d1d..4936a126a 100644 --- a/bindings/megapool/megapool-contract.go +++ b/bindings/megapool/megapool-contract.go @@ -17,6 +17,14 @@ import ( rptypes "github.com/rocket-pool/smartnode/bindings/types" ) +type ParticipationProof struct { + ParticipationSlot uint64 + ValidatorIndex *big.Int + // ParticipationFlagsChunk is the 32-byte merkle chunk of previous_epoch_participation + ParticipationFlagsChunk [32]byte + Witnesses [][32]byte +} + type SlotProof struct { Slot uint64 `json:"slot"` Witnesses [][32]byte `json:"witnesses"` diff --git a/bindings/megapool/performance.go b/bindings/megapool/performance.go new file mode 100644 index 000000000..209abf7cb --- /dev/null +++ b/bindings/megapool/performance.go @@ -0,0 +1,110 @@ +package megapool + +import ( + "fmt" + "math/big" + "sync" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" +) + +// Estimate the gas to call ChallengeMegapool +func EstimateChallengeMegapoolGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, startEpoch uint64, participation []*big.Int, slotTimestamp uint64, slotProof SlotProof, opts *bind.TransactOpts) (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) +} + +// 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) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkParticipation.Transact(opts, "challengeMegapool", megapoolAddress, validatorId, startEpoch, participation, slotTimestamp, slotProof) + if err != nil { + return common.Hash{}, fmt.Errorf("error challenging megapool: %w", err) + } + return tx.Hash(), nil +} + +// Estimate the gas to call 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) { + 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) +} + +// Respond to a performance challenge with a proof that the validator did +// participate in one of the challenged epochs +func RespondWithParticipation(rp *rocketpool.RocketPool, challengeId uint64, offset uint64, challengeLeaf *big.Int, challengeWitness []common.Hash, slotTimestamp uint64, validatorProof ValidatorProof, participationProof ParticipationProof, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkParticipation.Transact(opts, "respondWithParticipation", challengeId, offset, challengeLeaf, challengeWitness, slotTimestamp, validatorProof, participationProof, slotProof) + if err != nil { + return common.Hash{}, fmt.Errorf("error responding to challenge: %w", err) + } + return tx.Hash(), nil +} + +// Estimate the gas to call RespondWithValidator +func EstimateRespondWithValidatorGas(rp *rocketpool.RocketPool, challengeId uint64, slotTimestamp uint64, validatorProof ValidatorProof, slotProof SlotProof, opts *bind.TransactOpts) (gaslimit.Limits, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + return rocketNetworkParticipation.GetTransactionGasInfo(opts, "respondWithValidator", challengeId, slotTimestamp, validatorProof, slotProof) +} + +// Respond to a performance challenge with a proof that the validator was not +// staking for the entire challenge period +func RespondWithValidator(rp *rocketpool.RocketPool, challengeId uint64, slotTimestamp uint64, validatorProof ValidatorProof, slotProof SlotProof, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkParticipation.Transact(opts, "respondWithValidator", challengeId, slotTimestamp, validatorProof, slotProof) + if err != nil { + return common.Hash{}, fmt.Errorf("error responding to challenge: %w", err) + } + return tx.Hash(), nil +} + +// Estimate the gas to call FinaliseChallenge +func EstimateFinaliseChallengeGas(rp *rocketpool.RocketPool, challengeId uint64, opts *bind.TransactOpts) (gaslimit.Limits, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + return rocketNetworkParticipation.GetTransactionGasInfo(opts, "finaliseChallenge", challengeId) +} + +func FinaliseChallenge(rp *rocketpool.RocketPool, challengeId uint64, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkParticipation, err := getRocketNetworkParticipation(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkParticipation.Transact(opts, "finaliseChallenge", challengeId) + if err != nil { + return common.Hash{}, fmt.Errorf("error finalising challenge: %w", err) + } + return tx.Hash(), nil +} + +// Get contracts +var rocketNetworkParticipationLock sync.Mutex + +func getRocketNetworkParticipation(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*rocketpool.Contract, error) { + rocketNetworkParticipationLock.Lock() + defer rocketNetworkParticipationLock.Unlock() + return rp.GetContract("rocketNetworkParticipation", opts) +} diff --git a/bindings/minipool/minipool-constructor.go b/bindings/minipool/minipool-constructor.go index 26ba289e3..b2ee611ec 100644 --- a/bindings/minipool/minipool-constructor.go +++ b/bindings/minipool/minipool-constructor.go @@ -33,6 +33,8 @@ func NewMinipool(rp *rocketpool.RocketPool, address common.Address, opts *bind.C return newMinipool_v2(rp, address) case 3: return newMinipool_v3(rp, address, opts) + case 4: + return newMinipool_v4(rp, address, opts) default: return nil, fmt.Errorf("unexpected minipool contract version [%d]", version) } @@ -45,6 +47,8 @@ func NewMinipoolFromVersion(rp *rocketpool.RocketPool, address common.Address, v return newMinipool_v2(rp, address) case 3: return newMinipool_v3(rp, address, opts) + case 4: + return newMinipool_v4(rp, address, opts) default: return nil, fmt.Errorf("unexpected minipool contract version [%d]", version) } diff --git a/bindings/minipool/minipool-contract-v4.go b/bindings/minipool/minipool-contract-v4.go new file mode 100644 index 000000000..0fbc5d63a --- /dev/null +++ b/bindings/minipool/minipool-contract-v4.go @@ -0,0 +1,667 @@ +package minipool + +import ( + "context" + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/core/types" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "golang.org/x/sync/errgroup" + + "github.com/rocket-pool/smartnode/bindings/logs" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/storage" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" + rptypes "github.com/rocket-pool/smartnode/bindings/types" + "github.com/rocket-pool/smartnode/shared/math" +) + +const ( + minipoolV4EncodedAbi string = "" +) + +type MinipoolV4 interface { + Minipool + EstimateReduceBondAmountGas(opts *bind.TransactOpts) (gaslimit.Limits, error) + ReduceBondAmount(opts *bind.TransactOpts) (common.Hash, error) + EstimatePromoteGas(opts *bind.TransactOpts) (gaslimit.Limits, error) + Promote(opts *bind.TransactOpts) (common.Hash, error) + GetPreMigrationBalance(opts *bind.CallOpts) (*big.Int, error) + GetUserDistributed(opts *bind.CallOpts) (bool, error) + EstimateDistributeBalanceGas(rewardsOnly bool, opts *bind.TransactOpts) (gaslimit.Limits, error) + DistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (common.Hash, error) + PrepareDistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (*types.Transaction, error) + ForceExit(opts *bind.TransactOpts) (common.Hash, error) + EstimateForceExitGas(opts *bind.TransactOpts) (gaslimit.Limits, error) +} + +// Minipool contract +type minipool_v4 struct { + Address common.Address + Version uint8 + Contract *rocketpool.Contract + RocketPool *rocketpool.RocketPool +} + +// The decoded ABI for v2 minipools +var minipoolV4Abi *abi.ABI + +// Create new minipool contract +func newMinipool_v4(rp *rocketpool.RocketPool, address common.Address, opts *bind.CallOpts) (Minipool, error) { + + var contract *rocketpool.Contract + var err error + if minipoolV4Abi == nil { + // Get contract + contract, err = createMinipoolContractFromEncodedAbi(rp, address, minipoolV4EncodedAbi) + } else { + contract, err = createMinipoolContractFromAbi(rp, address, minipoolV4Abi) + } + if err != nil { + return nil, err + } else if minipoolV4Abi == nil { + minipoolV4Abi = contract.ABI + } + + // Create and return + return &minipool_v4{ + Address: address, + Version: 4, + Contract: contract, + RocketPool: rp, + }, nil +} + +// Get the minipool as a v4 minipool if it implements the required methods +func GetMinipoolAsV4(mp Minipool) (MinipoolV4, bool) { + castedMp, ok := mp.(MinipoolV4) + if ok { + return castedMp, true + } + return nil, false +} + +// Get the contract +func (mp *minipool_v4) GetContract() *rocketpool.Contract { + return mp.Contract +} + +// Get the contract address +func (mp *minipool_v4) GetAddress() common.Address { + return mp.Address +} + +// Get the contract version +func (mp *minipool_v4) GetVersion() uint8 { + return mp.Version +} + +// Get status details +func (mp *minipool_v4) GetStatusDetails(opts *bind.CallOpts) (StatusDetails, error) { + + // Data + var wg errgroup.Group + var status rptypes.MinipoolStatus + var statusBlock uint64 + var statusTime time.Time + var isVacant bool + + // Load data + wg.Go(func() error { + var err error + status, err = mp.GetStatus(opts) + return err + }) + wg.Go(func() error { + var err error + statusBlock, err = mp.GetStatusBlock(opts) + return err + }) + wg.Go(func() error { + var err error + statusTime, err = mp.GetStatusTime(opts) + return err + }) + wg.Go(func() error { + var err error + isVacant, err = mp.GetVacant(opts) + return err + }) + + // Wait for data + if err := wg.Wait(); err != nil { + return StatusDetails{}, err + } + + // Return + return StatusDetails{ + Status: status, + StatusBlock: statusBlock, + StatusTime: statusTime, + IsVacant: isVacant, + }, nil + +} +func (mp *minipool_v4) GetStatus(opts *bind.CallOpts) (rptypes.MinipoolStatus, error) { + status := new(uint8) + if err := mp.Contract.Call(opts, status, "getStatus"); err != nil { + return 0, fmt.Errorf("error getting minipool %s status: %w", mp.Address.Hex(), err) + } + return rptypes.MinipoolStatus(*status), nil +} +func (mp *minipool_v4) GetStatusBlock(opts *bind.CallOpts) (uint64, error) { + statusBlock := new(*big.Int) + if err := mp.Contract.Call(opts, statusBlock, "getStatusBlock"); err != nil { + return 0, fmt.Errorf("error getting minipool %s status changed block: %w", mp.Address.Hex(), err) + } + return (*statusBlock).Uint64(), nil +} +func (mp *minipool_v4) GetStatusTime(opts *bind.CallOpts) (time.Time, error) { + statusTime := new(*big.Int) + if err := mp.Contract.Call(opts, statusTime, "getStatusTime"); err != nil { + return time.Unix(0, 0), fmt.Errorf("error getting minipool %s status changed time: %w", mp.Address.Hex(), err) + } + return time.Unix((*statusTime).Int64(), 0), nil +} +func (mp *minipool_v4) GetFinalised(opts *bind.CallOpts) (bool, error) { + finalised := new(bool) + if err := mp.Contract.Call(opts, finalised, "getFinalised"); err != nil { + return false, fmt.Errorf("error getting minipool %s finalised: %w", mp.Address.Hex(), err) + } + return *finalised, nil +} + +// Get deposit type +func (mp *minipool_v4) GetDepositType(opts *bind.CallOpts) (rptypes.MinipoolDeposit, error) { + depositType := new(uint8) + if err := mp.Contract.Call(opts, depositType, "getDepositType"); err != nil { + return 0, fmt.Errorf("error getting minipool %s deposit type: %w", mp.Address.Hex(), err) + } + return rptypes.MinipoolDeposit(*depositType), nil +} + +// Get node details +func (mp *minipool_v4) GetNodeDetails(opts *bind.CallOpts) (NodeDetails, error) { + + // Data + var wg errgroup.Group + var address common.Address + var fee float64 + var depositBalance *big.Int + var refundBalance *big.Int + var depositAssigned bool + + // Load data + wg.Go(func() error { + var err error + address, err = mp.GetNodeAddress(opts) + return err + }) + wg.Go(func() error { + var err error + fee, err = mp.GetNodeFee(opts) + return err + }) + wg.Go(func() error { + var err error + depositBalance, err = mp.GetNodeDepositBalance(opts) + return err + }) + wg.Go(func() error { + var err error + refundBalance, err = mp.GetNodeRefundBalance(opts) + return err + }) + wg.Go(func() error { + var err error + depositAssigned, err = mp.GetNodeDepositAssigned(opts) + return err + }) + + // Wait for data + if err := wg.Wait(); err != nil { + return NodeDetails{}, err + } + + // Return + return NodeDetails{ + Address: address, + Fee: fee, + DepositBalance: depositBalance, + RefundBalance: refundBalance, + DepositAssigned: depositAssigned, + }, nil + +} +func (mp *minipool_v4) GetNodeAddress(opts *bind.CallOpts) (common.Address, error) { + nodeAddress := new(common.Address) + if err := mp.Contract.Call(opts, nodeAddress, "getNodeAddress"); err != nil { + return common.Address{}, fmt.Errorf("error getting minipool %s node address: %w", mp.Address.Hex(), err) + } + return *nodeAddress, nil +} +func (mp *minipool_v4) GetNodeFee(opts *bind.CallOpts) (float64, error) { + nodeFee := new(*big.Int) + if err := mp.Contract.Call(opts, nodeFee, "getNodeFee"); err != nil { + return 0, fmt.Errorf("error getting minipool %s node fee: %w", mp.Address.Hex(), err) + } + return math.WeiToEth(*nodeFee), nil +} +func (mp *minipool_v4) GetNodeFeeRaw(opts *bind.CallOpts) (*big.Int, error) { + nodeFee := new(*big.Int) + if err := mp.Contract.Call(opts, nodeFee, "getNodeFee"); err != nil { + return nil, fmt.Errorf("error getting minipool %s node fee: %w", mp.Address.Hex(), err) + } + return *nodeFee, nil +} +func (mp *minipool_v4) GetNodeDepositBalance(opts *bind.CallOpts) (*big.Int, error) { + nodeDepositBalance := new(*big.Int) + if err := mp.Contract.Call(opts, nodeDepositBalance, "getNodeDepositBalance"); err != nil { + return nil, fmt.Errorf("error getting minipool %s node deposit balance: %w", mp.Address.Hex(), err) + } + return *nodeDepositBalance, nil +} +func (mp *minipool_v4) GetNodeRefundBalance(opts *bind.CallOpts) (*big.Int, error) { + nodeRefundBalance := new(*big.Int) + if err := mp.Contract.Call(opts, nodeRefundBalance, "getNodeRefundBalance"); err != nil { + return nil, fmt.Errorf("error getting minipool %s node refund balance: %w", mp.Address.Hex(), err) + } + return *nodeRefundBalance, nil +} +func (mp *minipool_v4) GetNodeDepositAssigned(opts *bind.CallOpts) (bool, error) { + nodeDepositAssigned := new(bool) + if err := mp.Contract.Call(opts, nodeDepositAssigned, "getNodeDepositAssigned"); err != nil { + return false, fmt.Errorf("error getting minipool %s node deposit assigned status: %w", mp.Address.Hex(), err) + } + return *nodeDepositAssigned, nil +} +func (mp *minipool_v4) GetVacant(opts *bind.CallOpts) (bool, error) { + isVacant := new(bool) + if err := mp.Contract.Call(opts, isVacant, "getVacant"); err != nil { + return false, fmt.Errorf("error getting minipool %s vacant status: %w", mp.Address.Hex(), err) + } + return *isVacant, nil +} +func (mp *minipool_v4) GetPreMigrationBalance(opts *bind.CallOpts) (*big.Int, error) { + preMigrationBalance := new(*big.Int) + if err := mp.Contract.Call(opts, preMigrationBalance, "getPreMigrationBalance"); err != nil { + return nil, fmt.Errorf("error getting minipool %s pre-migration balance: %w", mp.Address.Hex(), err) + } + return *preMigrationBalance, nil +} + +// Get user deposit details +func (mp *minipool_v4) GetUserDetails(opts *bind.CallOpts) (UserDetails, error) { + + // Data + var wg errgroup.Group + var depositBalance *big.Int + var depositAssigned bool + var depositAssignedTime time.Time + + // Load data + wg.Go(func() error { + var err error + depositBalance, err = mp.GetUserDepositBalance(opts) + return err + }) + wg.Go(func() error { + var err error + depositAssigned, err = mp.GetUserDepositAssigned(opts) + return err + }) + wg.Go(func() error { + var err error + depositAssignedTime, err = mp.GetUserDepositAssignedTime(opts) + return err + }) + + // Wait for data + if err := wg.Wait(); err != nil { + return UserDetails{}, err + } + + // Return + return UserDetails{ + DepositBalance: depositBalance, + DepositAssigned: depositAssigned, + DepositAssignedTime: depositAssignedTime, + }, nil + +} +func (mp *minipool_v4) GetUserDepositBalance(opts *bind.CallOpts) (*big.Int, error) { + userDepositBalance := new(*big.Int) + if err := mp.Contract.Call(opts, userDepositBalance, "getUserDepositBalance"); err != nil { + return nil, fmt.Errorf("error getting minipool %s user deposit balance: %w", mp.Address.Hex(), err) + } + return *userDepositBalance, nil +} +func (mp *minipool_v4) GetUserDepositAssigned(opts *bind.CallOpts) (bool, error) { + userDepositAssigned := new(bool) + if err := mp.Contract.Call(opts, userDepositAssigned, "getUserDepositAssigned"); err != nil { + return false, fmt.Errorf("error getting minipool %s user deposit assigned status: %w", mp.Address.Hex(), err) + } + return *userDepositAssigned, nil +} +func (mp *minipool_v4) GetUserDepositAssignedTime(opts *bind.CallOpts) (time.Time, error) { + depositAssignedTime := new(*big.Int) + if err := mp.Contract.Call(opts, depositAssignedTime, "getUserDepositAssignedTime"); err != nil { + return time.Unix(0, 0), fmt.Errorf("error getting minipool %s user deposit assigned time: %w", mp.Address.Hex(), err) + } + return time.Unix((*depositAssignedTime).Int64(), 0), nil +} + +// Estimate the gas of Refund +func (mp *minipool_v4) EstimateRefundGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { + return mp.Contract.GetTransactionGasInfo(opts, "refund") +} + +// Refund node ETH from the minipool +func (mp *minipool_v4) Refund(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "refund") + if err != nil { + return common.Hash{}, fmt.Errorf("error refunding from minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Check if the minipool's balance has already been distributed +func (mp *minipool_v4) GetUserDistributed(opts *bind.CallOpts) (bool, error) { + distributed := new(bool) + if err := mp.Contract.Call(opts, distributed, "getUserDistributed"); err != nil { + return false, fmt.Errorf("error getting user distributed status for minipool %s: %w", mp.Address.Hex(), err) + } + return *distributed, nil +} + +// Estimate the gas of DistributeBalance +func (mp *minipool_v4) EstimateDistributeBalanceGas(rewardsOnly bool, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return mp.Contract.GetTransactionGasInfo(opts, "distributeBalance", rewardsOnly) +} + +// Distribute the minipool's ETH balance to the node operator and rETH staking pool. +func (mp *minipool_v4) DistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "distributeBalance", rewardsOnly) + if err != nil { + return common.Hash{}, fmt.Errorf("error processing withdrawal for minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// PrepareDistributeBalance is like DistributeBalance but forces NoSend and returns the signed transaction +// (instead of sending it). Useful for assembling Flashbots bundles. +func (mp *minipool_v4) PrepareDistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (*types.Transaction, error) { + if opts == nil { + opts = &bind.TransactOpts{} + } + opts.NoSend = true + tx, err := mp.Contract.Transact(opts, "distributeBalance", rewardsOnly) + if err != nil { + return nil, fmt.Errorf("error preparing distribute tx for minipool %s: %w", mp.Address.Hex(), err) + } + return tx, nil +} + +// Estimate the gas of Stake +func (mp *minipool_v4) EstimateStakeGas(validatorSignature rptypes.ValidatorSignature, depositDataRoot common.Hash, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return mp.Contract.GetTransactionGasInfo(opts, "stake", validatorSignature[:], depositDataRoot) +} + +// Progress the prelaunch minipool to staking +func (mp *minipool_v4) Stake(validatorSignature rptypes.ValidatorSignature, depositDataRoot common.Hash, opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "stake", validatorSignature[:], depositDataRoot) + if err != nil { + return common.Hash{}, fmt.Errorf("error staking minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of Dissolve +func (mp *minipool_v4) EstimateDissolveGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { + return mp.Contract.GetTransactionGasInfo(opts, "dissolve") +} + +// Dissolve the initialized or prelaunch minipool +func (mp *minipool_v4) Dissolve(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "dissolve") + if err != nil { + return common.Hash{}, fmt.Errorf("error dissolving minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of Close +func (mp *minipool_v4) EstimateCloseGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { + return mp.Contract.GetTransactionGasInfo(opts, "close") +} + +// Withdraw node balances from the dissolved minipool and close it +func (mp *minipool_v4) Close(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "close") + if err != nil { + return common.Hash{}, fmt.Errorf("error closing minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of Finalise +func (mp *minipool_v4) EstimateFinaliseGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { + return mp.Contract.GetTransactionGasInfo(opts, "finalise") +} + +// Finalise a minipool to get the RPL stake back +func (mp *minipool_v4) Finalise(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "finalise") + if err != nil { + return common.Hash{}, fmt.Errorf("error finalizing minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of DelegateUpgrade +func (mp *minipool_v4) EstimateDelegateUpgradeGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { + return mp.Contract.GetTransactionGasInfo(opts, "delegateUpgrade") +} + +// Upgrade this minipool to the latest network delegate contract +func (mp *minipool_v4) DelegateUpgrade(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "delegateUpgrade") + if err != nil { + return common.Hash{}, fmt.Errorf("error upgrading delegate for minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of SetUseLatestDelegate +func (mp *minipool_v4) EstimateSetUseLatestDelegateGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { + return mp.Contract.GetTransactionGasInfo(opts, "setUseLatestDelegate", true) +} + +// If set to true, will automatically use the latest delegate contract +func (mp *minipool_v4) SetUseLatestDelegate(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "setUseLatestDelegate", true) + if err != nil { + return common.Hash{}, fmt.Errorf("error setting use latest delegate for minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Getter for useLatestDelegate setting +func (mp *minipool_v4) GetUseLatestDelegate(opts *bind.CallOpts) (bool, error) { + setting := new(bool) + if err := mp.Contract.Call(opts, setting, "getUseLatestDelegate"); err != nil { + return false, fmt.Errorf("error getting use latest delegate for minipool %s: %w", mp.Address.Hex(), err) + } + return *setting, nil +} + +// Returns the address of the minipool's stored delegate +func (mp *minipool_v4) GetDelegate(opts *bind.CallOpts) (common.Address, error) { + address := new(common.Address) + if err := mp.Contract.Call(opts, address, "getDelegate"); err != nil { + return common.Address{}, fmt.Errorf("error getting delegate for minipool %s: %w", mp.Address.Hex(), err) + } + return *address, nil +} + +// Returns the address of the minipool's previous delegate (or address(0) if not set) +func (mp *minipool_v4) GetPreviousDelegate(opts *bind.CallOpts) (common.Address, error) { + address := new(common.Address) + if err := mp.Contract.Call(opts, address, "getPreviousDelegate"); err != nil { + return common.Address{}, fmt.Errorf("error getting previous delegate for minipool %s: %w", mp.Address.Hex(), err) + } + return *address, nil +} + +// Returns the delegate which will be used when calling this minipool taking into account useLatestDelegate setting +func (mp *minipool_v4) GetEffectiveDelegate(opts *bind.CallOpts) (common.Address, error) { + address := new(common.Address) + if err := mp.Contract.Call(opts, address, "getEffectiveDelegate"); err != nil { + return common.Address{}, fmt.Errorf("error getting effective delegate for minipool %s: %w", mp.Address.Hex(), err) + } + return *address, nil +} + +// Estimate the gas required to reduce a minipool's bond +func (mp *minipool_v4) EstimateReduceBondAmountGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { + return mp.Contract.GetTransactionGasInfo(opts, "reduceBondAmount") +} + +// Reduce a minipool's bond +func (mp *minipool_v4) ReduceBondAmount(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "reduceBondAmount") + if err != nil { + return common.Hash{}, fmt.Errorf("error reducing bond for minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Given a validator balance, calculates how much belongs to the node taking into consideration rewards and penalties +func (mp *minipool_v4) CalculateNodeShare(balance *big.Int, opts *bind.CallOpts) (*big.Int, error) { + nodeAmount := new(*big.Int) + if err := mp.Contract.Call(opts, nodeAmount, "calculateNodeShare", balance); err != nil { + return nil, fmt.Errorf("error getting minipool node portion: %w", err) + } + return *nodeAmount, nil +} + +// Given a validator balance, calculates how much belongs to rETH users taking into consideration rewards and penalties +func (mp *minipool_v4) CalculateUserShare(balance *big.Int, opts *bind.CallOpts) (*big.Int, error) { + userAmount := new(*big.Int) + if err := mp.Contract.Call(opts, userAmount, "calculateUserShare", balance); err != nil { + return nil, fmt.Errorf("error getting minipool user portion: %w", err) + } + return *userAmount, nil +} + +// Estimate the gas required to vote to scrub a minipool +func (mp *minipool_v4) EstimateVoteScrubGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { + return mp.Contract.GetTransactionGasInfo(opts, "voteScrub") +} + +// Vote to scrub a minipool +func (mp *minipool_v4) VoteScrub(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "voteScrub") + if err != nil { + return common.Hash{}, fmt.Errorf("error voting to scrub minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas required to promote a vacant minipool +func (mp *minipool_v4) EstimatePromoteGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { + return mp.Contract.GetTransactionGasInfo(opts, "promote") +} + +// Promote a vacant minipool +func (mp *minipool_v4) Promote(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "promote") + if err != nil { + return common.Hash{}, fmt.Errorf("error promoting minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} + +// Get the data from this minipool's MinipoolPrestaked event +func (mp *minipool_v4) GetPrestakeEvent(intervalSize *big.Int, opts *bind.CallOpts) (PrestakeData, error) { + + addressFilter := []common.Address{mp.Address} + topicFilter := [][]common.Hash{{mp.Contract.ABI.Events["MinipoolPrestaked"].ID}} + + // Grab the latest block number + currentBlock, err := mp.RocketPool.Client.BlockNumber(context.Background()) + if err != nil { + return PrestakeData{}, fmt.Errorf("Error getting current block %s: %w", mp.Address.Hex(), err) + } + + // Grab the lowest block number worth querying from (should never have to go back this far in practice) + fromBlockBig, err := storage.GetDeployBlock(mp.RocketPool) + if err != nil { + return PrestakeData{}, fmt.Errorf("Error getting deploy block %s: %w", mp.Address.Hex(), err) + } + + fromBlock := fromBlockBig.Uint64() + var log types.Log + found := false + + // Backwards scan through blocks to find the event + for i := currentBlock; i >= fromBlock; i -= EventScanInterval { + from := max(i-EventScanInterval+1, fromBlock) + + fromBig := big.NewInt(0).SetUint64(from) + toBig := big.NewInt(0).SetUint64(i) + + logs, err := logs.GetLogs(mp.RocketPool, addressFilter, topicFilter, intervalSize, fromBig, toBig, nil) + if err != nil { + return PrestakeData{}, fmt.Errorf("Error getting prestake logs for minipool %s: %w", mp.Address.Hex(), err) + } + + if len(logs) > 0 { + log = logs[0] + found = true + break + } + } + + if !found { + // This should never happen + return PrestakeData{}, fmt.Errorf("Error finding prestake log for minipool %s", mp.Address.Hex()) + } + + // Decode the event + prestakeEvent := new(MinipoolPrestakeEvent) + err = mp.Contract.Contract.UnpackLog(prestakeEvent, "MinipoolPrestaked", log) + if err != nil { + return PrestakeData{}, fmt.Errorf("Error unpacking prestake data: %w", err) + } + + // Convert the event to a more useable struct + prestakeData := PrestakeData{ + Pubkey: rptypes.BytesToValidatorPubkey(prestakeEvent.Pubkey), + WithdrawalCredentials: common.BytesToHash(prestakeEvent.WithdrawalCredentials), + Amount: prestakeEvent.Amount, + Signature: rptypes.BytesToValidatorSignature(prestakeEvent.Signature), + DepositDataRoot: prestakeEvent.DepositDataRoot, + Time: time.Unix(prestakeEvent.Time.Int64(), 0), + } + return prestakeData, nil +} + +// Estimate the gas required to force exit a minipool +func (mp *minipool_v4) EstimateForceExitGas(opts *bind.TransactOpts) (gaslimit.Limits, error) { + return mp.Contract.GetTransactionGasInfo(opts, "forceExit") +} + +// Force exit a minipool +func (mp *minipool_v4) ForceExit(opts *bind.TransactOpts) (common.Hash, error) { + tx, err := mp.Contract.Transact(opts, "forceExit") + if err != nil { + return common.Hash{}, fmt.Errorf("error forcing exit for minipool %s: %w", mp.Address.Hex(), err) + } + return tx.Hash(), nil +} diff --git a/bindings/network/exit.go b/bindings/network/exit.go new file mode 100644 index 000000000..72b4a7730 --- /dev/null +++ b/bindings/network/exit.go @@ -0,0 +1,355 @@ +package network + +import ( + "context" + "fmt" + "math/big" + "sync" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/logs" + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" + "github.com/rocket-pool/smartnode/bindings/types" +) + +// A MinipoolExitRequested event from rocketNetworkExit +type MinipoolExitRequest struct { + MinipoolAddress common.Address `json:"minipoolAddress"` + Pubkey types.ValidatorPubkey `json:"pubkey"` + RequestTimestamp uint64 `json:"requestTimestamp"` + BlockNumber uint64 `json:"blockNumber"` +} + +// A MegapoolExitRequested event from rocketNetworkExit +type MegapoolExitRequest struct { + MegapoolAddress common.Address `json:"megapoolAddress"` + ValidatorId uint32 `json:"validatorId"` + Pubkey types.ValidatorPubkey `json:"pubkey"` + RequestTimestamp uint64 `json:"requestTimestamp"` + BlockNumber uint64 `json:"blockNumber"` +} + +// Get the amount of ETH currently requested to exit +func GetRequestedEth(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, opts) + if err != nil { + return nil, err + } + value := new(*big.Int) + if err := rocketNetworkExit.Call(opts, value, "getRequestedEth"); err != nil { + return nil, fmt.Errorf("error getting requested ETH: %w", err) + } + return *value, nil +} + +// Get the start of the cooperative exit phase for a Minipool +func GetMinipoolCooperativeExitStart(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.CallOpts) (time.Time, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, opts) + if err != nil { + return time.Time{}, err + } + value := new(*big.Int) + if err := rocketNetworkExit.Call(opts, value, "getMinipoolCooperativeExitStart", minipoolAddress); err != nil { + return time.Time{}, fmt.Errorf("error getting minipool cooperative exit start for %s: %w", minipoolAddress.Hex(), err) + } + return time.Unix((*value).Int64(), 0), nil +} + +// Get the start of the cooperative exit phase for a Megapool validator +func GetMegapoolCooperativeExitStart(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.CallOpts) (time.Time, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, opts) + if err != nil { + return time.Time{}, err + } + value := new(*big.Int) + if err := rocketNetworkExit.Call(opts, value, "getMegapoolCooperativeExitStart", megapoolAddress, validatorId); err != nil { + return time.Time{}, fmt.Errorf("error getting megapool cooperative exit start for %s validator %d: %w", megapoolAddress.Hex(), validatorId, err) + } + return time.Unix((*value).Int64(), 0), nil +} + +// Get the timestamp of the last exit request for a Minipool (or zero time if never requested) +func GetMinipoolLastExit(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.CallOpts) (time.Time, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, opts) + if err != nil { + return time.Time{}, err + } + value := new(*big.Int) + if err := rocketNetworkExit.Call(opts, value, "getMinipoolLastExit", minipoolAddress); err != nil { + return time.Time{}, fmt.Errorf("error getting minipool last exit for %s: %w", minipoolAddress.Hex(), err) + } + return time.Unix((*value).Int64(), 0), nil +} + +// Estimate the gas of RequestMinipoolExit +func EstimateRequestMinipoolExitGas(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.TransactOpts) (gaslimit.Limits, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "requestMinipoolExit", minipoolAddress) +} + +// Request a Minipool to exit cooperatively +func RequestMinipoolExit(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "requestMinipoolExit", minipoolAddress) + if err != nil { + return common.Hash{}, fmt.Errorf("error requesting minipool exit for %s: %w", minipoolAddress.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of ForceMinipoolExit +func EstimateForceMinipoolExitGas(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.TransactOpts) (gaslimit.Limits, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "forceMinipoolExit", minipoolAddress) +} + +// Force a Minipool to exit after the cooperative exit phase +func ForceMinipoolExit(rp *rocketpool.RocketPool, minipoolAddress common.Address, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "forceMinipoolExit", minipoolAddress) + if err != nil { + return common.Hash{}, fmt.Errorf("error forcing minipool exit for %s: %w", minipoolAddress.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of PenaliseMinipool +func EstimatePenaliseMinipoolGas(rp *rocketpool.RocketPool, minipoolAddress common.Address, slotTimestamp uint64, validatorProof megapool.ValidatorProof, slotProof megapool.SlotProof, opts *bind.TransactOpts) (gaslimit.Limits, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "penaliseMinipool", minipoolAddress, slotTimestamp, validatorProof, slotProof) +} + +// Penalise a Minipool that failed to exit cooperatively (or force-exits if the delegate supports it) +func PenaliseMinipool(rp *rocketpool.RocketPool, minipoolAddress common.Address, slotTimestamp uint64, validatorProof megapool.ValidatorProof, slotProof megapool.SlotProof, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "penaliseMinipool", minipoolAddress, slotTimestamp, validatorProof, slotProof) + if err != nil { + return common.Hash{}, fmt.Errorf("error penalising minipool %s: %w", minipoolAddress.Hex(), err) + } + return tx.Hash(), nil +} + +// Estimate the gas of RequestMegapoolExit +func EstimateRequestMegapoolExitGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (gaslimit.Limits, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "requestMegapoolExit", megapoolAddress, validatorId) +} + +// Request a Megapool validator to exit cooperatively +func RequestMegapoolExit(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "requestMegapoolExit", megapoolAddress, validatorId) + if err != nil { + return common.Hash{}, fmt.Errorf("error requesting megapool exit for %s validator %d: %w", megapoolAddress.Hex(), validatorId, err) + } + return tx.Hash(), nil +} + +// Estimate the gas of ForceMegapoolExit +func EstimateForceMegapoolExitGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (gaslimit.Limits, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "forceMegapoolExit", megapoolAddress, validatorId) +} + +// Force a Megapool validator to exit after the cooperative exit phase +func ForceMegapoolExit(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "forceMegapoolExit", megapoolAddress, validatorId) + if err != nil { + return common.Hash{}, fmt.Errorf("error forcing megapool exit for %s validator %d: %w", megapoolAddress.Hex(), validatorId, err) + } + return tx.Hash(), nil +} + +// Estimate the gas of PenaliseMegapoolValidator +func EstimatePenaliseMegapoolValidatorGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (gaslimit.Limits, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "penaliseMegapool", megapoolAddress, validatorId) +} + +// Penalise a megapool validator that failed to exit within the cooperative exit phase +func PenaliseMegapoolValidator(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorId uint32, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "penaliseMegapool", megapoolAddress, validatorId) + if err != nil { + return common.Hash{}, fmt.Errorf("error penalising megapool %s validator %d: %w", megapoolAddress.Hex(), validatorId, err) + } + return tx.Hash(), nil +} + +// Get MinipoolExitRequested events emitted during the given block range +func GetMinipoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, fromBlock *big.Int, toBlock *big.Int, opts *bind.CallOpts) ([]MinipoolExitRequest, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, opts) + if err != nil { + return nil, err + } + + minipoolExitRequestedEvent, exists := rocketNetworkExit.ABI.Events["MinipoolExitRequested"] + if !exists { + return nil, fmt.Errorf("MinipoolExitRequested event not found in rocketNetworkExit ABI") + } + + addressFilter := []common.Address{*rocketNetworkExit.Address} + topicFilter := [][]common.Hash{{minipoolExitRequestedEvent.ID}} + + logs, err := logs.GetLogs(rp, addressFilter, topicFilter, intervalSize, fromBlock, toBlock, nil) + if err != nil { + return nil, err + } + if len(logs) == 0 { + return []MinipoolExitRequest{}, nil + } + + blockTimestamps := make(map[uint64]uint64, len(logs)) + requests := make([]MinipoolExitRequest, 0, len(logs)) + for _, log := range logs { + if len(log.Topics) < 2 { + return nil, fmt.Errorf("MinipoolExitRequested event had %d topics but at least 2 are required", len(log.Topics)) + } + + values, err := minipoolExitRequestedEvent.Inputs.Unpack(log.Data) + if err != nil { + return nil, fmt.Errorf("error unpacking MinipoolExitRequested event data: %w", err) + } + if len(values) < 1 { + return nil, fmt.Errorf("MinipoolExitRequested event had no data values") + } + pubkeyBytes, ok := values[0].([]byte) + if !ok { + return nil, fmt.Errorf("MinipoolExitRequested pubkey had unexpected type %T", values[0]) + } + + timestamp, cached := blockTimestamps[log.BlockNumber] + if !cached { + header, err := rp.Client.HeaderByNumber(context.Background(), new(big.Int).SetUint64(log.BlockNumber)) + if err != nil { + return nil, fmt.Errorf("error getting header for block %d: %w", log.BlockNumber, err) + } + timestamp = header.Time + blockTimestamps[log.BlockNumber] = timestamp + } + + requests = append(requests, MinipoolExitRequest{ + MinipoolAddress: common.BytesToAddress(log.Topics[1].Bytes()), + Pubkey: types.BytesToValidatorPubkey(pubkeyBytes), + RequestTimestamp: timestamp, + BlockNumber: log.BlockNumber, + }) + } + + return requests, nil +} + +// Get MegapoolExitRequested events emitted during the given block range +func GetMegapoolExitRequests(rp *rocketpool.RocketPool, intervalSize *big.Int, fromBlock *big.Int, toBlock *big.Int, opts *bind.CallOpts) ([]MegapoolExitRequest, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, opts) + if err != nil { + return nil, err + } + + megapoolExitRequestedEvent, exists := rocketNetworkExit.ABI.Events["MegapoolExitRequested"] + if !exists { + return nil, fmt.Errorf("MegapoolExitRequested event not found in rocketNetworkExit ABI") + } + + addressFilter := []common.Address{*rocketNetworkExit.Address} + topicFilter := [][]common.Hash{{megapoolExitRequestedEvent.ID}} + + logs, err := logs.GetLogs(rp, addressFilter, topicFilter, intervalSize, fromBlock, toBlock, nil) + if err != nil { + return nil, err + } + if len(logs) == 0 { + return []MegapoolExitRequest{}, nil + } + + blockTimestamps := make(map[uint64]uint64, len(logs)) + requests := make([]MegapoolExitRequest, 0, len(logs)) + for _, log := range logs { + if len(log.Topics) < 3 { + return nil, fmt.Errorf("MegapoolExitRequested event had %d topics but at least 3 are required", len(log.Topics)) + } + + values, err := megapoolExitRequestedEvent.Inputs.Unpack(log.Data) + if err != nil { + return nil, fmt.Errorf("error unpacking MegapoolExitRequested event data: %w", err) + } + if len(values) < 1 { + return nil, fmt.Errorf("MegapoolExitRequested event had no data values") + } + pubkeyBytes, ok := values[0].([]byte) + if !ok { + return nil, fmt.Errorf("MegapoolExitRequested pubkey had unexpected type %T", values[0]) + } + + timestamp, cached := blockTimestamps[log.BlockNumber] + if !cached { + header, err := rp.Client.HeaderByNumber(context.Background(), new(big.Int).SetUint64(log.BlockNumber)) + if err != nil { + return nil, fmt.Errorf("error getting header for block %d: %w", log.BlockNumber, err) + } + timestamp = header.Time + blockTimestamps[log.BlockNumber] = timestamp + } + + requests = append(requests, MegapoolExitRequest{ + MegapoolAddress: common.BytesToAddress(log.Topics[1].Bytes()), + ValidatorId: uint32(new(big.Int).SetBytes(log.Topics[2].Bytes()).Uint64()), + Pubkey: types.BytesToValidatorPubkey(pubkeyBytes), + RequestTimestamp: timestamp, + BlockNumber: log.BlockNumber, + }) + } + + return requests, nil +} + +// Get contracts +var rocketNetworkExitLock sync.Mutex + +func getRocketNetworkExit(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*rocketpool.Contract, error) { + rocketNetworkExitLock.Lock() + defer rocketNetworkExitLock.Unlock() + return rp.GetContract("rocketNetworkExit", opts) +} diff --git a/bindings/settings/protocol/exit.go b/bindings/settings/protocol/exit.go new file mode 100644 index 000000000..19b507797 --- /dev/null +++ b/bindings/settings/protocol/exit.go @@ -0,0 +1,111 @@ +package protocol + +import ( + "fmt" + "math/big" + "sync" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/dao/protocol" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" + "github.com/rocket-pool/smartnode/bindings/types" +) + +// Config +const ( + ExitSettingsContractName string = "rocketDAOProtocolSettingsExit" + CooperativeExitPhaseSettingPath string = "cooperative.exit.phase" + DidNotExitPenaltyBaseSettingPath string = "did.not.exit.penalty.base" + DidNotExitBaseSettingPath string = "did.not.exit.base" + DidNotExitBackoffSettingPath string = "did.not.exit.backoff" +) + +// Minimum time a validator must remain exit-requested before triggered exit or penalty (hours) +func GetCooperativeExitPhase(rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { + exitSettingsContract, err := getExitSettingsContract(rp, opts) + if err != nil { + return 0, err + } + value := new(*big.Int) + if err := exitSettingsContract.Call(opts, value, "getCooperativeExitPhase"); err != nil { + return 0, fmt.Errorf("error getting cooperative exit phase: %w", err) + } + return time.Duration((*value).Int64()) * time.Hour, nil +} +func ProposeCooperativeExitPhase(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", CooperativeExitPhaseSettingPath), ExitSettingsContractName, CooperativeExitPhaseSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposeCooperativeExitPhaseGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", CooperativeExitPhaseSettingPath), ExitSettingsContractName, CooperativeExitPhaseSettingPath, value, blockNumber, treeNodes, opts) +} + +// Base penalty applied to a minipool that fails to exit when requested +func GetDidNotExitPenaltyBase(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { + exitSettingsContract, err := getExitSettingsContract(rp, opts) + if err != nil { + return nil, err + } + value := new(*big.Int) + if err := exitSettingsContract.Call(opts, value, "getDidNotExitPenaltyBase"); err != nil { + return nil, fmt.Errorf("error getting did not exit penalty base: %w", err) + } + return *value, nil +} +func ProposeDidNotExitPenaltyBase(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", DidNotExitPenaltyBaseSettingPath), ExitSettingsContractName, DidNotExitPenaltyBaseSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposeDidNotExitPenaltyBaseGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", DidNotExitPenaltyBaseSettingPath), ExitSettingsContractName, DidNotExitPenaltyBaseSettingPath, value, blockNumber, treeNodes, opts) +} + +// Initial window between consecutive did-not-exit penalties (days) +func GetDidNotExitBase(rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { + exitSettingsContract, err := getExitSettingsContract(rp, opts) + if err != nil { + return 0, err + } + value := new(*big.Int) + if err := exitSettingsContract.Call(opts, value, "getDidNotExitBase"); err != nil { + return 0, fmt.Errorf("error getting did not exit base: %w", err) + } + return time.Duration((*value).Int64()) * 24 * time.Hour, nil +} +func ProposeDidNotExitBase(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", DidNotExitBaseSettingPath), ExitSettingsContractName, DidNotExitBaseSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposeDidNotExitBaseGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", DidNotExitBaseSettingPath), ExitSettingsContractName, DidNotExitBaseSettingPath, value, blockNumber, treeNodes, opts) +} + +// Multiplier applied to the penalty window on each iteration (18-decimal fixed point): +// the i-th window is did_not_exit_base * did_not_exit_backoff ** i +func GetDidNotExitBackoff(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { + exitSettingsContract, err := getExitSettingsContract(rp, opts) + if err != nil { + return nil, err + } + value := new(*big.Int) + if err := exitSettingsContract.Call(opts, value, "getDidNotExitBackoff"); err != nil { + return nil, fmt.Errorf("error getting did not exit backoff: %w", err) + } + return *value, nil +} +func ProposeDidNotExitBackoff(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", DidNotExitBackoffSettingPath), ExitSettingsContractName, DidNotExitBackoffSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposeDidNotExitBackoffGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", DidNotExitBackoffSettingPath), ExitSettingsContractName, DidNotExitBackoffSettingPath, value, blockNumber, treeNodes, opts) +} + +// Get contracts +var exitSettingsContractLock sync.Mutex + +func getExitSettingsContract(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*rocketpool.Contract, error) { + exitSettingsContractLock.Lock() + defer exitSettingsContractLock.Unlock() + return rp.GetContract(ExitSettingsContractName, opts) +} diff --git a/bindings/settings/protocol/megapool.go b/bindings/settings/protocol/megapool.go index 7b0af9a54..4d4c37912 100644 --- a/bindings/settings/protocol/megapool.go +++ b/bindings/settings/protocol/megapool.go @@ -4,6 +4,7 @@ import ( "fmt" "math/big" "sync" + "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -25,6 +26,7 @@ const ( MegapoolUserDistributeDelayPath string = "user.distribute.delay" MegapoolUserDistributeDelayShortfallPath string = "user.distribute.delay.shortfall" MegapoolPenaltyThreshold string = "megapool.penalty.threshold" + MegapoolPrestakeChallengePeriodPath string = "prestake.challenge.period" ) // How long after an assignment a watcher must wait to dissolve a megapool validator @@ -187,6 +189,27 @@ func EstimateProposePenaltyThreshold(rp *rocketpool.RocketPool, value *big.Int, return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", MegapoolPenaltyThreshold), MegapoolSettingsContractName, MegapoolPenaltyThreshold, value, blockNumber, treeNodes, opts) } +// The window after a validator's prestake during which fraud proofs of invalid +// withdrawal credentials can be submitted, before staking without a state proof is allowed +func GetPrestakeChallengePeriod(rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { + megapoolSettingsContract, err := getMegapoolSettingsContract(rp, opts) + if err != nil { + return 0, err + } + value := new(*big.Int) + if err := megapoolSettingsContract.Call(opts, value, "getPrestakeChallengePeriod"); err != nil { + return 0, fmt.Errorf("error getting megapool prestake challenge period value: %w", err) + } + return time.Duration((*value).Int64()) * time.Hour, nil +} + +func ProposePrestakeChallengePeriod(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", MegapoolPrestakeChallengePeriodPath), MegapoolSettingsContractName, MegapoolPrestakeChallengePeriodPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposePrestakeChallengePeriod(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", MegapoolPrestakeChallengePeriodPath), MegapoolSettingsContractName, MegapoolPrestakeChallengePeriodPath, value, blockNumber, treeNodes, opts) +} + // Get contracts var megapoolSettingsContractLock sync.Mutex diff --git a/bindings/settings/protocol/performance.go b/bindings/settings/protocol/performance.go new file mode 100644 index 000000000..fbd08a758 --- /dev/null +++ b/bindings/settings/protocol/performance.go @@ -0,0 +1,150 @@ +package protocol + +import ( + "fmt" + "math/big" + "sync" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/dao/protocol" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" + "github.com/rocket-pool/smartnode/bindings/types" +) + +// Config +const ( + PerformanceSettingsContractName string = "rocketDAOProtocolSettingsPerformance" + PerformanceExitsEnabledSettingPath string = "performance.exits.enabled" + PerformancePeriodSettingPath string = "performance.period" + ProofBufferSettingPath string = "proof.buffer" + PerformanceThresholdSettingPath string = "performance.threshold" + PerformanceChallengePeriodSettingPath string = "performance.challenge.period" + PerformanceChallengeBondSettingPath string = "performance.challenge.bond" +) + +// Performance exits currently enabled +func GetPerformanceExitsEnabled(rp *rocketpool.RocketPool, opts *bind.CallOpts) (bool, error) { + performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) + if err != nil { + return false, err + } + value := new(bool) + if err := performanceSettingsContract.Call(opts, value, "getPerformanceExitsEnabled"); err != nil { + return false, fmt.Errorf("error getting performance exits enabled status: %w", err) + } + return *value, nil +} +func ProposePerformanceExitsEnabled(rp *rocketpool.RocketPool, value bool, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetBool(rp, fmt.Sprintf("set %s", PerformanceExitsEnabledSettingPath), PerformanceSettingsContractName, PerformanceExitsEnabledSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposePerformanceExitsEnabledGas(rp *rocketpool.RocketPool, value bool, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetBoolGas(rp, fmt.Sprintf("set %s", PerformanceExitsEnabledSettingPath), PerformanceSettingsContractName, PerformanceExitsEnabledSettingPath, value, blockNumber, treeNodes, opts) +} + +// Number of epochs over which attestation performance is measured +func GetPerformancePeriod(rp *rocketpool.RocketPool, opts *bind.CallOpts) (uint64, error) { + performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) + if err != nil { + return 0, err + } + value := new(*big.Int) + if err := performanceSettingsContract.Call(opts, value, "getPerformancePeriod"); err != nil { + return 0, fmt.Errorf("error getting performance period: %w", err) + } + return (*value).Uint64(), nil +} +func ProposePerformancePeriod(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformancePeriodSettingPath), PerformanceSettingsContractName, PerformancePeriodSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposePerformancePeriodGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformancePeriodSettingPath), PerformanceSettingsContractName, PerformancePeriodSettingPath, value, blockNumber, treeNodes, opts) +} + +// Buffer to detect underperformance and generate proofs before a validator can be challenged (epochs) +func GetProofBuffer(rp *rocketpool.RocketPool, opts *bind.CallOpts) (uint64, error) { + performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) + if err != nil { + return 0, err + } + value := new(*big.Int) + if err := performanceSettingsContract.Call(opts, value, "getProofBuffer"); err != nil { + return 0, fmt.Errorf("error getting proof buffer: %w", err) + } + return (*value).Uint64(), nil +} +func ProposeProofBuffer(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", ProofBufferSettingPath), PerformanceSettingsContractName, ProofBufferSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposeProofBufferGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", ProofBufferSettingPath), PerformanceSettingsContractName, ProofBufferSettingPath, value, blockNumber, treeNodes, opts) +} + +// Minimum target attestation timeliness percentage required to avoid exit +func GetPerformanceThreshold(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { + performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) + if err != nil { + return nil, err + } + value := new(*big.Int) + if err := performanceSettingsContract.Call(opts, value, "getPerformanceThreshold"); err != nil { + return nil, fmt.Errorf("error getting performance threshold: %w", err) + } + return *value, nil +} +func ProposePerformanceThreshold(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformanceThresholdSettingPath), PerformanceSettingsContractName, PerformanceThresholdSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposePerformanceThresholdGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformanceThresholdSettingPath), PerformanceSettingsContractName, PerformanceThresholdSettingPath, value, blockNumber, treeNodes, opts) +} + +// How long a performance exit challenge remains open +func GetPerformanceChallengePeriod(rp *rocketpool.RocketPool, opts *bind.CallOpts) (time.Duration, error) { + performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) + if err != nil { + return 0, err + } + value := new(*big.Int) + if err := performanceSettingsContract.Call(opts, value, "getPerformanceChallengePeriod"); err != nil { + return 0, fmt.Errorf("error getting performance challenge period: %w", err) + } + return time.Duration((*value).Int64()) * time.Hour, nil +} +func ProposePerformanceChallengePeriod(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformanceChallengePeriodSettingPath), PerformanceSettingsContractName, PerformanceChallengePeriodSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposePerformanceChallengePeriodGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformanceChallengePeriodSettingPath), PerformanceSettingsContractName, PerformanceChallengePeriodSettingPath, value, blockNumber, treeNodes, opts) +} + +// RPL bond required to propose a performance exit +func GetPerformanceChallengeBond(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { + performanceSettingsContract, err := getPerformanceSettingsContract(rp, opts) + if err != nil { + return nil, err + } + value := new(*big.Int) + if err := performanceSettingsContract.Call(opts, value, "getPerformanceChallengeBond"); err != nil { + return nil, fmt.Errorf("error getting performance challenge bond: %w", err) + } + return *value, nil +} +func ProposePerformanceChallengeBond(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return protocol.ProposeSetUint(rp, fmt.Sprintf("set %s", PerformanceChallengeBondSettingPath), PerformanceSettingsContractName, PerformanceChallengeBondSettingPath, value, blockNumber, treeNodes, opts) +} +func EstimateProposePerformanceChallengeBondGas(rp *rocketpool.RocketPool, value *big.Int, blockNumber uint32, treeNodes []types.VotingTreeNode, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return protocol.EstimateProposeSetUintGas(rp, fmt.Sprintf("set %s", PerformanceChallengeBondSettingPath), PerformanceSettingsContractName, PerformanceChallengeBondSettingPath, value, blockNumber, treeNodes, opts) +} + +// Get contracts +var performanceSettingsContractLock sync.Mutex + +func getPerformanceSettingsContract(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*rocketpool.Contract, error) { + performanceSettingsContractLock.Lock() + defer performanceSettingsContractLock.Unlock() + return rp.GetContract(PerformanceSettingsContractName, opts) +} diff --git a/bindings/settings/protocol/setting-types.go b/bindings/settings/protocol/setting-types.go index cd086fe48..136ab5986 100644 --- a/bindings/settings/protocol/setting-types.go +++ b/bindings/settings/protocol/setting-types.go @@ -128,6 +128,21 @@ var pdaoSettingKinds = map[string]map[string]settingKind{ MegapoolUserDistributeDelayPath: settingKindUint256, MegapoolUserDistributeDelayShortfallPath: settingKindUint256, MegapoolPenaltyThreshold: settingKindUint256, + MegapoolPrestakeChallengePeriodPath: settingKindUint256, + }, + PerformanceSettingsContractName: { + PerformanceExitsEnabledSettingPath: settingKindBool, + PerformancePeriodSettingPath: settingKindUint256, + ProofBufferSettingPath: settingKindUint256, + PerformanceThresholdSettingPath: settingKindUint256, + PerformanceChallengePeriodSettingPath: settingKindUint256, + PerformanceChallengeBondSettingPath: settingKindUint256, + }, + ExitSettingsContractName: { + CooperativeExitPhaseSettingPath: settingKindUint256, + DidNotExitPenaltyBaseSettingPath: settingKindUint256, + DidNotExitBaseSettingPath: settingKindUint256, + DidNotExitBackoffSettingPath: settingKindUint256, }, } diff --git a/bindings/settings/security/performance.go b/bindings/settings/security/performance.go new file mode 100644 index 000000000..deb59add8 --- /dev/null +++ b/bindings/settings/security/performance.go @@ -0,0 +1,25 @@ +package security + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/dao/security" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + psettings "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/bindings/transactions/gaslimit" +) + +const ( + performanceNamespace string = "performance" +) + +// Performance exits currently enabled +func ProposePerformanceExitsEnabled(rp *rocketpool.RocketPool, value bool, opts *bind.TransactOpts) (uint64, common.Hash, error) { + return security.ProposeSetBool(rp, fmt.Sprintf("set %s", psettings.PerformanceExitsEnabledSettingPath), performanceNamespace, psettings.PerformanceExitsEnabledSettingPath, value, opts) +} +func EstimateProposePerformanceExitsEnabledGas(rp *rocketpool.RocketPool, value bool, opts *bind.TransactOpts) (gaslimit.Limits, error) { + return security.EstimateProposeSetBoolGas(rp, fmt.Sprintf("set %s", psettings.PerformanceExitsEnabledSettingPath), performanceNamespace, psettings.PerformanceExitsEnabledSettingPath, value, opts) +} diff --git a/bindings/utils/version-checker.go b/bindings/utils/version-checker.go index cfa87ec2b..cee4f81f7 100644 --- a/bindings/utils/version-checker.go +++ b/bindings/utils/version-checker.go @@ -14,6 +14,16 @@ import ( ) func GetCurrentVersion(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*version.Version, error) { + depositPoolVersion, err := deposit.GetRocketDepositPoolVersion(rp, opts) + if err != nil { + return nil, fmt.Errorf("error checking deposit pool version: %w", err) + } + + // Check for v1.5 (Saturn 2) + if depositPoolVersion > 4 { + return version.NewSemver("1.5.0") + } + beaconStateVerifierVersion, err := megapool.GetBeaconStateVerifierVersion(rp, opts) if err != nil { return nil, fmt.Errorf("error checking beacon state verifier version: %w", err) @@ -22,11 +32,6 @@ func GetCurrentVersion(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*version return version.NewSemver("1.4.1") } - depositPoolVersion, err := deposit.GetRocketDepositPoolVersion(rp, opts) - if err != nil { - return nil, fmt.Errorf("error checking deposit pool version: %w", err) - } - // Check for v1.4 (Saturn 1) if depositPoolVersion > 3 { diff --git a/rocketpool-cli/cli/saturn2-check.go b/rocketpool-cli/cli/saturn2-check.go new file mode 100644 index 000000000..aff18525d --- /dev/null +++ b/rocketpool-cli/cli/saturn2-check.go @@ -0,0 +1,35 @@ +package cli + +import ( + "fmt" + + "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/shared/services/rocketpool" +) + +const Saturn2NotDeployedMessage = "This command is not available until Saturn 2 is deployed." + +// IsSaturn2OnlySetting reports whether a protocol setting exists only after Saturn 2. +func IsSaturn2OnlySetting(contract, setting string) bool { + switch contract { + case protocol.PerformanceSettingsContractName, protocol.ExitSettingsContractName: + return true + case protocol.MegapoolSettingsContractName: + return setting == protocol.MegapoolPrestakeChallengePeriodPath + default: + return false + } +} + +// RequireSaturn2 prints Saturn2NotDeployedMessage and returns false when Saturn 2 is not deployed +func RequireSaturn2(rp *rocketpool.Client) (ok bool, err error) { + settings, err := rp.PDAOGetSettings() + if err != nil { + return false, fmt.Errorf("error checking if Saturn 2 is deployed: %w", err) + } + if !settings.Saturn2Deployed { + fmt.Println(Saturn2NotDeployedMessage) + return false, nil + } + return true, nil +} diff --git a/rocketpool-cli/cli/verify-performance/verify-performance.go b/rocketpool-cli/cli/verify-performance/verify-performance.go new file mode 100644 index 000000000..04bac067b --- /dev/null +++ b/rocketpool-cli/cli/verify-performance/verify-performance.go @@ -0,0 +1,200 @@ +// Package verifyperformance contains shared CLI helpers for the +// `rocketpool minipool verify-performance` and +// `rocketpool megapool verify-performance` commands. +package verifyperformance + +import ( + "fmt" + "math/big" + "time" + + "github.com/rocket-pool/smartnode/rocketpool-cli/cli/prompt" + "github.com/rocket-pool/smartnode/shared/services/performance" + "github.com/rocket-pool/smartnode/shared/services/rocketpool" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// LargeEpochRangeWarning is the number of epochs above which the CLI prompts +// the user to confirm, because each epoch issues a committees fetch plus a +// few-dozen block fetches against the beacon node. +const LargeEpochRangeWarning uint64 = 256 + +// ResolveEpochRange fills in defaults for the start/length range. If epochs +// is 0, it defaults to the on-chain performance_period setting. Returns the +// inclusive [startEpoch, endEpoch] range. +func ResolveEpochRange(rp *rocketpool.Client, startEpoch, epochs uint64) (uint64, uint64, error) { + if epochs == 0 { + settings, err := rp.PDAOGetSettings() + if err != nil { + return 0, 0, fmt.Errorf("error fetching pDAO settings for default performance_period: %w", err) + } + epochs = settings.Performance.Period + if epochs == 0 { + epochs = performance.DefaultPerformancePeriodEpochs + } + } + endEpoch := startEpoch + epochs - 1 + return startEpoch, endEpoch, nil +} + +// ConfirmLargeRange prompts the user when the requested epoch range exceeds +// LargeEpochRangeWarning and returns true if the user accepts the warning. +func ConfirmLargeRange(total uint64) bool { + if total <= LargeEpochRangeWarning { + return true + } + return prompt.Confirm( + "This will fetch attestation data for %d epochs from your beacon node. "+ + "For historical epochs this requires an archival beacon node and may take a while. "+ + "Continue?", + total, + ) +} + +// PrintCancelled prints a generic cancellation message and returns nil so it +// can be returned directly from a CLI Action. +func PrintCancelled() error { + fmt.Println("Cancelled.") + return nil +} + +// PrintElapsed prints the wall-clock time the verification took, rounded to the +// millisecond. +func PrintElapsed(elapsed time.Duration) { + fmt.Printf("\nCompleted in %s\n", elapsed.Round(time.Millisecond)) +} + +// PrintResult writes a VerifyPerformanceResponse to stdout in a human-readable +// format. `label` is the human-facing string identifying the validator being +// verified, e.g. "minipool 0x...". +func PrintResult(resp api.VerifyPerformanceResponse, label string) { + fmt.Printf("RPIP-73 target-vote performance for %s\n", label) + fmt.Printf(" Validator: %s (index %d)\n", resp.ValidatorPubkey.Hex(), resp.ValidatorIndex) + fmt.Printf(" Epoch range: %d - %d (inclusive, %d epochs)\n", resp.StartEpoch, resp.EndEpoch, resp.TotalEpochs) + fmt.Printf(" Timely target: %d epochs\n", resp.TimelyEpochs) + fmt.Printf(" Missed target: %d epochs\n", resp.MissedEpochs) + if resp.InactiveEpochs > 0 { + fmt.Printf(" Inactive: %d epochs (no committee assignment, excluded from %%)\n", resp.InactiveEpochs) + } + fmt.Printf(" Performance: %.2f%%\n", resp.PerformancePct) + fmt.Printf(" Threshold: %.2f%%\n", resp.PerformanceThresholdPct) + if resp.PassesThreshold { + fmt.Println(" Result: PASS (not exit-eligible under RPIP-73 with these parameters)") + } else { + fmt.Println(" Result: FAIL (exit-eligible under RPIP-73 with these parameters)") + } + + if len(resp.MissedEpochList) > 0 { + if resp.Challengeable { + fmt.Printf("\nMissed target epochs (challengeable):\n") + } else { + fmt.Printf("\nMissed target epochs (not challengeable):\n") + } + printEpochList(resp.MissedEpochList) + } +} + +// PrintBatchResults writes a batch of verify-performance results to stdout. For +// each result it prints the per-validator detail (or the per-validator error), +// then a closing summary. labelFor produces the human-facing label for a +// result, e.g. "minipool 0x..." or "megapool validator 3". +func PrintBatchResults(resp api.VerifyPerformanceBatchResponse, labelFor func(api.VerifyPerformanceResult) string) { + var passed int + var failedLabels []string + var erroredLabels []string + for i, result := range resp.Results { + if i > 0 { + fmt.Println() + } + label := labelFor(result) + if result.Error != "" { + fmt.Printf("RPIP-73 target-vote performance for %s\n", label) + fmt.Printf(" Error: %s\n", result.Error) + erroredLabels = append(erroredLabels, label) + continue + } + if result.Performance == nil { + fmt.Printf("RPIP-73 target-vote performance for %s\n", label) + fmt.Printf(" Error: no result returned\n") + erroredLabels = append(erroredLabels, label) + continue + } + PrintResult(*result.Performance, label) + if result.Performance.PassesThreshold { + passed++ + } else { + failedLabels = append(failedLabels, label) + } + } + + if len(resp.Results) > 1 { + fmt.Printf("\nSummary: %d validator(s) checked - %d pass, %d fail, %d errored\n", + len(resp.Results), passed, len(failedLabels), len(erroredLabels)) + } + + // List the validators that failed (and any that errored) at the very end so + // they are easy to spot without scrolling back through every result. + if len(failedLabels) > 0 { + fmt.Printf("\nFailed validators (exit-eligible under RPIP-73):\n") + for _, label := range failedLabels { + fmt.Printf(" - %s\n", label) + } + } + if len(erroredLabels) > 0 { + fmt.Printf("\nErrored validators (could not be verified):\n") + for _, label := range erroredLabels { + fmt.Printf(" - %s\n", label) + } + } +} + +// ChallengeGroup is a set of validators sharing an identical missed-epoch +// set, challengeable together in a single challengeMegapool call. +type ChallengeGroup struct { + ValidatorIds []uint32 + StartEpoch uint64 + Participation []*big.Int + MissedEpochs []uint64 +} + +// GroupChallengeable groups the challengeable validators of a batch result by +// identical missed-epoch sets, preserving the order in which they appear in +// the results. Errored, passing, and non-challengeable validators are +// excluded. +func GroupChallengeable(results []api.VerifyPerformanceResult) []ChallengeGroup { + groups := []ChallengeGroup{} + groupIndexByKey := map[string]int{} + for _, result := range results { + perf := result.Performance + if result.Error != "" || perf == nil || !perf.Challengeable || len(perf.MissedEpochList) == 0 { + continue + } + key := fmt.Sprint(perf.StartEpoch, perf.MissedEpochList) + if i, ok := groupIndexByKey[key]; ok { + groups[i].ValidatorIds = append(groups[i].ValidatorIds, result.ValidatorId) + continue + } + groupIndexByKey[key] = len(groups) + groups = append(groups, ChallengeGroup{ + ValidatorIds: []uint32{result.ValidatorId}, + StartEpoch: perf.StartEpoch, + Participation: perf.Participation, + MissedEpochs: perf.MissedEpochList, + }) + } + return groups +} + +func printEpochList(epochs []uint64) { + const perLine = 8 + for i, e := range epochs { + if i%perLine == 0 { + if i > 0 { + fmt.Println() + } + fmt.Print(" ") + } + fmt.Printf("%d ", e) + } + fmt.Println() +} diff --git a/rocketpool-cli/cli/verify-performance/verify-performance_test.go b/rocketpool-cli/cli/verify-performance/verify-performance_test.go new file mode 100644 index 000000000..07c26cd3c --- /dev/null +++ b/rocketpool-cli/cli/verify-performance/verify-performance_test.go @@ -0,0 +1,114 @@ +package verifyperformance + +import ( + "math/big" + "reflect" + "testing" + + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// challengeableResult builds a challengeable verify result for the tests. +func challengeableResult(validatorId uint32, startEpoch uint64, missedEpochs []uint64, participation []*big.Int) api.VerifyPerformanceResult { + return api.VerifyPerformanceResult{ + ValidatorId: validatorId, + Performance: &api.VerifyPerformanceResponse{ + StartEpoch: startEpoch, + MissedEpochList: missedEpochs, + Participation: participation, + Challengeable: true, + }, + } +} + +func TestGroupChallengeable(t *testing.T) { + missedA := []uint64{105000, 105003} + missedB := []uint64{105001} + participationA := []*big.Int{big.NewInt(9)} // bits 0 and 3 + participationB := []*big.Int{big.NewInt(2)} // bit 1 + + passing := api.VerifyPerformanceResult{ + ValidatorId: 90, + Performance: &api.VerifyPerformanceResponse{ + StartEpoch: 105000, + MissedEpochList: []uint64{}, + Challengeable: false, + }, + } + notChallengeable := api.VerifyPerformanceResult{ + ValidatorId: 91, + Performance: &api.VerifyPerformanceResponse{ + StartEpoch: 105000, + MissedEpochList: missedA, + Challengeable: false, + }, + } + errored := api.VerifyPerformanceResult{ + ValidatorId: 92, + Error: "validator not found", + } + + tests := []struct { + name string + results []api.VerifyPerformanceResult + want []ChallengeGroup + }{ + { + name: "no results", + results: []api.VerifyPerformanceResult{}, + want: []ChallengeGroup{}, + }, + { + name: "errored, passing and non-challengeable results are excluded", + results: []api.VerifyPerformanceResult{passing, notChallengeable, errored}, + want: []ChallengeGroup{}, + }, + { + name: "identical missed epochs merge into one group", + results: []api.VerifyPerformanceResult{ + challengeableResult(3, 105000, missedA, participationA), + challengeableResult(7, 105000, missedA, participationA), + }, + want: []ChallengeGroup{ + { + ValidatorIds: []uint32{3, 7}, + StartEpoch: 105000, + Participation: participationA, + MissedEpochs: missedA, + }, + }, + }, + { + name: "different missed epochs form separate groups in first-seen order", + results: []api.VerifyPerformanceResult{ + challengeableResult(3, 105000, missedA, participationA), + challengeableResult(5, 105000, missedB, participationB), + errored, + challengeableResult(7, 105000, missedA, participationA), + }, + want: []ChallengeGroup{ + { + ValidatorIds: []uint32{3, 7}, + StartEpoch: 105000, + Participation: participationA, + MissedEpochs: missedA, + }, + { + ValidatorIds: []uint32{5}, + StartEpoch: 105000, + Participation: participationB, + MissedEpochs: missedB, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := GroupChallengeable(tc.results) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("GroupChallengeable() = %+v, want %+v", got, tc.want) + } + }) + } +} diff --git a/rocketpool-cli/megapool/commands.go b/rocketpool-cli/megapool/commands.go index be57732c0..f0719f8d8 100644 --- a/rocketpool-cli/megapool/commands.go +++ b/rocketpool-cli/megapool/commands.go @@ -3,6 +3,7 @@ package megapool import ( "context" + "github.com/ethereum/go-ethereum/common" "github.com/urfave/cli/v3" cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" @@ -432,6 +433,61 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { return delegateUpgradeMegapool(c.Bool("yes")) }, }, + { + Name: "verify-performance", + Aliases: []string{"vp"}, + Usage: "Verify the RPIP-73 target-vote attestation performance of one or more megapool validators over a range of epochs.", + UsageText: "rocketpool megapool verify-performance validator-ids [options]", + Description: "validator-ids is either a single validator id, a comma-separated list of validator ids, or 'all' to check every validator on the megapool.", + Flags: []cli.Flag{ + &cli.Uint64Flag{ + Name: "start-epoch", + Aliases: []string{"s"}, + Usage: "The first epoch in the inclusive performance period.", + }, + &cli.Uint64Flag{ + Name: "epochs", + Aliases: []string{"e"}, + Usage: "Number of epochs to inspect starting at --start-epoch. Defaults to the pDAO performance_period setting.", + }, + &cli.StringFlag{ + Name: "megapool", + Aliases: []string{"M"}, + Usage: "The megapool address to inspect. Defaults to the current node's megapool when omitted.", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Skip the warning prompt when --epochs is large.", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + targets := c.Args().Get(0) + if err := validateMegapoolTargets(targets); err != nil { + return err + } + + var megapoolAddr common.Address + if c.IsSet("megapool") { + var err error + megapoolAddr, err = cliutils.ValidateAddress("megapool", c.String("megapool")) + if err != nil { + return err + } + } + + return verifyMegapoolPerformance( + megapoolAddr, + targets, + c.Uint64("start-epoch"), + c.Uint64("epochs"), + c.Bool("yes"), + ) + }, + }, { Name: "set-use-latest-delegate", Aliases: []string{"l"}, diff --git a/rocketpool-cli/megapool/verify-performance.go b/rocketpool-cli/megapool/verify-performance.go new file mode 100644 index 000000000..2283dcdde --- /dev/null +++ b/rocketpool-cli/megapool/verify-performance.go @@ -0,0 +1,146 @@ +package megapool + +import ( + "fmt" + "strings" + "time" + + "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" +) + +// validateMegapoolTargets checks that the verify-performance targets argument +// is either "all" or a comma-separated list of valid validator ids. +func validateMegapoolTargets(targets string) error { + if strings.EqualFold(strings.TrimSpace(targets), "all") { + return nil + } + found := false + for _, raw := range strings.Split(targets, ",") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + if _, err := cliutils.ValidateUint32("validator-id", raw); err != nil { + return err + } + found = true + } + if !found { + return fmt.Errorf("no validator id provided; supply an id, a comma-separated list, or 'all'") + } + return nil +} + +func verifyMegapoolPerformance(megapoolAddress common.Address, targetValidators string, startEpoch uint64, epochs uint64, yes bool) error { + rp, err := rocketpool.NewClient().WithReady() + if err != nil { + return err + } + defer rp.Close() + + startEpoch, endEpoch, err := verifyperf.ResolveEpochRange(rp, startEpoch, epochs) + if err != nil { + return err + } + if !yes && !verifyperf.ConfirmLargeRange(endEpoch-startEpoch+1) { + return verifyperf.PrintCancelled() + } + + start := time.Now() + resp, err := rp.VerifyMegapoolValidatorPerformance(megapoolAddress, targetValidators, startEpoch, endEpoch) + if err != nil { + return err + } + elapsed := time.Since(start) + + verifyperf.PrintBatchResults(resp, func(r api.VerifyPerformanceResult) string { + if (megapoolAddress != common.Address{}) { + return fmt.Sprintf("megapool %s validator %d", megapoolAddress.Hex(), r.ValidatorId) + } + return fmt.Sprintf("megapool validator %d", r.ValidatorId) + }) + verifyperf.PrintElapsed(elapsed) + + return 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 +} diff --git a/rocketpool-cli/minipool/commands.go b/rocketpool-cli/minipool/commands.go index 91c1acfb2..6dd6cc9ce 100644 --- a/rocketpool-cli/minipool/commands.go +++ b/rocketpool-cli/minipool/commands.go @@ -326,6 +326,46 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { }, }, + { + Name: "verify-performance", + Aliases: []string{"vp"}, + Usage: "Verify the RPIP-73 target-vote attestation performance of one or more minipools over a range of epochs.", + UsageText: "rocketpool minipool verify-performance minipools [options]", + Description: "minipools is either a single minipool address, a comma-separated list of minipool addresses, or 'all' to check every minipool owned by the node.", + Flags: []cli.Flag{ + &cli.Uint64Flag{ + Name: "start-epoch", + Aliases: []string{"s"}, + Usage: "The first epoch in the inclusive performance period.", + }, + &cli.Uint64Flag{ + Name: "epochs", + Aliases: []string{"e"}, + Usage: "Number of epochs to inspect starting at --start-epoch. Defaults to the pDAO performance_period setting.", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Skip the warning prompt when --epochs is large.", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + targets := c.Args().Get(0) + if err := validateMinipoolTargets(targets); err != nil { + return err + } + return verifyMinipoolPerformance( + targets, + c.Uint64("start-epoch"), + c.Uint64("epochs"), + c.Bool("yes"), + ) + }, + }, + { Name: "rescue-dissolved", Aliases: []string{"rd"}, diff --git a/rocketpool-cli/minipool/verify-performance.go b/rocketpool-cli/minipool/verify-performance.go new file mode 100644 index 000000000..13b0d9bc6 --- /dev/null +++ b/rocketpool-cli/minipool/verify-performance.go @@ -0,0 +1,64 @@ +package minipool + +import ( + "fmt" + "strings" + "time" + + cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" + verifyperf "github.com/rocket-pool/smartnode/rocketpool-cli/cli/verify-performance" + "github.com/rocket-pool/smartnode/shared/services/rocketpool" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// validateMinipoolTargets checks that the verify-performance targets argument +// is either "all" or a comma-separated list of valid minipool addresses. +func validateMinipoolTargets(targets string) error { + if strings.EqualFold(strings.TrimSpace(targets), "all") { + return nil + } + found := false + for _, raw := range strings.Split(targets, ",") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + if _, err := cliutils.ValidateAddress("minipool address", raw); err != nil { + return err + } + found = true + } + if !found { + return fmt.Errorf("no minipool address provided; supply an address, a comma-separated list, or 'all'") + } + return nil +} + +func verifyMinipoolPerformance(targets string, startEpoch uint64, epochs uint64, yes bool) error { + rp, err := rocketpool.NewClient().WithReady() + if err != nil { + return err + } + defer rp.Close() + + startEpoch, endEpoch, err := verifyperf.ResolveEpochRange(rp, startEpoch, epochs) + if err != nil { + return err + } + if !yes && !verifyperf.ConfirmLargeRange(endEpoch-startEpoch+1) { + return verifyperf.PrintCancelled() + } + + start := time.Now() + resp, err := rp.VerifyMinipoolPerformance(targets, startEpoch, endEpoch) + if err != nil { + return err + } + elapsed := time.Since(start) + + verifyperf.PrintBatchResults(resp, func(r api.VerifyPerformanceResult) string { + return "minipool " + r.MinipoolAddress.Hex() + }) + verifyperf.PrintElapsed(elapsed) + return nil +} diff --git a/rocketpool-cli/pdao/commands.go b/rocketpool-cli/pdao/commands.go index 7a2676ff3..12656776e 100644 --- a/rocketpool-cli/pdao/commands.go +++ b/rocketpool-cli/pdao/commands.go @@ -18,7 +18,10 @@ const ( percentUsage string = "specify a percentage between 0 and 1 (e.g., '0.51' for 51%)" unboundedPercentUsage string = "specify a percentage that can go over 100% (e.g., '1.5' for 150%)" uintUsage string = "specify an integer (e.g., '50')" + floatMultiplierUsage string = "specify a multiplier (e.g., '1.5')" epochCountUsage string = "specify a number, in epochs (eg., '100')" + hourCountUsage string = "specify a number, in hours (e.g., '72')" + dayCountUsage string = "specify a number, in days (e.g., '28')" durationUsage string = "specify a duration using hours, minutes, and seconds (e.g., '20m' or '72h0m0s')" addressListUsage string = "specify a list of one or more addresses separated by commas (e.g., '0x1a2b3c4d5e6f7890abcdef1234567890abcdef12,0xabcdefabcdefabcdefabcdefabcdefabcdefabcd')" ) @@ -3309,6 +3312,401 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { }, }, + + { + Name: "prestake-challenge-period", + Aliases: []string{"pcp"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.MegapoolPrestakeChallengePeriodPath, hourCountUsage), + UsageText: "rocketpool pdao propose setting megapool prestake-challenge-period value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidatePositiveUint("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingMegapoolPrestakeChallengePeriod(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + }, + }, + + { + Name: "performance", + Aliases: []string{"perf"}, + Usage: "Performance exit settings (RPIP-73)", + Commands: []*cli.Command{ + + { + Name: "exits-enabled", + Aliases: []string{"ee"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformanceExitsEnabledSettingPath, boolUsage), + UsageText: "rocketpool pdao propose setting performance exits-enabled value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateBool("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingPerformanceExitsEnabled(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "period", + Aliases: []string{"p"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformancePeriodSettingPath, epochCountUsage), + UsageText: "rocketpool pdao propose setting performance period value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidatePositiveUint("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingPerformancePeriod(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "proof-buffer", + Aliases: []string{"pb"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.ProofBufferSettingPath, epochCountUsage), + UsageText: "rocketpool pdao propose setting performance proof-buffer value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidatePositiveUint("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingProofBuffer(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "threshold", + Aliases: []string{"t"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformanceThresholdSettingPath, percentUsage), + UsageText: "rocketpool pdao propose setting performance threshold value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "raw", + Usage: "Add this flag if your setting is an 18-decimal-fixed-point-integer (wei) value instead of a float", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateFloat(c.Bool("raw"), "value", c.Args().Get(0), true, c.Bool("yes")) + if err != nil { + return err + } + + // Run + return proposeSettingPerformanceThreshold(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "challenge-period", + Aliases: []string{"cp"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformanceChallengePeriodSettingPath, durationUsage), + UsageText: "rocketpool pdao propose setting performance challenge-period value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateDuration("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingPerformanceChallengePeriod(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "challenge-bond", + Aliases: []string{"cb"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformanceChallengeBondSettingPath, floatRplUsage), + UsageText: "rocketpool pdao propose setting performance challenge-bond value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "raw", + Usage: "Add this flag if your setting is an 18-decimal-fixed-point-integer (wei) value instead of a float", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateFloat(c.Bool("raw"), "value", c.Args().Get(0), false, c.Bool("yes")) + if err != nil { + return err + } + + // Run + return proposeSettingPerformanceChallengeBond(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + }, + }, + + { + Name: "exit", + Aliases: []string{"x"}, + Usage: "Exit request settings (RPIP-80)", + Commands: []*cli.Command{ + + { + Name: "cooperative-exit-phase", + Aliases: []string{"cep"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.CooperativeExitPhaseSettingPath, hourCountUsage), + UsageText: "rocketpool pdao propose setting exit cooperative-exit-phase value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidatePositiveUint("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingCooperativeExitPhase(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "did-not-exit-penalty-base", + Aliases: []string{"dnepb"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.DidNotExitPenaltyBaseSettingPath, floatEthUsage), + UsageText: "rocketpool pdao propose setting exit did-not-exit-penalty-base value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "raw", + Usage: "Add this flag if your setting is an 18-decimal-fixed-point-integer (wei) value instead of a float", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateFloat(c.Bool("raw"), "value", c.Args().Get(0), false, c.Bool("yes")) + if err != nil { + return err + } + + // Run + return proposeSettingDidNotExitPenaltyBase(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "did-not-exit-base", + Aliases: []string{"dneb"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.DidNotExitBaseSettingPath, dayCountUsage), + UsageText: "rocketpool pdao propose setting exit did-not-exit-base value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidatePositiveUint("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingDidNotExitBase(value, c.Bool("yes"), c.String("to-json")) + + }, + }, + + { + Name: "did-not-exit-backoff", + Aliases: []string{"dnebo"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.DidNotExitBackoffSettingPath, floatMultiplierUsage), + UsageText: "rocketpool pdao propose setting exit did-not-exit-backoff value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "raw", + Usage: "Add this flag if your setting is an 18-decimal-fixed-point-integer (wei) value instead of a float", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + &cli.StringFlag{ + Name: "to-json", + Usage: "Write this setting to a JSON file instead of submitting a proposal (creates the file or appends to it)", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateFloat(c.Bool("raw"), "value", c.Args().Get(0), false, c.Bool("yes")) + if err != nil { + return err + } + + // Run + return proposeSettingDidNotExitBackoff(value, c.Bool("yes"), c.String("to-json")) + + }, + }, }, }, }, diff --git a/rocketpool-cli/pdao/get-settings.go b/rocketpool-cli/pdao/get-settings.go index 7fd553b66..30e8511e2 100644 --- a/rocketpool-cli/pdao/get-settings.go +++ b/rocketpool-cli/pdao/get-settings.go @@ -132,6 +132,26 @@ func getSettings() error { fmt.Printf("\tUpgrade Delay: %s\n", response.Security.UpgradeDelay) fmt.Println() + if response.Saturn2Deployed { + // Performance + fmt.Println("== Performance Settings ==") + fmt.Printf("\tPerformance Exits Enabled: %t\n", response.Performance.ExitsEnabled) + fmt.Printf("\tPerformance Period: %d Epochs\n", response.Performance.Period) + fmt.Printf("\tProof Buffer: %d Epochs\n", response.Performance.ProofBuffer) + fmt.Printf("\tPerformance Threshold: %.2f%%\n", math.WeiToEth(response.Performance.Threshold)*100) + fmt.Printf("\tChallenge Period: %s\n", response.Performance.ChallengePeriod) + fmt.Printf("\tChallenge Bond: %.6f RPL\n", math.WeiToEth(response.Performance.ChallengeBond)) + fmt.Println() + + // Exit + fmt.Println("== Exit Settings (RPIP-80) ==") + fmt.Printf("\tCooperative Exit Phase: %.0f Hours\n", response.Exit.CooperativeExitPhase.Hours()) + fmt.Printf("\tDid Not Exit Penalty Base: %.6f ETH\n", math.WeiToEth(response.Exit.DidNotExitPenaltyBase)) + fmt.Printf("\tDid Not Exit Base: %s\n", response.Exit.DidNotExitBase) + fmt.Printf("\tDid Not Exit Backoff: %.2fx\n", math.WeiToEth(response.Exit.DidNotExitBackoff)) + fmt.Println() + } + // Megapool fmt.Println("== Megapool Settings ==") fmt.Printf("\tTime Before Dissolve: %s\n", response.Megapool.TimeBeforeDissolve) @@ -142,6 +162,9 @@ func getSettings() error { fmt.Printf("\tUser Distribute Delay: %d Epochs\n", response.Megapool.UserDistributeDelay) fmt.Printf("\tUser Distribute Delay with Shortfall: %d Epochs\n", response.Megapool.UserDistributeDelayWithShortfall) fmt.Printf("\tPenalty Threshold: %.2f%%\n", math.WeiToEth(response.Megapool.PenaltyThreshold)*100) + if response.Saturn2Deployed { + fmt.Printf("\tPrestake Challenge Period: %.0f Hours\n", response.Megapool.PrestakeChallengePeriod.Hours()) + } return nil } diff --git a/rocketpool-cli/pdao/propose-settings.go b/rocketpool-cli/pdao/propose-settings.go index b739f83a8..86874d9fe 100644 --- a/rocketpool-cli/pdao/propose-settings.go +++ b/rocketpool-cli/pdao/propose-settings.go @@ -365,6 +365,61 @@ func proposeSettingPenaltyThreshold(value *big.Int, yes bool, toJson string) err return proposeSetting(protocol.MegapoolSettingsContractName, protocol.MegapoolPenaltyThreshold, trueValue, yes, toJson) } +func proposeSettingMegapoolPrestakeChallengePeriod(value uint64, yes bool, toJson string) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.MegapoolSettingsContractName, protocol.MegapoolPrestakeChallengePeriodPath, trueValue, yes, toJson) +} + +func proposeSettingPerformanceExitsEnabled(value bool, yes bool, toJson string) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceExitsEnabledSettingPath, trueValue, yes, toJson) +} + +func proposeSettingPerformancePeriod(value uint64, yes bool, toJson string) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformancePeriodSettingPath, trueValue, yes, toJson) +} + +func proposeSettingProofBuffer(value uint64, yes bool, toJson string) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.ProofBufferSettingPath, trueValue, yes, toJson) +} + +func proposeSettingPerformanceThreshold(value *big.Int, yes bool, toJson string) error { + trueValue := value.String() + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceThresholdSettingPath, trueValue, yes, toJson) +} + +func proposeSettingPerformanceChallengePeriod(value time.Duration, yes bool, toJson string) error { + trueValue := fmt.Sprint(uint64(value.Hours())) + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceChallengePeriodSettingPath, trueValue, yes, toJson) +} + +func proposeSettingPerformanceChallengeBond(value *big.Int, yes bool, toJson string) error { + trueValue := value.String() + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceChallengeBondSettingPath, trueValue, yes, toJson) +} + +func proposeSettingCooperativeExitPhase(value uint64, yes bool, toJson string) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.ExitSettingsContractName, protocol.CooperativeExitPhaseSettingPath, trueValue, yes, toJson) +} + +func proposeSettingDidNotExitPenaltyBase(value *big.Int, yes bool, toJson string) error { + trueValue := value.String() + return proposeSetting(protocol.ExitSettingsContractName, protocol.DidNotExitPenaltyBaseSettingPath, trueValue, yes, toJson) +} + +func proposeSettingDidNotExitBase(value uint64, yes bool, toJson string) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.ExitSettingsContractName, protocol.DidNotExitBaseSettingPath, trueValue, yes, toJson) +} + +func proposeSettingDidNotExitBackoff(value *big.Int, yes bool, toJson string) error { + trueValue := value.String() + return proposeSetting(protocol.ExitSettingsContractName, protocol.DidNotExitBackoffSettingPath, trueValue, yes, toJson) +} + func proposeSettingNodeCommissionShare(value *big.Int, yes bool, toJson string) error { trueValue := value.String() return proposeSetting(protocol.NetworkSettingsContractName, protocol.NetworkNodeCommissionSharePath, trueValue, yes, toJson) @@ -413,6 +468,13 @@ func proposeSetting(contract string, setting string, value string, yes bool, toJ } defer rp.Close() + if cliutils.IsSaturn2OnlySetting(contract, setting) { + ok, err := cliutils.RequireSaturn2(rp) + if err != nil || !ok { + return err + } + } + // Check if proposal can be made canPropose, err := rp.PDAOCanProposeSetting(contract, setting, value) if err != nil { diff --git a/rocketpool-cli/pdao/submit-batch.go b/rocketpool-cli/pdao/submit-batch.go index 4010196a4..960976e15 100644 --- a/rocketpool-cli/pdao/submit-batch.go +++ b/rocketpool-cli/pdao/submit-batch.go @@ -80,6 +80,16 @@ func submitBatch(file string, message string, yes bool) error { } defer rp.Close() + for _, setting := range settings { + if cliutils.IsSaturn2OnlySetting(setting.Contract, setting.Setting) { + ok, err := cliutils.RequireSaturn2(rp) + if err != nil || !ok { + return err + } + break + } + } + canPropose, err := rp.PDAOCanProposeSettingMulti(settings, message) if err != nil { return err diff --git a/rocketpool-cli/security/commands.go b/rocketpool-cli/security/commands.go index 58d509d5b..b53ee1c31 100644 --- a/rocketpool-cli/security/commands.go +++ b/rocketpool-cli/security/commands.go @@ -551,6 +551,43 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { }, }, }, + + { + Name: "performance", + Aliases: []string{"perf"}, + Usage: "Performance exit settings (RPIP-73)", + Commands: []*cli.Command{ + + { + Name: "exits-enabled", + Aliases: []string{"ee"}, + Usage: fmt.Sprintf("Propose updating the %s setting; %s", protocol.PerformanceExitsEnabledSettingPath, boolUsage), + UsageText: "rocketpool security propose setting performance exits-enabled value", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm all interactive questions", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + value, err := cliutils.ValidateBool("value", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return proposeSettingPerformanceExitsEnabled(value, c.Bool("yes")) + + }, + }, + }, + }, }, }, }, diff --git a/rocketpool-cli/security/propose-settings.go b/rocketpool-cli/security/propose-settings.go index a130c3034..6d759026e 100644 --- a/rocketpool-cli/security/propose-settings.go +++ b/rocketpool-cli/security/propose-settings.go @@ -82,6 +82,11 @@ func proposeSettingNodeComissionShareSecurityCouncilAdder(value *big.Int, yes bo return proposeSetting(protocol.NetworkSettingsContractName, protocol.NetworkNodeCommissionShareSecurityCouncilAdderPath, trueValue, yes) } +func proposeSettingPerformanceExitsEnabled(value bool, yes bool) error { + trueValue := fmt.Sprint(value) + return proposeSetting(protocol.PerformanceSettingsContractName, protocol.PerformanceExitsEnabledSettingPath, trueValue, yes) +} + // Master general proposal function func proposeSetting(contract string, setting string, value string, yes bool) error { // Get RP client @@ -91,6 +96,13 @@ func proposeSetting(contract string, setting string, value string, yes bool) err } defer rp.Close() + if cliutils.IsSaturn2OnlySetting(contract, setting) { + ok, err := cliutils.RequireSaturn2(rp) + if err != nil || !ok { + return err + } + } + // Check if proposal can be made canPropose, err := rp.SecurityCanProposeSetting(contract, setting, value) if err != nil { diff --git a/rocketpool/api/megapool/challenge-performance.go b/rocketpool/api/megapool/challenge-performance.go new file mode 100644 index 000000000..a9df7eaba --- /dev/null +++ b/rocketpool/api/megapool/challenge-performance.go @@ -0,0 +1,220 @@ +package megapool + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v3" + + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/node" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/bindings/tokens" + "github.com/rocket-pool/smartnode/rocketpool/api/response" + "github.com/rocket-pool/smartnode/rocketpool/api/snroute" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/wallet" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// canChallengePerformance checks whether the node can challenge the target-vote +// performance of a 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, megapoolAddress, pubkey, nil) + if err != nil { + return 0, megapool.SlotProof{}, fmt.Errorf("error building slot proof: %w", err) + } + return slotTimestamp, slotProof, nil +} + +func canChallengePerformanceHandler(ctx snroute.Context) { + megapoolAddr, 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) +} diff --git a/rocketpool/api/megapool/routes.go b/rocketpool/api/megapool/routes.go index 94685b55f..129af9d50 100644 --- a/rocketpool/api/megapool/routes.go +++ b/rocketpool/api/megapool/routes.go @@ -5,6 +5,9 @@ import ( "math/big" "net/http" "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" "github.com/rocket-pool/smartnode/rocketpool/api/response" "github.com/rocket-pool/smartnode/rocketpool/api/snroute" @@ -48,6 +51,9 @@ func RegisterRoutes(router *snroute.Router) { snroute.Read("/api/megapool/get-effective-delegate", getEffectiveDelegateHandler).RegisterTo(router) snroute.Read("/api/megapool/latest-block-withdrawals", latestBlockWithdrawalsHandler).RegisterTo(router) snroute.Read("/api/megapool/beacon-withdrawal-queue-estimate", beaconWithdrawalQueueEstimateHandler).RegisterTo(router) + snroute.Read("/api/megapool/verify-performance", verifyPerformanceHandler).RegisterTo(router) + snroute.Read("/api/megapool/can-challenge-performance", canChallengePerformanceHandler).RegisterTo(router) + snroute.Write("/api/megapool/challenge-performance", challengePerformanceHandler).RegisterTo(router) } func parseUint64(r *http.Request, name string) (uint64, error) { @@ -82,6 +88,58 @@ 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/verify-performance.go b/rocketpool/api/megapool/verify-performance.go new file mode 100644 index 000000000..d64a8bea8 --- /dev/null +++ b/rocketpool/api/megapool/verify-performance.go @@ -0,0 +1,179 @@ +package megapool + +import ( + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v3" + + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/node" + rptypes "github.com/rocket-pool/smartnode/bindings/types" + + cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" + "github.com/rocket-pool/smartnode/rocketpool/api/response" + "github.com/rocket-pool/smartnode/rocketpool/api/snroute" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/performance" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// verifyPerformance computes the RPIP-73 target-vote performance over the +// inclusive epoch range [startEpoch, endEpoch] for one or more validators of a +// megapool. If megapoolAddress is the zero address, the node's own megapool +// address is used +func verifyPerformance( + c *cli.Command, + megapoolAddress common.Address, + targets string, + startEpoch uint64, + endEpoch uint64, +) (*api.VerifyPerformanceBatchResponse, error) { + if err := services.RequireBeaconClientSynced(c); err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + if (megapoolAddress == common.Address{}) { + if err := services.RequireNodeRegistered(c); err != nil { + return nil, fmt.Errorf("no megapool address supplied and node is not registered: %w", err) + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + nodeAccount, err := w.GetNodeAccount() + if err != nil { + return nil, err + } + megapoolAddress, err = node.GetMegapoolAddress(rp, nodeAccount.Address, nil) + if err != nil { + return nil, fmt.Errorf("error looking up node's megapool address: %w", err) + } + if (megapoolAddress == common.Address{}) { + return nil, fmt.Errorf("node has no megapool deployed; pass --megapool to specify one") + } + } + + mp, err := megapool.NewMegaPoolV1(rp, megapoolAddress, nil) + if err != nil { + return nil, fmt.Errorf("error creating megapool binding for %s: %w", megapoolAddress.Hex(), err) + } + + validatorIds, err := resolveMegapoolTargets(mp, targets) + if err != nil { + return nil, err + } + if len(validatorIds) == 0 { + return nil, fmt.Errorf("no megapool validators to verify") + } + + // Resolve each validator's pubkey up front. Per-validator pubkey failures + // are recorded and excluded from the beacon batch. + pubkeys := make([]rptypes.ValidatorPubkey, len(validatorIds)) + pubkeyErrs := make([]string, len(validatorIds)) + for i, validatorId := range validatorIds { + pubkey, err := mp.GetValidatorPubkey(validatorId, nil) + if err != nil { + pubkeyErrs[i] = fmt.Sprintf("error getting megapool %s validator %d pubkey: %s", megapoolAddress.Hex(), validatorId, err.Error()) + continue + } + pubkeys[i] = pubkey + } + + batch, err := performance.VerifyPerformanceBatch(rp, bc, pubkeys, startEpoch, endEpoch) + if err != nil { + return nil, err + } + + challengeable, err := performance.IsRangeChallengeable(rp, bc, startEpoch, endEpoch) + if err != nil { + return nil, err + } + + response := &api.VerifyPerformanceBatchResponse{ + Results: make([]api.VerifyPerformanceResult, 0, len(validatorIds)), + } + for i, validatorId := range validatorIds { + if !batch[i].Active { + continue + } + result := api.VerifyPerformanceResult{ValidatorId: validatorId} + switch { + case pubkeyErrs[i] != "": + result.Error = pubkeyErrs[i] + case batch[i].Err != nil: + result.Error = batch[i].Err.Error() + default: + result.Performance = batch[i].Response + result.Performance.Challengeable = challengeable && performance.ExceedsChallengeThreshold(result.Performance) + } + response.Results = append(response.Results, result) + } + + return response, nil +} + +// resolveMegapoolTargets turns the targets string into a concrete list of validator IDs +func resolveMegapoolTargets(mp megapool.Megapool, targets string) ([]uint32, error) { + if strings.EqualFold(strings.TrimSpace(targets), "all") { + count, err := mp.GetValidatorCount(nil) + if err != nil { + return nil, fmt.Errorf("error getting megapool validator count: %w", err) + } + ids := make([]uint32, 0, count) + for i := uint32(0); i < count; i++ { + ids = append(ids, i) + } + return ids, nil + } + + var ids []uint32 + for _, raw := range strings.Split(targets, ",") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + id, err := cliutils.ValidateUint32("validator-id", raw) + if err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, nil +} + +func verifyPerformanceHandler(ctx snroute.Context) { + targets := ctx.Request.FormValue("targets") + if targets == "" { + response.WriteErrorResponse(ctx.Writer, fmt.Errorf("missing required parameter 'targets'")) + return + } + startEpoch, err := parseUint64(ctx.Request, "startEpoch") + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + endEpoch, err := parseUint64(ctx.Request, "endEpoch") + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + // megapoolAddress is optional; zero address means "use the node's own megapool". + var megapoolAddr common.Address + if raw := ctx.Request.URL.Query().Get("megapoolAddress"); raw != "" { + megapoolAddr = common.HexToAddress(raw) + } else if raw := ctx.Request.FormValue("megapoolAddress"); raw != "" { + megapoolAddr = common.HexToAddress(raw) + } + resp, err := verifyPerformance(ctx.Command(), megapoolAddr, targets, startEpoch, endEpoch) + response.WriteResponse(ctx.Writer, resp, err) +} diff --git a/rocketpool/api/minipool/routes.go b/rocketpool/api/minipool/routes.go index c0262269e..baef18604 100644 --- a/rocketpool/api/minipool/routes.go +++ b/rocketpool/api/minipool/routes.go @@ -3,6 +3,7 @@ package minipool import ( "fmt" "net/http" + "strconv" "github.com/ethereum/go-ethereum/common" @@ -39,8 +40,8 @@ func RegisterRoutes(router *snroute.Router) { snroute.Read("/api/minipool/can-change-withdrawal-creds", canChangeWithdrawalCredsHandler).RegisterTo(router) snroute.Write("/api/minipool/change-withdrawal-creds", changeWithdrawalCredsHandler).RegisterTo(router) snroute.Read("/api/minipool/get-rescue-dissolved-details-for-node", getRescueDissolvedDetailsForNodeHandler).RegisterTo(router) + snroute.Read("/api/minipool/verify-performance", verifyPerformanceHandler).RegisterTo(router) snroute.Write("/api/minipool/rescue-dissolved", rescueDissolvedHandler).RegisterTo(router) - } func parseAddress(r *http.Request, name string) (common.Address, error) { @@ -53,3 +54,18 @@ func parseAddress(r *http.Request, name string) (common.Address, error) { } return common.HexToAddress(raw), nil } + +func parseUint64Param(r *http.Request, name string) (uint64, error) { + raw := r.URL.Query().Get(name) + if raw == "" { + raw = r.FormValue(name) + } + if raw == "" { + return 0, fmt.Errorf("missing required parameter: %s", name) + } + v, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid %s: %w", name, err) + } + return v, nil +} diff --git a/rocketpool/api/minipool/verify-performance.go b/rocketpool/api/minipool/verify-performance.go new file mode 100644 index 000000000..797e85b05 --- /dev/null +++ b/rocketpool/api/minipool/verify-performance.go @@ -0,0 +1,152 @@ +package minipool + +import ( + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v3" + + "github.com/rocket-pool/smartnode/bindings/minipool" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + rptypes "github.com/rocket-pool/smartnode/bindings/types" + + cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" + "github.com/rocket-pool/smartnode/rocketpool/api/response" + "github.com/rocket-pool/smartnode/rocketpool/api/snroute" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/performance" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// verifyPerformance computes the RPIP-73 target-vote performance over the +// inclusive epoch range [startEpoch, endEpoch] for one or more of the node's +// minipools +func verifyPerformance( + c *cli.Command, + targets string, + startEpoch uint64, + endEpoch uint64, +) (*api.VerifyPerformanceBatchResponse, error) { + if err := services.RequireBeaconClientSynced(c); err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + addresses, err := resolveMinipoolTargets(c, rp, targets) + if err != nil { + return nil, err + } + if len(addresses) == 0 { + return nil, fmt.Errorf("no minipools to verify") + } + + // Resolve each minipool's validator pubkey up front. Per-minipool pubkey + // failures are recorded and excluded from the beacon batch. + pubkeys := make([]rptypes.ValidatorPubkey, len(addresses)) + pubkeyErrs := make([]string, len(addresses)) + for i, address := range addresses { + pubkey, err := minipool.GetMinipoolPubkey(rp, address, nil) + if err != nil { + pubkeyErrs[i] = fmt.Sprintf("error getting minipool %s pubkey: %s", address.Hex(), err.Error()) + continue + } + pubkeys[i] = pubkey + } + + batch, err := performance.VerifyPerformanceBatch(rp, bc, pubkeys, startEpoch, endEpoch) + if err != nil { + return nil, err + } + + challengeable, err := performance.IsRangeChallengeable(rp, bc, startEpoch, endEpoch) + if err != nil { + return nil, err + } + + response := &api.VerifyPerformanceBatchResponse{ + Results: make([]api.VerifyPerformanceResult, 0, len(addresses)), + } + for i, address := range addresses { + if !batch[i].Active { + continue + } + result := api.VerifyPerformanceResult{MinipoolAddress: address} + switch { + case pubkeyErrs[i] != "": + result.Error = pubkeyErrs[i] + case batch[i].Err != nil: + result.Error = batch[i].Err.Error() + default: + result.Performance = batch[i].Response + result.Performance.Challengeable = challengeable && performance.ExceedsChallengeThreshold(result.Performance) + } + response.Results = append(response.Results, result) + } + + return response, nil +} + +// resolveMinipoolTargets turns the targets string ("all" or a comma-separated +// list of addresses) into a concrete list of minipool addresses +func resolveMinipoolTargets(c *cli.Command, rp *rocketpool.RocketPool, targets string) ([]common.Address, error) { + if strings.EqualFold(strings.TrimSpace(targets), "all") { + if err := services.RequireNodeRegistered(c); err != nil { + return nil, err + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + nodeAccount, err := w.GetNodeAccount() + if err != nil { + return nil, err + } + addresses, err := minipool.GetNodeMinipoolAddresses(rp, nodeAccount.Address, nil) + if err != nil { + return nil, fmt.Errorf("error getting node minipool addresses: %w", err) + } + return addresses, nil + } + + var addresses []common.Address + for _, raw := range strings.Split(targets, ",") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + address, err := cliutils.ValidateAddress("minipool address", raw) + if err != nil { + return nil, err + } + addresses = append(addresses, address) + } + return addresses, nil +} + +func verifyPerformanceHandler(ctx snroute.Context) { + targets := ctx.Request.FormValue("targets") + if targets == "" { + response.WriteErrorResponse(ctx.Writer, fmt.Errorf("missing required parameter 'targets'")) + return + } + startEpoch, err := parseUint64Param(ctx.Request, "startEpoch") + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + endEpoch, err := parseUint64Param(ctx.Request, "endEpoch") + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + resp, err := verifyPerformance(ctx.Command(), targets, startEpoch, endEpoch) + response.WriteResponse(ctx.Writer, resp, err) +} diff --git a/rocketpool/api/pdao/get-settings.go b/rocketpool/api/pdao/get-settings.go index 6796d2cf8..4f311454b 100644 --- a/rocketpool/api/pdao/get-settings.go +++ b/rocketpool/api/pdao/get-settings.go @@ -11,6 +11,7 @@ import ( "github.com/rocket-pool/smartnode/rocketpool/api/snroute" "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/state" "github.com/rocket-pool/smartnode/shared/types/api" ) @@ -25,6 +26,11 @@ func getSettings(c *cli.Command) (*api.GetPDAOSettingsResponse, error) { // Response response := api.GetPDAOSettingsResponse{} + response.Saturn2Deployed, err = state.IsSaturn2Deployed(rp, nil) + if err != nil { + return nil, err + } + // Data var wg errgroup.Group @@ -186,6 +192,76 @@ func getSettings(c *cli.Command) (*api.GetPDAOSettingsResponse, error) { return err }) + // === Performance === + + if response.Saturn2Deployed { + wg.Go(func() error { + var err error + response.Performance.ExitsEnabled, err = protocol.GetPerformanceExitsEnabled(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Performance.Period, err = protocol.GetPerformancePeriod(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Performance.ProofBuffer, err = protocol.GetProofBuffer(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Performance.Threshold, err = protocol.GetPerformanceThreshold(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Performance.ChallengePeriod, err = protocol.GetPerformanceChallengePeriod(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Performance.ChallengeBond, err = protocol.GetPerformanceChallengeBond(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Exit.CooperativeExitPhase, err = protocol.GetCooperativeExitPhase(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Exit.DidNotExitPenaltyBase, err = protocol.GetDidNotExitPenaltyBase(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Exit.DidNotExitBase, err = protocol.GetDidNotExitBase(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Exit.DidNotExitBackoff, err = protocol.GetDidNotExitBackoff(rp, nil) + return err + }) + + wg.Go(func() error { + var err error + response.Megapool.PrestakeChallengePeriod, err = protocol.GetPrestakeChallengePeriod(rp, nil) + return err + }) + } + // === Auction === wg.Go(func() error { diff --git a/rocketpool/api/pdao/propose-settings.go b/rocketpool/api/pdao/propose-settings.go index 8a2f2fa4c..ee1c31e13 100644 --- a/rocketpool/api/pdao/propose-settings.go +++ b/rocketpool/api/pdao/propose-settings.go @@ -959,6 +959,132 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, if err != nil { return nil, fmt.Errorf("error estimating gas for proposing PenaltyThreshold: %w", err) } + // PrestakeChallengePeriod + case protocol.MegapoolPrestakeChallengePeriodPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = protocol.EstimateProposePrestakeChallengePeriod(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PrestakeChallengePeriod: %w", err) + } + } + + case protocol.PerformanceSettingsContractName: + switch settingName { + // PerformanceExitsEnabled + case protocol.PerformanceExitsEnabledSettingPath: + newValue, err := cliutils.ValidateBool(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = protocol.EstimateProposePerformanceExitsEnabledGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PerformanceExitsEnabled: %w", err) + } + + // PerformancePeriod + case protocol.PerformancePeriodSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = protocol.EstimateProposePerformancePeriodGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PerformancePeriod: %w", err) + } + + // ProofBuffer + case protocol.ProofBufferSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = protocol.EstimateProposeProofBufferGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing ProofBuffer: %w", err) + } + + // PerformanceThreshold + case protocol.PerformanceThresholdSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = protocol.EstimateProposePerformanceThresholdGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PerformanceThreshold: %w", err) + } + + // PerformanceChallengePeriod + case protocol.PerformanceChallengePeriodSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = protocol.EstimateProposePerformanceChallengePeriodGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PerformanceChallengePeriod: %w", err) + } + + // PerformanceChallengeBond + case protocol.PerformanceChallengeBondSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = protocol.EstimateProposePerformanceChallengeBondGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PerformanceChallengeBond: %w", err) + } + } + + case protocol.ExitSettingsContractName: + switch settingName { + // CooperativeExitPhase + case protocol.CooperativeExitPhaseSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = protocol.EstimateProposeCooperativeExitPhaseGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing CooperativeExitPhase: %w", err) + } + + // DidNotExitPenaltyBase + case protocol.DidNotExitPenaltyBaseSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = protocol.EstimateProposeDidNotExitPenaltyBaseGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing DidNotExitPenaltyBase: %w", err) + } + + // DidNotExitBase + case protocol.DidNotExitBaseSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = protocol.EstimateProposeDidNotExitBaseGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing DidNotExitBase: %w", err) + } + + // DidNotExitBackoff + case protocol.DidNotExitBackoffSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = protocol.EstimateProposeDidNotExitBackoffGas(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing DidNotExitBackoff: %w", err) + } } } @@ -1846,7 +1972,133 @@ func proposeSetting(c *cli.Command, contractName string, settingName string, val if err != nil { return nil, fmt.Errorf("error proposing PenaltyThreshold: %w", err) } + // PrestakeChallengePeriod + case protocol.MegapoolPrestakeChallengePeriodPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposePrestakeChallengePeriod(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PrestakeChallengePeriod: %w", err) + } + + } + + case protocol.PerformanceSettingsContractName: + switch settingName { + // PerformanceExitsEnabled + case protocol.PerformanceExitsEnabledSettingPath: + newValue, err := cliutils.ValidateBool(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposePerformanceExitsEnabled(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PerformanceExitsEnabled: %w", err) + } + + // PerformancePeriod + case protocol.PerformancePeriodSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposePerformancePeriod(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PerformancePeriod: %w", err) + } + + // ProofBuffer + case protocol.ProofBufferSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposeProofBuffer(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing ProofBuffer: %w", err) + } + + // PerformanceThreshold + case protocol.PerformanceThresholdSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposePerformanceThreshold(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PerformanceThreshold: %w", err) + } + + // PerformanceChallengePeriod + case protocol.PerformanceChallengePeriodSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposePerformanceChallengePeriod(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PerformanceChallengePeriod: %w", err) + } + + // PerformanceChallengeBond + case protocol.PerformanceChallengeBondSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposePerformanceChallengeBond(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PerformanceChallengeBond: %w", err) + } + } + + case protocol.ExitSettingsContractName: + switch settingName { + // CooperativeExitPhase + case protocol.CooperativeExitPhaseSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposeCooperativeExitPhase(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing CooperativeExitPhase: %w", err) + } + // DidNotExitPenaltyBase + case protocol.DidNotExitPenaltyBaseSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposeDidNotExitPenaltyBase(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing DidNotExitPenaltyBase: %w", err) + } + + // DidNotExitBase + case protocol.DidNotExitBaseSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposeDidNotExitBase(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing DidNotExitBase: %w", err) + } + + // DidNotExitBackoff + case protocol.DidNotExitBackoffSettingPath: + newValue, err := cliutils.ValidateBigInt(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = protocol.ProposeDidNotExitBackoff(rp, newValue, blockNumber, pollard, opts) + if err != nil { + return nil, fmt.Errorf("error proposing DidNotExitBackoff: %w", err) + } } } diff --git a/rocketpool/api/security/propose-settings.go b/rocketpool/api/security/propose-settings.go index 656fa9831..7f9c9c7b0 100644 --- a/rocketpool/api/security/propose-settings.go +++ b/rocketpool/api/security/propose-settings.go @@ -202,6 +202,20 @@ func canProposeSetting(c *cli.Command, contractName string, settingName string, return nil, fmt.Errorf("error estimating gas for proposing VacantMinipoolsEnabled: %w", err) } } + + case protocol.PerformanceSettingsContractName: + switch settingName { + // PerformanceExitsEnabled + case protocol.PerformanceExitsEnabledSettingPath: + newValue, err := cliutils.ValidateBool(valueName, value) + if err != nil { + return nil, err + } + response.GasLimits, err = security.EstimateProposePerformanceExitsEnabledGas(rp, newValue, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for proposing PerformanceExitsEnabled: %w", err) + } + } } // Make sure a setting was actually hit @@ -394,6 +408,20 @@ func proposeSetting(c *cli.Command, contractName string, settingName string, val return nil, fmt.Errorf("error proposing VacantMinipoolsEnabled: %w", err) } } + + case protocol.PerformanceSettingsContractName: + switch settingName { + // PerformanceExitsEnabled + case protocol.PerformanceExitsEnabledSettingPath: + newValue, err := cliutils.ValidateBool(valueName, value) + if err != nil { + return nil, err + } + proposalID, hash, err = security.ProposePerformanceExitsEnabled(rp, newValue, opts) + if err != nil { + return nil, fmt.Errorf("error proposing PerformanceExitsEnabled: %w", err) + } + } } // Make sure a setting was actually hit diff --git a/rocketpool/node/check-megapool-exit-requests.go b/rocketpool/node/check-megapool-exit-requests.go new file mode 100644 index 000000000..682df2ef5 --- /dev/null +++ b/rocketpool/node/check-megapool-exit-requests.go @@ -0,0 +1,400 @@ +package node + +import ( + "fmt" + "math/big" + "strconv" + "time" + + "github.com/docker/docker/client" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/urfave/cli/v3" + eth2types "github.com/wealdtech/go-eth2-types/v2" + + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/network" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/bindings/transactions" + rpvalidator "github.com/rocket-pool/smartnode/rocketpool/validator" + + log "github.com/rocket-pool/smartnode/shared/logger" + "github.com/rocket-pool/smartnode/shared/math" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/config" + rpgas "github.com/rocket-pool/smartnode/shared/services/gas" + "github.com/rocket-pool/smartnode/shared/services/state" + "github.com/rocket-pool/smartnode/shared/services/wallet" +) + +// Check megapool exit requests task +type checkMegapoolExitRequests struct { + c *cli.Command + log log.ColorLogger + cfg *config.RocketPoolConfig + w wallet.Wallet + rp *rocketpool.RocketPool + bc beacon.Client + d *client.Client + gasThreshold float64 + maxFee *big.Int + maxPriorityFee *big.Int + gasLimit uint64 + intervalSize *big.Int +} + +// Create check megapool exit requests task +func newCheckMegapoolExitRequests(c *cli.Command, logger log.ColorLogger) (*checkMegapoolExitRequests, error) { + + // Get services + cfg, err := services.GetConfig(c) + if err != nil { + return nil, err + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + d, err := services.GetDocker(c) + if err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + gasThreshold := cfg.Smartnode.AutoTxGasThreshold.Value.(float64) + + // Get the user-requested max fee + maxFeeGwei := cfg.Smartnode.ManualMaxFee.Value.(float64) + var maxFee *big.Int + if maxFeeGwei == 0 { + maxFee = nil + } else { + maxFee = math.GweiToWei(maxFeeGwei) + } + + // Get the user-requested priority fee + priorityFeeGwei := cfg.Smartnode.PriorityFee.Value.(float64) + var priorityFee *big.Int + if priorityFeeGwei == 0 { + logger.Printlnf("WARNING: priority fee was missing or 0, setting a default of %.2f.", rpgas.DefaultPriorityFeeGwei) + priorityFee = math.GweiToWei(rpgas.DefaultPriorityFeeGwei) + } else { + priorityFee = math.GweiToWei(priorityFeeGwei) + } + + // Get the event log interval + eventLogInterval, err := cfg.GetEventLogInterval() + if err != nil { + return nil, err + } + + // Return task + return &checkMegapoolExitRequests{ + c: c, + log: logger, + cfg: cfg, + w: w, + rp: rp, + bc: bc, + d: d, + gasThreshold: gasThreshold, + maxFee: maxFee, + maxPriorityFee: priorityFee, + gasLimit: 0, + intervalSize: big.NewInt(int64(eventLogInterval)), + }, nil + +} + +// Check for megapool validators that did not respond to an exit request +func (t *checkMegapoolExitRequests) run(state *state.NetworkStateIndex) error { + // Check if Saturn 2 is deployed + if !state.Saturn2Deployed { + t.log.Println("Saturn 2 is not deployed, skipping megapool exit requests check.") + return nil + } + + // Log + t.log.Println("Checking for megapool validators that did not respond to an exit request...") + + // Get the latest state + opts := &bind.CallOpts{ + BlockNumber: big.NewInt(0).SetUint64(state.ElBlockNumber), + } + + // Get node account + nodeAccount, err := t.w.GetNodeAccount() + if err != nil { + return err + } + + // Get the cooperative exit phase duration + cooperativeExitPhase, err := protocol.GetCooperativeExitPhase(t.rp, opts) + if err != nil { + return err + } + + // Search MegapoolExitRequested events over a window covering the cooperative exit phase + lookbackBlocks := uint64(cooperativeExitPhase / (time.Duration(state.BeaconConfig.SecondsPerSlot) * time.Second)) + if lookbackBlocks == 0 { + lookbackBlocks = 1 + } + var fromBlock *big.Int + if state.ElBlockNumber > lookbackBlocks { + fromBlock = big.NewInt(int64(state.ElBlockNumber - lookbackBlocks)) + } else { + fromBlock = big.NewInt(0) + } + toBlock := big.NewInt(int64(state.ElBlockNumber)) + + exitRequests, err := network.GetMegapoolExitRequests(t.rp, t.intervalSize, fromBlock, toBlock, opts) + if err != nil { + return err + } + if len(exitRequests) == 0 { + return nil + } + + // Get this node's megapool address, if one is deployed + ownMegapoolDeployed := false + nodeDetails, exists := state.NodeDetailsByAddress[nodeAccount.Address] + if exists { + ownMegapoolDeployed = nodeDetails.MegapoolDeployed + } + + for _, request := range exitRequests { + + status, err := t.bc.GetValidatorStatus(request.Pubkey, nil) + if err != nil { + t.log.Printlnf("Error getting the status of validator %s: %s", request.Pubkey.Hex(), err.Error()) + continue + } + if !status.Exists { + t.log.Printlnf("Validator %s not found on the beacon chain", request.Pubkey.Hex()) + continue + } + + validatorIndex, err := strconv.ParseUint(status.Index, 10, 64) + if err != nil { + t.log.Printlnf("Error parsing validator index %s: %s", status.Index, err.Error()) + continue + } + + // An initiated exit satisfies the request + if status.ExitEpoch != FarFutureEpoch { + t.log.Printlnf("Validator %d has already exited", validatorIndex) + continue + } + + // If the megapool belongs to this node, cooperate signing and submitting a voluntary exit + if ownMegapoolDeployed && request.MegapoolAddress == nodeDetails.MegapoolAddress { + t.log.Printlnf("Megapool %s (validator %d) belongs to this node; submitting a voluntary exit", request.MegapoolAddress.Hex(), validatorIndex) + err := t.exitOwnMegapoolValidator(state, request, status) + if err != nil { + t.log.Printlnf("Error exiting megapool %s validator %d: %s", request.MegapoolAddress.Hex(), request.ValidatorId, err.Error()) + } + continue + } + + mp, err := megapool.NewMegapool(t.rp, request.MegapoolAddress, opts) + if err != nil { + t.log.Printlnf("Error creating a binding for megapool %s: %s", request.MegapoolAddress.Hex(), err.Error()) + continue + } + + // Skip requests still within the cooperative exit phase + deadline := time.Unix(int64(request.RequestTimestamp), 0).Add(cooperativeExitPhase) + if time.Now().Before(deadline) { + continue + } + + // Megapools from version 2 support a forced exit request, so the + // did-not-exit penalty only applies to version 1 + if mpv2, ok := mp.(megapool.MegapoolV2); ok { + t.log.Printlnf("Megapool %s (validator %d) uses version %d; submitting ForceExit", request.MegapoolAddress.Hex(), validatorIndex, mp.GetVersion()) + err := t.forceExitMegapoolValidator(mpv2, request) + if err != nil { + t.log.Printlnf("Error force-exiting megapool %s validator %d: %s", request.MegapoolAddress.Hex(), request.ValidatorId, err.Error()) + } + continue + } + + // Log + t.log.Printlnf("The validator %d (megapool %s) did not exit within the cooperative exit phase", validatorIndex, request.MegapoolAddress.Hex()) + + err = t.penaliseMegapoolValidator(request) + // dont return if there was an error, just log it so we can continue with the next validator + if err != nil { + t.log.Printlnf("Error penalising megapool %s validator %d: %s", request.MegapoolAddress.Hex(), request.ValidatorId, err.Error()) + } + } + + // Return + return nil + +} + +// Sign and broadcast the voluntary exit for a megapool validator belonging to this node +func (t *checkMegapoolExitRequests) exitOwnMegapoolValidator(state *state.NetworkStateIndex, request network.MegapoolExitRequest, status beacon.ValidatorStatus) error { + + // Check the validator status on the megapool + validatorInfo, exists := state.GetMegapoolValidatorInfo(request.MegapoolAddress, request.Pubkey) + if !exists { + return fmt.Errorf("validator %s not found in the megapool validator info map", request.Pubkey.Hex()) + } + if !validatorInfo.ValidatorInfo.Staked { + return fmt.Errorf("megapool %s validator %d is not staked", request.MegapoolAddress.Hex(), request.ValidatorId) + } + + // Get the validator private key + validatorKey, err := t.w.GetValidatorKeyByPubkey(request.Pubkey) + if err != nil { + return err + } + + // Get beacon head + head, err := t.bc.GetBeaconHead() + if err != nil { + return err + } + + // Get voluntary exit signature domain + signatureDomain, err := t.bc.GetDomainData(eth2types.DomainVoluntaryExit[:], head.Epoch, false) + if err != nil { + return err + } + + // Get signed voluntary exit message + signature, err := rpvalidator.GetSignedExitMessage(validatorKey, status.Index, head.Epoch, signatureDomain) + if err != nil { + return err + } + + // Broadcast voluntary exit message + if err := t.bc.ExitValidator(status.Index, head.Epoch, signature); err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully submitted a voluntary exit for validator %s (megapool %s).", status.Index, request.MegapoolAddress.Hex()) + + // Return + return nil +} + +func (t *checkMegapoolExitRequests) forceExitMegapoolValidator(mp megapool.MegapoolV2, request network.MegapoolExitRequest) error { + + // Get transactor + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + + // Get the gas limit + gasInfo, err := network.EstimateForceMegapoolExitGas(t.rp, request.MegapoolAddress, request.ValidatorId, opts) + if err != nil { + return fmt.Errorf("could not estimate the gas required to force exit megapool %s validator %d: %w", request.MegapoolAddress.Hex(), request.ValidatorId, err) + } + var gas *big.Int + if t.gasLimit != 0 { + gas = new(big.Int).SetUint64(t.gasLimit) + } else { + gas = new(big.Int).SetUint64(gasInfo.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() + + // Force exit the validator via the megapool contract + hash, err := network.ForceMegapoolExit(t.rp, request.MegapoolAddress, request.ValidatorId, opts) + if err != nil { + return err + } + + // Print TX info and wait for it to be included in a block + err = transactions.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) + if err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully submitted ForceExit for megapool %s validator %d.", request.MegapoolAddress.Hex(), request.ValidatorId) + + // Return + return nil +} + +func (t *checkMegapoolExitRequests) penaliseMegapoolValidator(request network.MegapoolExitRequest) error { + + // Get transactor + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + + // Get the gas limit + gasInfo, err := network.EstimatePenaliseMegapoolValidatorGas(t.rp, request.MegapoolAddress, request.ValidatorId, opts) + if err != nil { + return fmt.Errorf("could not estimate the gas required to penalise megapool %s validator %d: %w", request.MegapoolAddress.Hex(), request.ValidatorId, err) + } + gas := big.NewInt(int64(gasInfo.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() + + // Penalise the megapool validator for failing to exit within the cooperative phase + hash, err := network.PenaliseMegapoolValidator(t.rp, request.MegapoolAddress, request.ValidatorId, opts) + if err != nil { + return err + } + + // Print TX info and wait for it to be included in a block + err = transactions.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) + if err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully penalised megapool %s (validator %d) for not exiting.", request.MegapoolAddress.Hex(), request.ValidatorId) + + // Return + return nil +} diff --git a/rocketpool/node/check-minipool-exit-requests.go b/rocketpool/node/check-minipool-exit-requests.go new file mode 100644 index 000000000..a24534904 --- /dev/null +++ b/rocketpool/node/check-minipool-exit-requests.go @@ -0,0 +1,436 @@ +package node + +import ( + "fmt" + "math/big" + "strconv" + "time" + + "github.com/docker/docker/client" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v3" + eth2types "github.com/wealdtech/go-eth2-types/v2" + + "github.com/rocket-pool/smartnode/bindings/network" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/bindings/transactions" + "github.com/rocket-pool/smartnode/bindings/types" + rpstate "github.com/rocket-pool/smartnode/bindings/utils/state" + rpvalidator "github.com/rocket-pool/smartnode/rocketpool/validator" + + log "github.com/rocket-pool/smartnode/shared/logger" + "github.com/rocket-pool/smartnode/shared/math" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/config" + rpgas "github.com/rocket-pool/smartnode/shared/services/gas" + "github.com/rocket-pool/smartnode/shared/services/state" + "github.com/rocket-pool/smartnode/shared/services/wallet" + "github.com/rocket-pool/smartnode/shared/types/eth2" +) + +// A minipool validator that did not exit within the cooperative exit phase +type didNotExitValidator struct { + validatorIndex uint64 + pubkey types.ValidatorPubkey + minipoolAddress common.Address +} + +// Check minipool exit requests task +type checkMinipoolExitRequests struct { + c *cli.Command + log log.ColorLogger + cfg *config.RocketPoolConfig + w wallet.Wallet + rp *rocketpool.RocketPool + bc beacon.Client + d *client.Client + gasThreshold float64 + maxFee *big.Int + maxPriorityFee *big.Int + gasLimit uint64 + intervalSize *big.Int +} + +// Create check minipool exit requests task +func newCheckMinipoolExitRequests(c *cli.Command, logger log.ColorLogger) (*checkMinipoolExitRequests, error) { + + // Get services + cfg, err := services.GetConfig(c) + if err != nil { + return nil, err + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + d, err := services.GetDocker(c) + if err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + gasThreshold := cfg.Smartnode.AutoTxGasThreshold.Value.(float64) + + // Get the user-requested max fee + maxFeeGwei := cfg.Smartnode.ManualMaxFee.Value.(float64) + var maxFee *big.Int + if maxFeeGwei == 0 { + maxFee = nil + } else { + maxFee = math.GweiToWei(maxFeeGwei) + } + + // Get the user-requested priority fee + priorityFeeGwei := cfg.Smartnode.PriorityFee.Value.(float64) + var priorityFee *big.Int + if priorityFeeGwei == 0 { + logger.Printlnf("WARNING: priority fee was missing or 0, setting a default of %.2f.", rpgas.DefaultPriorityFeeGwei) + priorityFee = math.GweiToWei(rpgas.DefaultPriorityFeeGwei) + } else { + priorityFee = math.GweiToWei(priorityFeeGwei) + } + + // Get the event log interval + eventLogInterval, err := cfg.GetEventLogInterval() + if err != nil { + return nil, err + } + + // Return task + return &checkMinipoolExitRequests{ + c: c, + log: logger, + cfg: cfg, + w: w, + rp: rp, + bc: bc, + d: d, + gasThreshold: gasThreshold, + maxFee: maxFee, + maxPriorityFee: priorityFee, + gasLimit: 0, + intervalSize: big.NewInt(int64(eventLogInterval)), + }, nil + +} + +// Check for minipool validators that did not respond to an exit request +func (t *checkMinipoolExitRequests) run(state *state.NetworkStateIndex) error { + // Check if Saturn 2 is deployed + if !state.Saturn2Deployed { + t.log.Println("Saturn 2 is not deployed, skipping minipool exit requests check.") + return nil + } + + // Log + t.log.Println("Checking for minipool validators that did not respond to an exit request...") + + // Get the latest state + opts := &bind.CallOpts{ + BlockNumber: big.NewInt(0).SetUint64(state.ElBlockNumber), + } + + // Get node account + nodeAccount, err := t.w.GetNodeAccount() + if err != nil { + return err + } + + // Get the cooperative exit phase duration + cooperativeExitPhase, err := protocol.GetCooperativeExitPhase(t.rp, opts) + if err != nil { + return err + } + + // Search MinipoolExitRequested events over a window covering the cooperative exit phase + lookbackBlocks := uint64(cooperativeExitPhase / (time.Duration(state.BeaconConfig.SecondsPerSlot) * time.Second)) + if lookbackBlocks == 0 { + lookbackBlocks = 1 + } + var fromBlock *big.Int + if state.ElBlockNumber > lookbackBlocks { + fromBlock = big.NewInt(int64(state.ElBlockNumber - lookbackBlocks)) + } else { + fromBlock = big.NewInt(0) + } + toBlock := big.NewInt(int64(state.ElBlockNumber)) + + exitRequests, err := network.GetMinipoolExitRequests(t.rp, t.intervalSize, fromBlock, toBlock, opts) + if err != nil { + return err + } + if len(exitRequests) == 0 { + return nil + } + + // Index the minipool details by validator pubkey + minipoolDetailsByPubkey := make(map[types.ValidatorPubkey]*rpstate.NativeMinipoolDetails, len(state.MinipoolDetails)) + for i := range state.MinipoolDetails { + minipoolDetailsByPubkey[state.MinipoolDetails[i].Pubkey] = &state.MinipoolDetails[i] + } + + validatorsToProve := []didNotExitValidator{} + for _, request := range exitRequests { + + status, err := t.bc.GetValidatorStatus(request.Pubkey, nil) + if err != nil { + t.log.Printlnf("Error getting the status of validator %s: %s", request.Pubkey.Hex(), err.Error()) + continue + } + if !status.Exists { + t.log.Printlnf("Validator %s not found on the beacon chain", request.Pubkey.Hex()) + continue + } + + validatorIndex, err := strconv.ParseUint(status.Index, 10, 64) + if err != nil { + t.log.Printlnf("Error parsing validator index %s: %s", status.Index, err.Error()) + continue + } + + // An initiated exit satisfies the request + if status.ExitEpoch != FarFutureEpoch { + t.log.Printlnf("Validator %d has already exited", validatorIndex) + continue + } + + minipoolDetails, exists := minipoolDetailsByPubkey[request.Pubkey] + if !exists { + t.log.Printlnf("Validator %d does not belong to a known minipool", validatorIndex) + continue + } + + // If the minipool belongs to this node, cooperate signing and submitting a voluntary exit + if minipoolDetails.NodeAddress == nodeAccount.Address { + t.log.Printlnf("Minipool %s (validator %d) belongs to this node; submitting a voluntary exit", minipoolDetails.MinipoolAddress.Hex(), validatorIndex) + err := t.exitOwnMinipool(minipoolDetails, status) + if err != nil { + t.log.Printlnf("Error exiting minipool %s: %s", minipoolDetails.MinipoolAddress.Hex(), err.Error()) + } + continue + } + + // Skip requests within the cooperative exit phase + deadline := time.Unix(int64(request.RequestTimestamp), 0).Add(cooperativeExitPhase) + if time.Now().Before(deadline) { + continue + } + + // Delegates from version 4 support a forced exit request, so the + // did-not-exit path only applies to older delegates + if minipoolDetails.Version >= 4 { + t.log.Printlnf("Minipool %s (validator %d) uses delegate version %d; submitting ForceExit", minipoolDetails.MinipoolAddress.Hex(), validatorIndex, minipoolDetails.Version) + err := t.forceExitMinipool(minipoolDetails) + if err != nil { + t.log.Printlnf("Error force-exiting minipool %s: %s", minipoolDetails.MinipoolAddress.Hex(), err.Error()) + } + continue + } + + validatorsToProve = append(validatorsToProve, didNotExitValidator{ + validatorIndex: validatorIndex, + pubkey: request.Pubkey, + minipoolAddress: request.MinipoolAddress, + }) + } + + // Check if there are any validators to prove + if len(validatorsToProve) == 0 { + return nil + } + + beaconState, err := services.GetBeaconState(t.bc) + if err != nil { + return err + } + + for _, validator := range validatorsToProve { + + // Log + t.log.Printlnf("The validator %d (minipool %s) did not exit within the cooperative exit phase", validator.validatorIndex, validator.minipoolAddress.Hex()) + + err := t.proveDidNotExit(beaconState, state, validator) + // dont return if there was an error, just log it so we can continue with the next validator + if err != nil { + t.log.Printlnf("Error proving validator %d did not exit: %s", validator.validatorIndex, err.Error()) + } + } + + // Return + return nil + +} + +// Sign and broadcast the voluntary exit for a minipool validator belonging to this node +func (t *checkMinipoolExitRequests) exitOwnMinipool(mpd *rpstate.NativeMinipoolDetails, status beacon.ValidatorStatus) error { + + // Check the minipool status + if mpd.Status != types.Staking { + return fmt.Errorf("minipool %s is not in staking status", mpd.MinipoolAddress.Hex()) + } + + // Get the validator private key + validatorKey, err := t.w.GetValidatorKeyByPubkey(mpd.Pubkey) + if err != nil { + return err + } + + // Get beacon head + head, err := t.bc.GetBeaconHead() + if err != nil { + return err + } + + // Get voluntary exit signature domain + signatureDomain, err := t.bc.GetDomainData(eth2types.DomainVoluntaryExit[:], head.Epoch, false) + if err != nil { + return err + } + + // Get signed voluntary exit message + signature, err := rpvalidator.GetSignedExitMessage(validatorKey, status.Index, head.Epoch, signatureDomain) + if err != nil { + return err + } + + // Broadcast voluntary exit message + if err := t.bc.ExitValidator(status.Index, head.Epoch, signature); err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully submitted a voluntary exit for validator %s (minipool %s).", status.Index, mpd.MinipoolAddress.Hex()) + + // Return + return nil +} + +func (t *checkMinipoolExitRequests) forceExitMinipool(mpd *rpstate.NativeMinipoolDetails) error { + + // Get transactor + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + + // Get the gas limit + gasInfo, err := network.EstimateForceMinipoolExitGas(t.rp, mpd.MinipoolAddress, opts) + if err != nil { + return fmt.Errorf("could not estimate the gas required to force exit minipool %s: %w", mpd.MinipoolAddress.Hex(), err) + } + var gas *big.Int + if t.gasLimit != 0 { + gas = new(big.Int).SetUint64(t.gasLimit) + } else { + gas = new(big.Int).SetUint64(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() + + // Force exit the minipool via rocketNetworkExit + hash, err := network.ForceMinipoolExit(t.rp, mpd.MinipoolAddress, opts) + if err != nil { + return err + } + + // Print TX info and wait for it to be included in a block + err = transactions.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) + if err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully submitted ForceMinipoolExit for minipool %s.", mpd.MinipoolAddress.Hex()) + + // Return + return nil +} + +func (t *checkMinipoolExitRequests) proveDidNotExit(beaconState eth2.BeaconState, state *state.NetworkStateIndex, validator didNotExitValidator) error { + + t.log.Printlnf("[STARTED] Crafting a did-not-exit proof. This process can take several seconds and is CPU and memory intensive. If you don't see a [FINISHED] log entry your system may not have enough resources to perform this operation.") + + // The megapool proof structs are generic SSZ proofs; the address parameter is unused by GetValidatorProof + validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(t.c, 0, t.w, state.BeaconConfig, common.Address{}, validator.pubkey, beaconState) + if err != nil { + t.log.Printlnf("[ERROR] There was an error during the proof creation process: %s", err.Error()) + return err + } + + t.log.Printlnf("[FINISHED] The did-not-exit proof for validator %d has been successfully created (exit epoch %d at slot %d).", validator.validatorIndex, validatorProof.Validator.ExitEpoch, slotProof.Slot) + + // Get transactor + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + + // Get the gas limit + gasInfo, err := network.EstimatePenaliseMinipoolGas(t.rp, validator.minipoolAddress, slotTimestamp, validatorProof, slotProof, opts) + if err != nil { + t.log.Printlnf("Could not estimate the gas required to penalise minipool %s: %s", validator.minipoolAddress.Hex(), err.Error()) + 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() + + // Penalise the minipool for failing to exit within the cooperative phase + hash, err := network.PenaliseMinipool(t.rp, validator.minipoolAddress, slotTimestamp, validatorProof, slotProof, opts) + if err != nil { + return err + } + + // Print TX info and wait for it to be included in a block + err = transactions.PrintAndWaitForTransaction(t.cfg, hash, t.rp.Client, &t.log) + if err != nil { + return err + } + + // Log + t.log.Printlnf("Successfully penalised minipool %s (validator %d) for not exiting.", validator.minipoolAddress.Hex(), validator.validatorIndex) + + // Return + return nil +} diff --git a/rocketpool/node/defend-challenge-performance.go b/rocketpool/node/defend-challenge-performance.go new file mode 100644 index 000000000..05979394a --- /dev/null +++ b/rocketpool/node/defend-challenge-performance.go @@ -0,0 +1,466 @@ +package node + +import ( + "fmt" + "math/big" + "strconv" + "time" + + "github.com/docker/docker/client" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v3" + + "github.com/rocket-pool/smartnode/bindings/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" + + log "github.com/rocket-pool/smartnode/shared/logger" + "github.com/rocket-pool/smartnode/shared/math" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/config" + rpgas "github.com/rocket-pool/smartnode/shared/services/gas" + "github.com/rocket-pool/smartnode/shared/services/performance" + "github.com/rocket-pool/smartnode/shared/services/state" + "github.com/rocket-pool/smartnode/shared/services/wallet" +) + +// Stake megapool validator task +type defendChallengePerformance struct { + c *cli.Command + log log.ColorLogger + cfg *config.RocketPoolConfig + w wallet.Wallet + rp *rocketpool.RocketPool + bc beacon.Client + d *client.Client + gasThreshold float64 + maxFee *big.Int + maxPriorityFee *big.Int + gasLimit uint64 +} + +type megapoolPerformanceChallenge struct { + challengeId uint64 + megapoolAddress common.Address + validatorId 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 +} + +// participationCallData word (a Solidity uint256). +const bitsPerParticipationWord = 256 + +func (c *megapoolPerformanceChallenge) getChallengedEpochs() []uint64 { + // The challenged epochs are represented as 1s in the bitmaps in the + // participationCallData. The words are concatenated into a single bit + // stream starting at startEpoch + challengedEpochs := []uint64{} + for wordIndex, participationCallData := range c.participationCallData { + wordOffset := uint64(wordIndex) * bitsPerParticipationWord + for i := 0; i < participationCallData.BitLen(); i++ { + if participationCallData.Bit(i) == 1 { + challengedEpochs = append(challengedEpochs, c.startEpoch+wordOffset+uint64(i)) + } + } + } + return challengedEpochs +} + +// Create stake megapool validator task +func newDefendChallengePerformance(c *cli.Command, logger log.ColorLogger) (*defendChallengePerformance, error) { + + // Get services + cfg, err := services.GetConfig(c) + if err != nil { + return nil, err + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + d, err := services.GetDocker(c) + if err != nil { + return nil, err + } + bc, err := services.GetBeaconClient(c) + if err != nil { + return nil, err + } + + gasThreshold := cfg.Smartnode.AutoTxGasThreshold.Value.(float64) + + // Get the user-requested max fee + maxFeeGwei := cfg.Smartnode.ManualMaxFee.Value.(float64) + var maxFee *big.Int + if maxFeeGwei == 0 { + maxFee = nil + } else { + maxFee = math.GweiToWei(maxFeeGwei) + } + + // Get the user-requested max fee + priorityFeeGwei := cfg.Smartnode.PriorityFee.Value.(float64) + var priorityFee *big.Int + if priorityFeeGwei == 0 { + logger.Printlnf("WARNING: priority fee was missing or 0, setting a default of %.2f.", rpgas.DefaultPriorityFeeGwei) + priorityFee = math.GweiToWei(rpgas.DefaultPriorityFeeGwei) + } else { + priorityFee = math.GweiToWei(priorityFeeGwei) + } + + // Return task + return &defendChallengePerformance{ + c: c, + log: logger, + cfg: cfg, + w: w, + rp: rp, + bc: bc, + d: d, + gasThreshold: gasThreshold, + maxFee: maxFee, + maxPriorityFee: priorityFee, + gasLimit: 0, + }, nil + +} + +// Check for performance challenges +func (t *defendChallengePerformance) run(state *state.NetworkStateIndex) error { + // Check if Saturn 2 is deployed + if !state.Saturn2Deployed { + return nil + } + + // Log + t.log.Println("Checking for performance challenges ...") + + // Get the latest state + opts := &bind.CallOpts{ + BlockNumber: big.NewInt(0).SetUint64(state.ElBlockNumber), + } + + // Get node account + nodeAccount, err := t.w.GetNodeAccount() + if err != nil { + return err + } + + // Check if the megapool is deployed + deployed, err := megapool.GetMegapoolDeployed(t.rp, nodeAccount.Address, opts) + if err != nil { + return err + } + if !deployed { + return nil + } + + // Get the megapool address + megapoolAddress, err := megapool.GetMegapoolExpectedAddress(t.rp, nodeAccount.Address, opts) + if err != nil { + return err + } + + // Load the megapool + mp, err := megapool.NewMegaPoolV1(t.rp, megapoolAddress, nil) + if err != nil { + return err + } + + // Get the performance challenge period + performanceChallengePeriod, err := protocol.GetPerformanceChallengePeriod(t.rp, opts) + if err != nil { + return err + } + + // Get the performance measurement period (in epochs) + performancePeriod, err := protocol.GetPerformancePeriod(t.rp, opts) + if err != nil { + return err + } + + // TODO: Fetch megapool challenges + + participationCallData := []*big.Int{new(big.Int).Sub( + 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(), + }, + } + + for _, challenge := range challenges { + + // Old challenges can be finalised + if time.Since(challenge.challengeTimestamp) > performanceChallengePeriod { + t.log.Printlnf("Challenge %d has been open for longer than the performance challenge period; finalising it.", challenge.challengeId) + if err := t.finaliseChallenge(challenge); err != nil { + t.log.Printlnf("error finalising performance challenge %d: %v", challenge.challengeId, err) + } + continue + } + + 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 + } + beaconStatus, err := t.bc.GetValidatorStatus(pubkey, nil) + if err != nil { + t.log.Printlnf("error getting beacon status for megapool validator %d (%s): %v", challenge.validatorId, pubkey.Hex(), err) + continue + } + if !beaconStatus.Exists || beaconStatus.Index == "" { + t.log.Printlnf("Megapool validator %d (%s) is not on the beacon chain yet, skipping.", challenge.validatorId, pubkey.Hex()) + continue + } + validatorIndex, err := strconv.ParseUint(beaconStatus.Index, 10, 64) + if err != nil { + t.log.Printlnf("error parsing beacon index %q for megapool validator %d: %v", beaconStatus.Index, challenge.validatorId, err) + continue + } + defender := challengedValidator{ + validatorId: challenge.validatorId, + pubkey: pubkey, + index: validatorIndex, + } + + // Find an epoch in the challenged range where the validator made a + // timely target vote. A single (validator, epoch) proof is enough to + // defend the challenge + _, epoch, found, err := performance.FindFirstTimelyTargetVote(t.bc, state.BeaconConfig, []uint64{validatorIndex}, challengedEpochs) + if err != nil { + return fmt.Errorf("error verifying target-vote participation for challenged megapool 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) + } + 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 + } + + 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 + } + + // 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 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 + opts, err := t.w.GetNodeAccountTransactor() + if err != nil { + return err + } + + t.log.Printlnf("Creating a validator proof for megapool validator %d.", defender.validatorId) + + // Build a fresh validator proof against the head state to satisfy the + // contract's slot recency requirement + validatorProof, slotTimestamp, slotProof, err := services.GetValidatorProof(t.c, 0, t.w, state.BeaconConfig, challenge.megapoolAddress, defender.pubkey, nil) + if err != nil { + return fmt.Errorf("error creating the validator proof: %w", err) + } + + gasInfo, err := megapool.EstimateRespondWithValidatorGas(t.rp, challenge.challengeId, slotTimestamp, validatorProof, slotProof, opts) + if err != nil { + return err + } + + gas := big.NewInt(int64(gasInfo.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 +} + +// 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 + 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) + } + + gasInfo, err := megapool.EstimateRespondWithParticipationGas(rp, challenge.challengeId, proofs.Offset, proofs.ChallengeLeaf, proofs.ChallengeWitness, proofs.SlotTimestamp, proofs.Validator, proofs.Participation, proofs.Slot, opts) + if err != nil { + return err + } + + 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 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) + 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 +} diff --git a/rocketpool/node/defend-challenge-performance_test.go b/rocketpool/node/defend-challenge-performance_test.go new file mode 100644 index 000000000..1cf88bea3 --- /dev/null +++ b/rocketpool/node/defend-challenge-performance_test.go @@ -0,0 +1,141 @@ +package node + +import ( + "math/big" + "reflect" + "testing" + + "github.com/rocket-pool/smartnode/shared/services/performance" +) + +func TestGetChallengedEpochs(t *testing.T) { + tests := []struct { + name string + startEpoch uint64 + words []*big.Int + want []uint64 + }{ + { + name: "no words", + startEpoch: 100, + words: []*big.Int{}, + want: []uint64{}, + }, + { + name: "single word, no bits set", + startEpoch: 100, + words: []*big.Int{big.NewInt(0)}, + want: []uint64{}, + }, + { + name: "single word, single bit set", + startEpoch: 100, + words: []*big.Int{big.NewInt(1)}, + want: []uint64{100}, + }, + { + name: "single word, scattered bits", + startEpoch: 100, + // bits 0, 3, and 5 set -> 1 + 8 + 32 = 41 + words: []*big.Int{big.NewInt(41)}, + want: []uint64{100, 103, 105}, + }, + { + name: "single word, non-zero start offset within word", + startEpoch: 50, + words: []*big.Int{new(big.Int).Lsh(big.NewInt(1), 10)}, // bit 10 set + want: []uint64{60}, + }, + { + name: "multiple words, bits in each", + startEpoch: 105000, + words: []*big.Int{ + big.NewInt(1), // bit 0 of word 0 -> epoch 105000 + new(big.Int).Lsh(big.NewInt(1), 5), // bit 5 of word 1 -> epoch 105000 + 256 + 5 + new(big.Int).Lsh(big.NewInt(1), 255), // bit 255 of word 2 -> epoch 105000 + 512 + 255 + }, + want: []uint64{105000, 105261, 105767}, + }, + { + name: "all bits set in a word", + startEpoch: 0, + words: []*big.Int{ + new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 200), big.NewInt(1)), // bits 0..199 set + }, + want: allEpochsInRange(0, 200), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + challenge := megapoolPerformanceChallenge{ + startEpoch: tc.startEpoch, + participationCallData: tc.words, + } + got := challenge.getChallengedEpochs() + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("getChallengedEpochs() = %v, want %v", got, tc.want) + } + }) + } +} + +// TestParticipationBitsetRoundTrip pins the encoder in the performance +// package and the decoder here to the same bit-layout convention: encoding a +// missed-epoch list and decoding it back must return the original list. +func TestParticipationBitsetRoundTrip(t *testing.T) { + tests := []struct { + name string + startEpoch uint64 + endEpoch uint64 + missedEpochs []uint64 + }{ + { + name: "no missed epochs", + startEpoch: 105000, + endEpoch: 105500, + missedEpochs: []uint64{}, + }, + { + name: "scattered epochs across word boundaries", + startEpoch: 105000, + endEpoch: 106000, + missedEpochs: []uint64{105000, 105255, 105256, 105511, 105767, 106000}, + }, + { + name: "every epoch in a small range", + startEpoch: 200, + endEpoch: 209, + missedEpochs: allEpochsInRange(200, 10), + }, + { + name: "every epoch in a large range", + startEpoch: 1, + endEpoch: 44032, + missedEpochs: allEpochsInRange(1, 44032), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + challenge := megapoolPerformanceChallenge{ + startEpoch: tc.startEpoch, + participationCallData: performance.EncodeParticipationBitset(tc.startEpoch, tc.endEpoch, tc.missedEpochs), + } + got := challenge.getChallengedEpochs() + if !reflect.DeepEqual(got, tc.missedEpochs) { + t.Errorf("round trip = %v, want %v", got, tc.missedEpochs) + } + }) + } +} + +// allEpochsInRange returns [start, start+count) as a slice, matching the +// order getChallengedEpochs produces for a fully-set bitmap word. +func allEpochsInRange(start uint64, count int) []uint64 { + epochs := make([]uint64, count) + for i := 0; i < count; i++ { + epochs[i] = start + uint64(i) + } + return epochs +} diff --git a/rocketpool/node/node.go b/rocketpool/node/node.go index 2dcda9358..59197a8c3 100644 --- a/rocketpool/node/node.go +++ b/rocketpool/node/node.go @@ -39,24 +39,27 @@ var ( const ( MaxConcurrentEth1Requests = 200 - DownloadRewardsTreesColor = color.FgGreen - MetricsColor = color.FgHiYellow - ManageFeeRecipientColor = color.FgHiCyan - DefendPdaoPropsColor = color.FgYellow - VerifyPdaoPropsColor = color.FgYellow - DistributeMinipoolsColor = color.FgHiGreen - ErrorColor = color.FgRed - WarningColor = color.FgYellow - ObserveWarningColor = color.FgHiRed - UpdateColor = color.FgHiWhite - PrestakeMegapoolValidatorColor = color.FgHiGreen - StakeMegapoolValidatorColor = color.FgHiBlue - NotifyValidatorExitColor = color.FgHiYellow - NotifyFinalBalanceColor = color.FgHiMagenta - DefendChallengeExitColor = color.FgHiGreen - ProvisionExpressTickets = color.FgMagenta - SetUseLatestDelegateColor = color.FgBlue - CheckPortConnectivityColor = color.FgHiYellow + DownloadRewardsTreesColor = color.FgGreen + MetricsColor = color.FgHiYellow + ManageFeeRecipientColor = color.FgHiCyan + DefendPdaoPropsColor = color.FgYellow + VerifyPdaoPropsColor = color.FgYellow + DistributeMinipoolsColor = color.FgHiGreen + ErrorColor = color.FgRed + WarningColor = color.FgYellow + ObserveWarningColor = color.FgHiRed + UpdateColor = color.FgHiWhite + PrestakeMegapoolValidatorColor = color.FgHiGreen + StakeMegapoolValidatorColor = color.FgHiBlue + NotifyValidatorExitColor = color.FgHiYellow + NotifyFinalBalanceColor = color.FgHiMagenta + CheckMinipoolExitRequestsColor = color.FgHiCyan + CheckMegapoolExitRequestsColor = color.FgCyan + DefendChallengeExitColor = color.FgHiGreen + DefendChallengePerformanceColor = color.FgHiBlue + ProvisionExpressTickets = color.FgMagenta + SetUseLatestDelegateColor = color.FgBlue + CheckPortConnectivityColor = color.FgHiYellow ) // Register node command @@ -205,6 +208,12 @@ func run(c *cli.Command) error { if err != nil { return err } + + defendChallengePerformance, err := newDefendChallengePerformance(c, log.NewColorLogger(DefendChallengePerformanceColor)) + if err != nil { + return err + } + distributeMinipools, err := newDistributeMinipools(c, log.NewColorLogger(DistributeMinipoolsColor)) if err != nil { return err @@ -225,6 +234,14 @@ func run(c *cli.Command) error { if err != nil { return err } + checkMinipoolExitRequests, err := newCheckMinipoolExitRequests(c, log.NewColorLogger(CheckMinipoolExitRequestsColor)) + if err != nil { + return err + } + checkMegapoolExitRequests, err := newCheckMegapoolExitRequests(c, log.NewColorLogger(CheckMegapoolExitRequestsColor)) + if err != nil { + return err + } downloadRewardsTrees, err := newDownloadRewardsTrees(c, log.NewColorLogger(DownloadRewardsTreesColor)) if err != nil { return err @@ -366,6 +383,11 @@ func run(c *cli.Command) error { errorLog.Println(err) } + // Run the defend challenge performance task + if err := defendChallengePerformance.run(state); err != nil { + errorLog.Println(err) + } + // Run the rewards download check if err := downloadRewardsTrees.run(state); err != nil { errorLog.Println(err) @@ -426,6 +448,22 @@ func run(c *cli.Command) error { return } + // Run the minipool exit request check + if err := checkMinipoolExitRequests.run(state); err != nil { + errorLog.Println(err) + } + if !sleepWithContext(ctx, taskCooldown) { + return + } + + // Run the megapool exit request check + if err := checkMegapoolExitRequests.run(state); err != nil { + errorLog.Println(err) + } + if !sleepWithContext(ctx, taskCooldown) { + return + } + // Run the megapool provision express ticket check if err := provisionExpressTickets.run(state); err != nil { errorLog.Println(err) diff --git a/rocketpool/watchtower/submit-network-balances_test.go b/rocketpool/watchtower/submit-network-balances_test.go index 59b837d64..d6c9bc335 100644 --- a/rocketpool/watchtower/submit-network-balances_test.go +++ b/rocketpool/watchtower/submit-network-balances_test.go @@ -530,6 +530,9 @@ func (s *stubBeaconClient) GetEth1DataForEth2Block(blockId string) (beacon.Eth1D func (s *stubBeaconClient) GetCommitteesForEpoch(epoch *uint64) (beacon.Committees, error) { return nil, nil } +func (s *stubBeaconClient) GetHistoricalCommitteesForEpoch(epoch uint64) (beacon.Committees, error) { + return nil, nil +} func (s *stubBeaconClient) ChangeWithdrawalCredentials(validatorIndex string, fromBlsPubkey rptypes.ValidatorPubkey, toExecutionAddress common.Address, signature rptypes.ValidatorSignature) error { return nil } diff --git a/shared/services/bc-manager.go b/shared/services/bc-manager.go index 8a25a89c6..bb34baa84 100644 --- a/shared/services/bc-manager.go +++ b/shared/services/bc-manager.go @@ -357,6 +357,16 @@ func (m *BeaconClientManager) GetCommitteesForEpoch(epoch *uint64) (beacon.Commi return result.(beacon.Committees), nil } +func (m *BeaconClientManager) GetHistoricalCommitteesForEpoch(epoch uint64) (beacon.Committees, error) { + result, err := m.runFunction1(func(client beacon.Client) (interface{}, error) { + return client.GetHistoricalCommitteesForEpoch(epoch) + }) + if err != nil { + return nil, err + } + return result.(beacon.Committees), nil +} + // Change the withdrawal credentials for a validator func (m *BeaconClientManager) ChangeWithdrawalCredentials(validatorIndex string, fromBlsPubkey types.ValidatorPubkey, toExecutionAddress common.Address, signature types.ValidatorSignature) error { err := m.runFunction0(func(client beacon.Client) error { diff --git a/shared/services/beacon/client.go b/shared/services/beacon/client.go index b80ec67ad..4e6b8a374 100644 --- a/shared/services/beacon/client.go +++ b/shared/services/beacon/client.go @@ -69,6 +69,9 @@ type BeaconBlock struct { type BeaconBlockHeader struct { Slot uint64 ProposerIndex string + // Root is the block root for this header. Used by RPIP-73 target-vote + // verification to resolve the canonical target root at an epoch boundary. + Root common.Hash } // Committees is an interface as an optimization- since committees responses @@ -102,6 +105,13 @@ type AttestationInfo struct { SlotIndex uint64 // Committees represented by AggregationBits Committees bitfield.Bitvector64 + // TargetEpoch is the epoch this attestation voted as its target. Populated + // by parsers that need it (e.g. RPIP-73 target-vote verification); other + // callers may leave it zero-valued. + TargetEpoch uint64 + // TargetRoot is the block root this attestation voted as its target. + // Populated alongside TargetEpoch. + TargetRoot common.Hash } func (a *AttestationInfo) CommitteeIndices() []int { @@ -196,6 +206,7 @@ type Client interface { Close() error GetEth1DataForEth2Block(blockId string) (Eth1Data, bool, error) GetCommitteesForEpoch(epoch *uint64) (Committees, error) + GetHistoricalCommitteesForEpoch(epoch uint64) (Committees, error) ChangeWithdrawalCredentials(validatorIndex string, fromBlsPubkey types.ValidatorPubkey, toExecutionAddress common.Address, signature types.ValidatorSignature) error GetBeaconStateSSZ(slot uint64) (*BeaconStateSSZ, error) diff --git a/shared/services/beacon/client/std-http-client.go b/shared/services/beacon/client/std-http-client.go index 60e67d5ad..f501d841a 100644 --- a/shared/services/beacon/client/std-http-client.go +++ b/shared/services/beacon/client/std-http-client.go @@ -636,6 +636,8 @@ func (c *StandardHttpClient) GetAttestations(blockId string) ([]beacon.Attestati for i, attestation := range attestations.Data { bitString := hexutil.RemovePrefix(attestation.AggregationBits) attestationInfo[i].SlotIndex = uint64(attestation.Data.Slot) + attestationInfo[i].TargetEpoch = uint64(attestation.Data.Target.Epoch) + attestationInfo[i].TargetRoot = common.BytesToHash(attestation.Data.Target.Root) attestationInfo[i].AggregationBits, err = hex.DecodeString(bitString) if err != nil { return nil, false, fmt.Errorf("Error decoding aggregation bits for attestation %d of block %s: %w", i, blockId, err) @@ -692,7 +694,9 @@ func (c *StandardHttpClient) GetBeaconBlock(blockId string) (beacon.BeaconBlock, for i, attestation := range block.Data.Message.Body.Attestations { bitString := hexutil.RemovePrefix(attestation.AggregationBits) info := beacon.AttestationInfo{ - SlotIndex: uint64(attestation.Data.Slot), + SlotIndex: uint64(attestation.Data.Slot), + TargetEpoch: uint64(attestation.Data.Target.Epoch), + TargetRoot: common.BytesToHash(attestation.Data.Target.Root), } info.AggregationBits, err = hex.DecodeString(bitString) if err != nil { @@ -746,6 +750,7 @@ func (c *StandardHttpClient) GetBeaconBlockHeader(blockId string) (beacon.Beacon beaconBlock := beacon.BeaconBlockHeader{ Slot: uint64(block.Data.Header.Message.Slot), ProposerIndex: block.Data.Header.Message.ProposerIndex, + Root: common.HexToHash(block.Data.Root), } return beaconBlock, true, nil } @@ -760,6 +765,38 @@ func (c *StandardHttpClient) GetCommitteesForEpoch(epoch *uint64) (beacon.Commit return &response, nil } +// GetHistoricalCommitteesForEpoch returns committees for a past epoch. The +// request uses the beacon state at the epoch's first slot so archival nodes +// return the correct shuffling. If that state is unavailable, head is tried +// as a fallback +func (c *StandardHttpClient) GetHistoricalCommitteesForEpoch(epoch uint64) (beacon.Committees, error) { + eth2Config, err := c.getEth2Config() + if err != nil { + return nil, err + } + + stateSlot := epoch * uint64(eth2Config.Data.SlotsPerEpoch) + response, err := c.getCommittees(strconv.FormatUint(stateSlot, 10), &epoch) + if err == nil && len(response.Data) > 0 { + return &response, nil + } + + // Some clients can resolve historical shuffling from head; others only + // serve epoch E from a state at or after epoch E. + headResponse, headErr := c.getCommittees("head", &epoch) + if headErr == nil && len(headResponse.Data) > 0 { + return &headResponse, nil + } + + if err != nil { + return nil, err + } + if headErr != nil { + return nil, headErr + } + return nil, fmt.Errorf("Could not get committees for epoch %d: no committee data returned (archival beacon node may be required)", epoch) +} + // Perform a withdrawal credentials change on a validator func (c *StandardHttpClient) ChangeWithdrawalCredentials(validatorIndex string, fromBlsPubkey types.ValidatorPubkey, toExecutionAddress common.Address, signature types.ValidatorSignature) error { return c.postWithdrawalCredentialsChange(BLSToExecutionChangeRequest{ diff --git a/shared/services/beacon/client/types.go b/shared/services/beacon/client/types.go index 799ac3415..cff7b0679 100644 --- a/shared/services/beacon/client/types.go +++ b/shared/services/beacon/client/types.go @@ -174,8 +174,12 @@ type CommitteesResponse struct { type Attestation struct { AggregationBits string `json:"aggregation_bits"` Data struct { - Slot uinteger `json:"slot"` - Index uinteger `json:"index"` + Slot uinteger `json:"slot"` + Index uinteger `json:"index"` + Target struct { + Epoch uinteger `json:"epoch"` + Root byteArray `json:"root"` + } `json:"target"` } `json:"data"` CommitteeBits string `json:"committee_bits"` } diff --git a/shared/services/megapools.go b/shared/services/megapools.go index b8e639b7a..47423f259 100644 --- a/shared/services/megapools.go +++ b/shared/services/megapools.go @@ -2,6 +2,7 @@ package services import ( "context" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -30,6 +31,7 @@ import ( "github.com/rocket-pool/smartnode/bindings/utils/multicall" rpstate "github.com/rocket-pool/smartnode/bindings/utils/state" "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/performance" "github.com/rocket-pool/smartnode/shared/services/wallet" "github.com/rocket-pool/smartnode/shared/types/api" cfgtypes "github.com/rocket-pool/smartnode/shared/types/config" @@ -128,6 +130,342 @@ func GetValidatorProof(c *cli.Command, slot uint64, wallet wallet.Wallet, eth2Co return proof, slotTimestamp, slotProof, err } +// PerformanceDefenseProofs bundles everything the RespondWithParticipation +// call of a megapool performance challenge needs. +type PerformanceDefenseProofs struct { + // Offset is the epoch offset of the disproven epoch from the challenge's + // start epoch (challengedEpoch = startEpoch + Offset) + Offset uint64 + // ChallengeLeaf is the word of the challenge participation bitmap that + // contains the Offset bit (LSB-first: bit Offset % 256 must be set, + // meaning the epoch was challenged as missed) + ChallengeLeaf *big.Int + // ChallengeWitness is the sha256 merkle branch proving ChallengeLeaf is + // part of the challenge bitmap root stored on-chain + ChallengeWitness []common.Hash + SlotTimestamp uint64 + // Validator ties the beacon validator index to the challenged validator's + // pubkey, anchored to the same slot as the participation proof + Validator megapool.ValidatorProof + // Participation carries the participation metadata + Participation megapool.ParticipationProof + Slot megapool.SlotProof +} + +// Number of epochs encoded per challenge participation bitmap word (a +// Solidity uint256) +const bitsPerParticipationWord = 256 + +// participationBitmapWitness builds the merkle branch proving that the word +// at leafIndex is part of the challenge participation bitmap tree. It mirrors +// RocketNetworkParticipation.hashTree/restoreMerkleRoot: the leaves are the +// raw uint256 bitmap words, zero-padded to the next power of two, hashed +// pairwise with sha256. +func participationBitmapWitness(participation []*big.Int, leafIndex uint64) ([]common.Hash, error) { + leafCount := uint64(len(participation)) + if leafCount == 0 { + return nil, fmt.Errorf("the participation bitmap is empty") + } + if leafIndex >= leafCount { + return nil, fmt.Errorf("leaf index %d out of bounds of the participation bitmap (%d words)", leafIndex, leafCount) + } + + width := uint64(1) + for width < leafCount { + width *= 2 + } + + level := make([][32]byte, width) + for i, word := range participation { + if word.Sign() < 0 || word.BitLen() > bitsPerParticipationWord { + return nil, fmt.Errorf("participation bitmap word %d is not a uint256", i) + } + word.FillBytes(level[i][:]) + } + + witness := []common.Hash{} + index := leafIndex + for len(level) > 1 { + witness = append(witness, common.Hash(level[index^1])) + next := make([][32]byte, len(level)/2) + for i := range next { + var pair [64]byte + copy(pair[:32], level[2*i][:]) + copy(pair[32:], level[2*i+1][:]) + next[i] = sha256.Sum256(pair[:]) + } + level = next + index /= 2 + } + return witness, nil +} + +// participationProofSlotRange returns the inclusive slot range of epoch E+1, +// which is where challenged epoch E's participation flags live in +// previous_epoch_participation and are final by the end of the epoch. +func participationProofSlotRange(challengedEpoch uint64, slotsPerEpoch uint64) (firstSlot uint64, lastSlot uint64) { + firstSlot = (challengedEpoch + 1) * slotsPerEpoch + lastSlot = (challengedEpoch+2)*slotsPerEpoch - 1 + return firstSlot, lastSlot +} + +// buildRecentParticipationWitnesses assembles the witness chain for a +// participation slot still covered by the anchor state's state_roots vector: +// [participation chunk -> participation state root] ++ +// [state_roots[n] -> anchor state root] ++ [anchor block header] +func buildRecentParticipationWitnesses(anchorState eth2.BeaconState, participationSlot uint64, chunkProof [][]byte) ([][]byte, error) { + stateRootProof, err := anchorState.StateRootProof(participationSlot) + if err != nil { + return nil, fmt.Errorf("error building the state root proof: %w", err) + } + blockHeaderProof, err := anchorState.BlockHeaderProof() + if err != nil { + return nil, fmt.Errorf("error building the anchor block header proof: %w", err) + } + witnesses := make([][]byte, 0, len(chunkProof)+len(stateRootProof)+len(blockHeaderProof)) + witnesses = append(witnesses, chunkProof...) + witnesses = append(witnesses, stateRootProof...) + witnesses = append(witnesses, blockHeaderProof...) + return witnesses, nil +} + +// buildHistoricalParticipationWitnesses assembles the witness chain for a +// participation slot older than the anchor state's state_roots vector, +// routing through historical_summaries[n].state_summary_root: the era +// boundary state's state_roots vector proves the participation state root +// within the summary, and the anchor state proves the summary itself (the +// historical summary proof includes the anchor block header cap): +// [participation chunk -> participation state root] ++ +// [state_roots[n] -> state_summary_root -> HistoricalSummary root] ++ +// [historical_summaries[n] -> anchor state root -> anchor block header] +func buildHistoricalParticipationWitnesses(anchorState eth2.BeaconState, eraState eth2.BeaconState, participationSlot uint64, capellaOffset uint64, chunkProof [][]byte) ([][]byte, error) { + summaryStateRootProof, err := eraState.HistoricalSummaryStateRootProof(int(participationSlot)) + if err != nil { + return nil, fmt.Errorf("error building the historical summary state root proof: %w", err) + } + historicalSummaryProof, err := anchorState.HistoricalSummaryProof(participationSlot, capellaOffset) + if err != nil { + return nil, fmt.Errorf("error building the historical summary proof: %w", err) + } + witnesses := make([][]byte, 0, len(chunkProof)+len(summaryStateRootProof)+len(historicalSummaryProof)) + witnesses = append(witnesses, chunkProof...) + witnesses = append(witnesses, summaryStateRootProof...) + witnesses = append(witnesses, historicalSummaryProof...) + return witnesses, nil +} + +// verifyParticipationStateLink sanity checks that linkState's state_roots +// vector commits to the participation state's hash tree root before anything +// is submitted on chain. The link and participation states are inspected +// independently (they can be different forks across a fork boundary); the +// check is skipped for forks it can't inspect. +func verifyParticipationStateLink(linkState eth2.BeaconState, participationState eth2.BeaconState, participationSlot uint64) error { + var stateRoots *[8192][32]byte + switch s := linkState.(type) { + case *fulu.BeaconState: + stateRoots = &s.StateRoots + case *gloas.BeaconState: + stateRoots = &s.StateRoots + default: + return nil + } + switch participationState.(type) { + case *fulu.BeaconState, *gloas.BeaconState: + default: + return nil + } + participationRoot, err := generic.SSZ.HashTreeRoot(participationState) + if err != nil { + return fmt.Errorf("error hashing the participation state: %w", err) + } + if stateRoots[participationSlot%generic.SlotsPerHistoricalRoot] != participationRoot { + return fmt.Errorf("the state at slot %d does not commit to the root of the participation state at slot %d", linkState.GetSlot(), participationSlot) + } + return nil +} + +// GetParticipationProof builds the proofs needed to respond to a performance +// challenge with a validator's timely target vote in challengedEpoch. The +// challenge is identified by its start epoch and participation bitmap +// (challengedEpoch must be marked as missed in the bitmap). The participation +// state is the post-state of the last block in epoch challengedEpoch+1: +// attestations only enter the state via blocks, so that state holds the final +// previous_epoch_participation flags for the challenged epoch. All proofs are +// anchored at a recent finalized state: the participation state's root is +// proven through the anchor's state_roots vector when it is at most 8192 +// slots old, or through historical_summaries otherwise. +func GetParticipationProof(c *cli.Command, validatorIndex uint64, validatorPubkey types.ValidatorPubkey, challengedEpoch uint64, startEpoch uint64, participation []*big.Int) (PerformanceDefenseProofs, error) { + // Locate the challenged epoch within the challenge participation bitmap + if challengedEpoch < startEpoch { + return PerformanceDefenseProofs{}, fmt.Errorf("challenged epoch %d is before the challenge start epoch %d", challengedEpoch, startEpoch) + } + offset := challengedEpoch - startEpoch + leafIndex := offset / bitsPerParticipationWord + if leafIndex >= uint64(len(participation)) { + return PerformanceDefenseProofs{}, fmt.Errorf("epoch %d (offset %d) is out of bounds of the challenge participation bitmap (%d words)", challengedEpoch, offset, len(participation)) + } + challengeLeaf := participation[leafIndex] + if challengeLeaf.Bit(int(offset%bitsPerParticipationWord)) != 1 { + return PerformanceDefenseProofs{}, fmt.Errorf("epoch %d (offset %d) was not challenged as missed in the participation bitmap", challengedEpoch, offset) + } + challengeWitness, err := participationBitmapWitness(participation, leafIndex) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error building the challenge bitmap witness: %w", err) + } + + bc, err := GetBeaconClient(c) + if err != nil { + return PerformanceDefenseProofs{}, err + } + eth2Config, err := bc.GetEth2Config() + if err != nil { + return PerformanceDefenseProofs{}, err + } + if eth2Config.SlotsPerEpoch == 0 { + return PerformanceDefenseProofs{}, fmt.Errorf("invalid beacon config: SlotsPerEpoch is 0") + } + + // Walk back from the last slot of epoch E+1 to the most recent slot with a + // block; its post-state holds the final participation flags for epoch E. + firstSlot, lastSlot := participationProofSlotRange(challengedEpoch, eth2Config.SlotsPerEpoch) + participationSlot := uint64(0) + participationSlotFound := false + for slot := lastSlot; slot >= firstSlot; slot-- { + _, exists, err := bc.GetBeaconBlockHeader(strconv.FormatUint(slot, 10)) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error getting beacon block header at slot %d: %w", slot, err) + } + if exists { + participationSlot = slot + participationSlotFound = true + break + } + } + if !participationSlotFound { + return PerformanceDefenseProofs{}, fmt.Errorf("no block found in epoch %d to prove the participation of epoch %d", challengedEpoch+1, challengedEpoch) + } + + stateResponse, err := bc.GetBeaconStateSSZ(participationSlot) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error getting beacon state at slot %d (an archive Beacon Node may be required for old epochs): %w", participationSlot, err) + } + participationState, err := eth2.NewBeaconState(stateResponse.Data, stateResponse.Size, stateResponse.Fork) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error parsing beacon state at slot %d: %w", participationSlot, err) + } + + epochParticipation := participationState.GetPreviousEpochParticipation() + if validatorIndex >= uint64(len(epochParticipation)) { + return PerformanceDefenseProofs{}, fmt.Errorf("validator index %d out of bounds of the previous epoch participation list (%d entries)", validatorIndex, len(epochParticipation)) + } + flags := epochParticipation[validatorIndex] + if flags&(1<= uint64(len(validators)) { + return PerformanceDefenseProofs{}, fmt.Errorf("validator index %d out of bounds of the validator set (%d entries)", validatorIndex, len(validators)) + } + validator := validators[validatorIndex] + var withdrawalCredentialsFixed [32]byte + copy(withdrawalCredentialsFixed[:], validator.WithdrawalCredentials) + + // Extend the participation chunk proof up to the anchor block root, via + // the anchor's state_roots vector when the participation slot is recent + // or via historical_summaries otherwise + var participationWitnesses [][]byte + if participationSlot+generic.SlotsPerHistoricalRoot >= anchorSlot { + if err := verifyParticipationStateLink(anchorState, participationState, participationSlot); err != nil { + return PerformanceDefenseProofs{}, err + } + participationWitnesses, err = buildRecentParticipationWitnesses(anchorState, participationSlot, chunkProofBytes) + if err != nil { + return PerformanceDefenseProofs{}, err + } + } else { + // Fetch the state at the end of the 8192 slot era containing the + // participation slot; its state_roots vector is the one summarised + // by historical_summaries[era] + eraBoundarySlot := (participationSlot/generic.SlotsPerHistoricalRoot + 1) * generic.SlotsPerHistoricalRoot + eraStateResponse, err := bc.GetBeaconStateSSZ(eraBoundarySlot) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error getting the era boundary state at slot %d (an archive Beacon Node may be required): %w", eraBoundarySlot, err) + } + eraState, err := eth2.NewBeaconState(eraStateResponse.Data, eraStateResponse.Size, eraStateResponse.Fork) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error parsing the era boundary state at slot %d: %w", eraBoundarySlot, err) + } + if err := verifyParticipationStateLink(eraState, participationState, participationSlot); err != nil { + return PerformanceDefenseProofs{}, err + } + capellaOffset := eth2Config.CapellaForkEpoch * eth2Config.SlotsPerEpoch / generic.SlotsPerHistoricalRoot + participationWitnesses, err = buildHistoricalParticipationWitnesses(anchorState, eraState, participationSlot, capellaOffset, chunkProofBytes) + if err != nil { + return PerformanceDefenseProofs{}, err + } + } + + slotTimestamp, err := GetChildBlockTimestampForSlot(c, anchorSlot) + if err != nil { + return PerformanceDefenseProofs{}, fmt.Errorf("error getting the slot timestamp: %w", err) + } + + return PerformanceDefenseProofs{ + Offset: offset, + ChallengeLeaf: challengeLeaf, + ChallengeWitness: challengeWitness, + SlotTimestamp: slotTimestamp, + Validator: megapool.ValidatorProof{ + ValidatorIndex: new(big.Int).SetUint64(validatorIndex), + Validator: megapool.ProvedValidator{ + Pubkey: validatorPubkey[:], + WithdrawalCredentials: withdrawalCredentialsFixed, + EffectiveBalance: validator.EffectiveBalance, + Slashed: validator.Slashed, + ActivationEligibilityEpoch: validator.ActivationEligibilityEpoch, + ActivationEpoch: validator.ActivationEpoch, + ExitEpoch: validator.ExitEpoch, + WithdrawableEpoch: validator.WithdrawableEpoch, + }, + Witnesses: ConvertToFixedSize(validatorProofBytes), + }, + Participation: megapool.ParticipationProof{ + ParticipationSlot: participationSlot, + ValidatorIndex: new(big.Int).SetUint64(validatorIndex), + ParticipationFlagsChunk: chunk, + Witnesses: ConvertToFixedSize(participationWitnesses), + }, + Slot: megapool.SlotProof{ + Slot: anchorSlot, + Witnesses: ConvertToFixedSize(slotProofBytes), + }, + }, nil +} + func GetWithdrawableEpochProof(c *cli.Command, wallet *wallet.Wallet, eth2Config beacon.Eth2Config, megapoolAddress common.Address, validatorPubkey types.ValidatorPubkey) (api.ValidatorWithdrawableEpochProof, error) { bc, err := GetBeaconClient(c) if err != nil { diff --git a/shared/services/megapools_test.go b/shared/services/megapools_test.go index 5c975e246..570d1f6d5 100644 --- a/shared/services/megapools_test.go +++ b/shared/services/megapools_test.go @@ -1,13 +1,18 @@ package services import ( + "bytes" + "crypto/sha256" "math/big" + "math/bits" "testing" "github.com/ethereum/go-ethereum/common" "github.com/rocket-pool/smartnode/bindings/rocketpool" "github.com/rocket-pool/smartnode/shared/types/api" + "github.com/rocket-pool/smartnode/shared/types/eth2/fork/fulu" + "github.com/rocket-pool/smartnode/shared/types/eth2/generic" ) // --------------------------------------------------------------------------- @@ -215,3 +220,435 @@ func TestHighExpressQueueRate(t *testing.T) { t.Errorf("want 5, got %d", got) } } + +// --------------------------------------------------------------------------- +// Participation proof slot selection +// --------------------------------------------------------------------------- + +// TestParticipationProofSlotRange — the proof state for challenged epoch E +// must come from epoch E+1, where E's flags live in +// previous_epoch_participation. +func TestParticipationProofSlotRange(t *testing.T) { + tests := []struct { + name string + challengedEpoch uint64 + slotsPerEpoch uint64 + wantFirst uint64 + wantLast uint64 + }{ + { + name: "epoch zero", + challengedEpoch: 0, + slotsPerEpoch: 32, + wantFirst: 32, + wantLast: 63, + }, + { + name: "mainnet-like epoch", + challengedEpoch: 105000, + slotsPerEpoch: 32, + wantFirst: 105001 * 32, + wantLast: 105002*32 - 1, + }, + { + name: "single-slot epochs", + challengedEpoch: 10, + slotsPerEpoch: 1, + wantFirst: 11, + wantLast: 11, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + first, last := participationProofSlotRange(tc.challengedEpoch, tc.slotsPerEpoch) + if first != tc.wantFirst || last != tc.wantLast { + t.Errorf("participationProofSlotRange(%d, %d) = (%d, %d), want (%d, %d)", + tc.challengedEpoch, tc.slotsPerEpoch, first, last, tc.wantFirst, tc.wantLast) + } + }) + } +} + +// --------------------------------------------------------------------------- +// participationBitmapWitness +// --------------------------------------------------------------------------- + +// testHashTree mirrors RocketNetworkParticipation.hashTree: raw uint256 words +// as leaves, zero-padded to the next power of two, hashed pairwise with +// sha256. +func testHashTree(t *testing.T, leaves []*big.Int) [32]byte { + t.Helper() + width := 1 + for width < len(leaves) { + width *= 2 + } + tree := make([][32]byte, width) + for i, leaf := range leaves { + leaf.FillBytes(tree[i][:]) + } + for width > 1 { + for i := 0; i < width; i += 2 { + var pair [64]byte + copy(pair[:32], tree[i][:]) + copy(pair[32:], tree[i+1][:]) + tree[i/2] = sha256.Sum256(pair[:]) + } + width /= 2 + } + return tree[0] +} + +// testRestoreMerkleRoot mirrors RocketNetworkParticipation.restoreMerkleRoot: +// walk the generalized index from the leaf to the root, hashing with the +// witnesses. +func testRestoreMerkleRoot(t *testing.T, leaf [32]byte, gindex uint64, witnesses []common.Hash) [32]byte { + t.Helper() + if 1<<(len(witnesses)+1) <= gindex { + t.Fatalf("invalid witness length %d for gindex %d", len(witnesses), gindex) + } + value := leaf + i := 0 + for gindex != 1 { + var pair [64]byte + if gindex%2 == 1 { + copy(pair[:32], witnesses[i][:]) + copy(pair[32:], value[:]) + } else { + copy(pair[:32], value[:]) + copy(pair[32:], witnesses[i][:]) + } + value = sha256.Sum256(pair[:]) + gindex /= 2 + i++ + } + return value +} + +func TestParticipationBitmapWitness(t *testing.T) { + for _, wordCount := range []int{1, 2, 3, 5, 8} { + // Deterministic, distinct bitmap words + participation := make([]*big.Int, wordCount) + for i := range participation { + word := new(big.Int).Lsh(big.NewInt(int64(i)+1), 13) + word.Add(word, big.NewInt(int64(i)*7+1)) + participation[i] = word + } + + root := testHashTree(t, participation) + width := uint64(1) + for width < uint64(wordCount) { + width *= 2 + } + + for leafIndex := uint64(0); leafIndex < uint64(wordCount); leafIndex++ { + witness, err := participationBitmapWitness(participation, leafIndex) + if err != nil { + t.Fatalf("wordCount %d leafIndex %d: unexpected error: %v", wordCount, leafIndex, err) + } + + var leaf [32]byte + participation[leafIndex].FillBytes(leaf[:]) + + // The contract computes the generalized index as + // nextPowerOfTwo(leafCount) + leafIndex + gindex := width + leafIndex + restored := testRestoreMerkleRoot(t, leaf, gindex, witness) + if restored != root { + t.Fatalf("wordCount %d leafIndex %d: restored root %x does not match tree root %x", wordCount, leafIndex, restored, root) + } + } + } +} + +func TestParticipationBitmapWitnessSingleWord(t *testing.T) { + participation := []*big.Int{big.NewInt(0b1011)} + witness, err := participationBitmapWitness(participation, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // A single-word bitmap has an empty branch: the root is the word itself + if len(witness) != 0 { + t.Fatalf("expected empty witness for a single-word bitmap, got %d hashes", len(witness)) + } +} + +func TestParticipationBitmapWitnessErrors(t *testing.T) { + if _, err := participationBitmapWitness([]*big.Int{}, 0); err == nil { + t.Fatal("expected an error for an empty bitmap") + } + if _, err := participationBitmapWitness([]*big.Int{big.NewInt(1)}, 1); err == nil { + t.Fatal("expected an error for an out-of-bounds leaf index") + } + tooBig := new(big.Int).Lsh(big.NewInt(1), 256) + if _, err := participationBitmapWitness([]*big.Int{tooBig}, 0); err == nil { + t.Fatal("expected an error for a word larger than uint256") + } +} + +// --------------------------------------------------------------------------- +// Participation witness chain assembly +// --------------------------------------------------------------------------- + +// newProofTestFuluState builds a minimal but SSZ-valid fulu beacon state with +// numValidators validators and per-validator previous-epoch participation +// flags of (i % 8). +func newProofTestFuluState(t *testing.T, numValidators int, slot uint64) *fulu.BeaconState { + t.Helper() + + validators := make([]*generic.Validator, numValidators) + balances := make([]uint64, numValidators) + inactivityScores := make([]uint64, numValidators) + previousParticipation := make([]byte, numValidators) + currentParticipation := make([]byte, numValidators) + for i := range validators { + validators[i] = &generic.Validator{ + Pubkey: make([]byte, 48), + WithdrawalCredentials: make([]byte, 32), + EffectiveBalance: 32e9, + } + validators[i].Pubkey[0] = byte(i + 1) + balances[i] = 32e9 + previousParticipation[i] = byte(i % 8) + currentParticipation[i] = byte((i + 1) % 8) + } + + randaoMixes := make([][]byte, 65536) + for i := range randaoMixes { + randaoMixes[i] = make([]byte, 32) + } + + syncCommittee := func() *generic.SyncCommittee { + pubkeys := make([][]byte, 512) + for i := range pubkeys { + pubkeys[i] = make([]byte, 48) + } + return &generic.SyncCommittee{PubKeys: pubkeys} + } + + parentRoot := make([]byte, 32) + parentRoot[0] = 0xaa + bodyRoot := make([]byte, 32) + bodyRoot[0] = 0xbb + + return &fulu.BeaconState{ + GenesisValidatorsRoot: make([]byte, 32), + Slot: slot, + Fork: &generic.Fork{ + PreviousVersion: make([]byte, 4), + CurrentVersion: make([]byte, 4), + }, + LatestBlockHeader: &generic.BeaconBlockHeader{ + Slot: slot, + ProposerIndex: 1, + ParentRoot: parentRoot, + StateRoot: make([]byte, 32), + BodyRoot: bodyRoot, + }, + HistoricalRoots: [][]byte{}, + Eth1Data: &generic.Eth1Data{ + DepositRoot: make([]byte, 32), + BlockHash: make([]byte, 32), + }, + Eth1DataVotes: []*generic.Eth1Data{}, + Validators: validators, + Balances: balances, + RandaoMixes: randaoMixes, + Slashings: make([]uint64, 8192), + PreviousEpochParticipation: previousParticipation, + CurrentEpochParticipation: currentParticipation, + PreviousJustifiedCheckpoint: &generic.Checkpoint{Root: make([]byte, 32)}, + CurrentJustifiedCheckpoint: &generic.Checkpoint{Root: make([]byte, 32)}, + FinalizedCheckpoint: &generic.Checkpoint{Root: make([]byte, 32)}, + InactivityScores: inactivityScores, + CurrentSyncCommittee: syncCommittee(), + NextSyncCommittee: syncCommittee(), + LatestExecutionPayloadHeader: &generic.ExecutionPayloadHeader{}, + HistoricalSummaries: []*generic.HistoricalSummary{}, + ProposerLookahead: make([]uint64, 64), + } +} + +// concatGid appends a child generalized index (rooted at 1 in its own +// subtree) onto a parent generalized index, mirroring the on-chain SSZ.concat +// path construction. The combined index exceeds 64 bits for full +// participation witness chains, hence big.Int. +func concatGid(parent *big.Int, child uint64) *big.Int { + depth := bits.Len64(child) - 1 + out := new(big.Int).Lsh(parent, uint(depth)) + return out.Or(out, new(big.Int).SetUint64(child-1< state_root ++ state -> state_roots[n] ++ state -> chunk + stateRootsGid := (uint64(1)*64+generic.BeaconStateStateRootsFieldIndex)*generic.BeaconStateBlockRootsMaxLength + participationSlot%generic.SlotsPerHistoricalRoot + chunkGid := generic.GetGeneralizedIndexForParticipationChunk(validatorIndex/32, fulu.GetGeneralizedIndexForPreviousEpochParticipation()) + gid := big.NewInt(int64(generic.BeaconBlockHeaderStateRootGeneralizedIndex)) + gid = concatGid(gid, stateRootsGid) + gid = concatGid(gid, chunkGid) + + root := walkWitnessChain(t, chunk[:], gid, witnesses) + if !bytes.Equal(root, anchorBlockRoot(t, anchorState)) { + t.Fatalf("restored root %x does not match the anchor block root", root) + } +} + +func TestBuildHistoricalParticipationWitnesses(t *testing.T) { + const numValidators = 100 + const validatorIndex = uint64(33) + const capellaOffset = uint64(0) + // Participation slot in era 1, anchored more than 8192 slots later + const participationSlot = uint64(generic.SlotsPerHistoricalRoot + 5000) + const eraBoundarySlot = (participationSlot/generic.SlotsPerHistoricalRoot + 1) * generic.SlotsPerHistoricalRoot + const anchorSlot = uint64(5*generic.SlotsPerHistoricalRoot + 77) + const entry = participationSlot/generic.SlotsPerHistoricalRoot - capellaOffset + + participationState := newProofTestFuluState(t, numValidators, participationSlot) + eraState := newProofTestFuluState(t, numValidators, eraBoundarySlot) + anchorState := newProofTestFuluState(t, numValidators, anchorSlot) + + // Wire the era boundary state's state_roots vector to commit to the + // participation state + participationRoot, err := generic.SSZ.HashTreeRoot(participationState) + if err != nil { + t.Fatalf("failed to hash participation state: %v", err) + } + eraState.StateRoots[participationSlot%generic.SlotsPerHistoricalRoot] = participationRoot + + if err := verifyParticipationStateLink(eraState, participationState, participationSlot); err != nil { + t.Fatalf("state link check failed: %v", err) + } + + // Wire the anchor's historical_summaries to commit to the era boundary + // state's roots vectors + hsls := generic.HistoricalSummaryLists{ + BlockRoots: eraState.BlockRoots, + StateRoots: eraState.StateRoots, + } + hslsTree, err := generic.SSZ.GetTree(&hsls) + if err != nil { + t.Fatalf("failed to get historical summary lists tree: %v", err) + } + blockSummaryNode, err := hslsTree.Get(2) + if err != nil { + t.Fatalf("failed to get block summary node: %v", err) + } + stateSummaryNode, err := hslsTree.Get(3) + if err != nil { + t.Fatalf("failed to get state summary node: %v", err) + } + summaries := make([]*generic.HistoricalSummary, entry+1) + for i := range summaries { + summaries[i] = &generic.HistoricalSummary{} + summaries[i].BlockSummaryRoot[0] = byte(i + 1) + } + copy(summaries[entry].BlockSummaryRoot[:], blockSummaryNode.Hash()) + copy(summaries[entry].StateSummaryRoot[:], stateSummaryNode.Hash()) + anchorState.HistoricalSummaries = summaries + + chunk, chunkProof, err := participationState.PreviousEpochParticipationChunkProof(validatorIndex) + if err != nil { + t.Fatalf("failed to build the chunk proof: %v", err) + } + witnesses, err := buildHistoricalParticipationWitnesses(anchorState, eraState, participationSlot, capellaOffset, chunkProof) + if err != nil { + t.Fatalf("failed to build the historical witnesses: %v", err) + } + if len(witnesses) != 90 { + t.Fatalf("historical witness count = %d, want 90", len(witnesses)) + } + + // Combined gindex, mirroring BeaconStateVerifier.verifyParticipation: + // header -> state_root ++ state -> historical_summaries[n] ++ + // HistoricalSummary -> state_summary_root ++ state_roots -> [n] ++ + // state -> chunk + summaryElementGid := (uint64(1)*64+generic.BeaconStateHistoricalSummariesFieldIndex)*2*generic.BeaconStateHistoricalSummariesMaxLength + entry + stateRootsVectorGid := generic.SlotsPerHistoricalRoot + participationSlot%generic.SlotsPerHistoricalRoot + chunkGid := generic.GetGeneralizedIndexForParticipationChunk(validatorIndex/32, fulu.GetGeneralizedIndexForPreviousEpochParticipation()) + gid := big.NewInt(int64(generic.BeaconBlockHeaderStateRootGeneralizedIndex)) + gid = concatGid(gid, summaryElementGid) + gid = concatGid(gid, 3) // HistoricalSummary -> state_summary_root + gid = concatGid(gid, stateRootsVectorGid) + gid = concatGid(gid, chunkGid) + + root := walkWitnessChain(t, chunk[:], gid, witnesses) + if !bytes.Equal(root, anchorBlockRoot(t, anchorState)) { + t.Fatalf("restored root %x does not match the anchor block root", root) + } +} diff --git a/shared/services/performance/target-performance.go b/shared/services/performance/target-performance.go new file mode 100644 index 000000000..0841f0ddc --- /dev/null +++ b/shared/services/performance/target-performance.go @@ -0,0 +1,856 @@ +// Package performance implements RPIP-73 target-vote performance verification. +// +// RPIP-73 measures attestation performance using the "target" timeliness flag +// from the Beacon State's previous_epoch_participation vector. The flag is +// defined to be set for validator v in epoch E iff some attestation by v with +// data.target.epoch == E was: +// +// 1. included in a block (which implies source-checkpoint matching), AND +// 2. voting for the correct target root, i.e. data.target.root equals the +// canonical block root at the first slot of epoch E, AND +// 3. included within SLOTS_PER_EPOCH slots of data.slot. +package performance + +import ( + "fmt" + "math/big" + "strconv" + + "github.com/ethereum/go-ethereum/common" + + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/settings/protocol" + rptypes "github.com/rocket-pool/smartnode/bindings/types" + "github.com/rocket-pool/smartnode/shared/math" + + "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/state" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +// Beacon participation flag indices, see the Ethereum consensus spec +// (Altair upgrade). previous_epoch_participation packs these as bit flags +// per validator in a single byte. +const ( + TimelySourceFlagIndex = 0 + TimelyTargetFlagIndex = 1 + TimelyHeadFlagIndex = 2 +) + +// defaultPerformanceThresholdPct is the RPIP-73 initial pDAO value +const defaultPerformanceThresholdPct = 94.0 + +func GetPerformanceThresholdPct(rp *rocketpool.RocketPool) (float64, error) { + saturn2Deployed, err := state.IsSaturn2Deployed(rp, nil) + if err != nil { + return 0, fmt.Errorf("error checking if Saturn 2 is deployed: %w", err) + } + if !saturn2Deployed { + return defaultPerformanceThresholdPct, nil + } + thresholdWei, err := protocol.GetPerformanceThreshold(rp, nil) + if err != nil { + return 0, fmt.Errorf("error getting performance threshold: %w", err) + } + return math.WeiToEth(thresholdWei) * 100.0, nil +} + +// DefaultPerformancePeriodEpochs is used by verify-performance when Saturn 2 +// is not deployed and there is no on-chain performance_period to read. +const DefaultPerformancePeriodEpochs uint64 = 44032 + +// ChallengeParams are the pDAO settings governing performance challenges. +type ChallengeParams struct { + ExitsEnabled bool + PeriodEpochs uint64 + ProofBufferEpochs uint64 +} + +// GetChallengeParams fetches the pDAO performance-challenge settings. +func GetChallengeParams(rp *rocketpool.RocketPool) (ChallengeParams, error) { + saturn2Deployed, err := state.IsSaturn2Deployed(rp, nil) + if err != nil { + return ChallengeParams{}, fmt.Errorf("error checking if Saturn 2 is deployed: %w", err) + } + if !saturn2Deployed { + return ChallengeParams{}, nil + } + exitsEnabled, err := protocol.GetPerformanceExitsEnabled(rp, nil) + if err != nil { + return ChallengeParams{}, err + } + periodEpochs, err := protocol.GetPerformancePeriod(rp, nil) + if err != nil { + return ChallengeParams{}, err + } + proofBufferEpochs, err := protocol.GetProofBuffer(rp, nil) + if err != nil { + return ChallengeParams{}, err + } + return ChallengeParams{ + ExitsEnabled: exitsEnabled, + PeriodEpochs: periodEpochs, + ProofBufferEpochs: proofBufferEpochs, + }, nil +} + +// challengeBeaconClient is the beacon client surface needed to evaluate the +// challengeability of an epoch range. +type challengeBeaconClient interface { + GetBeaconHead() (beacon.BeaconHead, error) +} + +// IsRangeChallengeable fetches the pDAO challenge settings and the beacon +// head, then reports whether a performance check over the inclusive range +// [startEpoch, endEpoch] could back an on-chain challenge. See +// IsChallengeable for the rules. +func IsRangeChallengeable(rp *rocketpool.RocketPool, bc challengeBeaconClient, startEpoch, endEpoch uint64) (bool, error) { + params, err := GetChallengeParams(rp) + if err != nil { + return false, err + } + head, err := bc.GetBeaconHead() + if err != nil { + return false, fmt.Errorf("error getting beacon head: %w", err) + } + return IsChallengeable(params, head.Epoch, startEpoch, endEpoch), nil +} + +// ExceedsChallengeThreshold reports whether the validator missed enough +// target votes for a challenge to succeed: the missed share of the checked +// period must be higher than the allowed slack (100% - performance_threshold). +func ExceedsChallengeThreshold(resp *api.VerifyPerformanceResponse) bool { + if resp.TotalEpochs == 0 { + return false + } + missedPct := float64(resp.MissedEpochs) / float64(resp.TotalEpochs) * 100.0 + return missedPct > 100.0-resp.PerformanceThresholdPct +} + +// IsChallengeable reports whether a performance check over the inclusive +// range [startEpoch, endEpoch] could back an on-chain challenge: performance +// exits must be enabled, the range must cover exactly one performance period, +// and it must be recent enough that the proof buffer has not elapsed +// (startEpoch > currentEpoch - period - proofBuffer). +func IsChallengeable(params ChallengeParams, currentEpoch, startEpoch, endEpoch uint64) bool { + if !params.ExitsEnabled { + return false + } + if endEpoch != startEpoch+params.PeriodEpochs-1 { + return false + } + window := params.PeriodEpochs + params.ProofBufferEpochs + if currentEpoch <= window { + // The whole chain history is still within the challenge window. + return true + } + return startEpoch > currentEpoch-window +} + +// farFutureEpoch is the spec's FAR_FUTURE_EPOCH sentinel (2^64-1). +const farFutureEpoch = ^uint64(0) + +// maxTargetRootWalkback bounds the number of skipped slots we will walk back +// to find the canonical target block root for an epoch. In any healthy chain +// this is 0 (the boundary slot has a block) or a small handful. +const maxTargetRootWalkback = 64 + +// EpochPerformance is the per-epoch result of a target-vote check. +type EpochPerformance struct { + Epoch uint64 `json:"epoch"` + TimelyTarget bool `json:"timelyTarget"` +} + +// PerformanceSummary aggregates the per-epoch results of a target-vote check +// over the inclusive range [StartEpoch, EndEpoch]. Epochs in which the +// validator was not assigned to any committee (i.e. not yet active or already +// exited) are counted in InactiveEpochs and excluded from PerformancePct's +// denominator. +type PerformanceSummary struct { + ValidatorIndex uint64 `json:"validatorIndex"` + StartEpoch uint64 `json:"startEpoch"` + EndEpoch uint64 `json:"endEpoch"` + TotalEpochs uint64 `json:"totalEpochs"` + TimelyEpochs uint64 `json:"timelyEpochs"` + MissedEpochs uint64 `json:"missedEpochs"` + InactiveEpochs uint64 `json:"inactiveEpochs"` + PerformancePct float64 `json:"performancePct"` + MissedEpochList []uint64 `json:"missedEpochList"` + TimelyEpochList []uint64 `json:"timelyEpochList"` +} + +// PerformanceBeaconClient is the minimal beacon client surface used by the +// block-based target-vote engine. +type PerformanceBeaconClient interface { + GetEth2Config() (beacon.Eth2Config, error) + GetHistoricalCommitteesForEpoch(epoch uint64) (beacon.Committees, error) + GetBeaconBlock(blockId string) (beacon.BeaconBlock, bool, error) + GetBeaconBlockHeader(blockId string) (beacon.BeaconBlockHeader, bool, error) + GetValidatorStatusByIndex(index string, opts *beacon.ValidatorStatusOptions) (beacon.ValidatorStatus, error) +} + +// pubkeyBeaconClient is the beacon client surface needed to resolve validator +// pubkeys to beacon-chain indices in addition to the engine requirements. +type pubkeyBeaconClient interface { + PerformanceBeaconClient + GetValidatorIndex(pubkey rptypes.ValidatorPubkey) (string, error) + GetValidatorStatuses(pubkeys []rptypes.ValidatorPubkey, opts *beacon.ValidatorStatusOptions) (map[rptypes.ValidatorPubkey]beacon.ValidatorStatus, error) +} + +// VerifyPerformance is the end-to-end RPIP-73 target-vote verification flow for +// a single validator. It resolves the validator's beacon-chain index from the +// supplied pubkey, runs CheckTargetPerformance, and packages the result +// alongside the pDAO performance_threshold for pass/fail reporting. +// +// To verify several validators in one run, prefer VerifyPerformanceBatch, which +// shares per-epoch beacon data across all of them. +func VerifyPerformance( + rp *rocketpool.RocketPool, + bc pubkeyBeaconClient, + pubkey rptypes.ValidatorPubkey, + startEpoch uint64, + endEpoch uint64, +) (*api.VerifyPerformanceResponse, error) { + if pubkey == (rptypes.ValidatorPubkey{}) { + return nil, fmt.Errorf("validator has no pubkey on-chain yet (not deposited?)") + } + + cfg, err := bc.GetEth2Config() + if err != nil { + return nil, fmt.Errorf("error getting beacon config: %w", err) + } + + indexStr, err := bc.GetValidatorIndex(pubkey) + if err != nil { + return nil, fmt.Errorf("error getting beacon-chain index for validator %s: %w", pubkey.Hex(), err) + } + validatorIndex, err := strconv.ParseUint(indexStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("error parsing validator index %q: %w", indexStr, err) + } + + thresholdPct, err := GetPerformanceThresholdPct(rp) + if err != nil { + return nil, err + } + + summary, err := CheckTargetPerformance(bc, cfg, validatorIndex, startEpoch, endEpoch) + if err != nil { + return nil, err + } + + return summaryToResponse(pubkey, summary, thresholdPct), nil +} + +// BatchValidatorResult is one validator's outcome from VerifyPerformanceBatch. +// Exactly one of Response or Err is set: Response when the check succeeded, Err +// when that single validator could not be verified (the rest of the batch is +// unaffected). The slice returned by VerifyPerformanceBatch is aligned +// positionally with the input pubkeys, so callers can map results back to their +// own identifiers (minipool address, megapool validator id, etc). +type BatchValidatorResult struct { + Pubkey rptypes.ValidatorPubkey + // Active reports whether the validator is currently active on the beacon + // chain (activated and not yet exited) according to its live head status. + // Callers that target "all" validators use this to skip validators that are + // not actively attesting. + Active bool + Response *api.VerifyPerformanceResponse + Err error +} + +// isActiveValidatorState reports whether a beacon validator state counts as +// actively attesting (activated on the beacon chain and not yet exited). +func isActiveValidatorState(state beacon.ValidatorState) bool { + switch state { + case beacon.ValidatorState_ActiveOngoing, + beacon.ValidatorState_ActiveExiting, + beacon.ValidatorState_ActiveSlashed: + return true + default: + return false + } +} + +// VerifyPerformanceBatch verifies the RPIP-73 target-vote performance of many +// validators over the same inclusive epoch range in a single pass. All +// per-epoch beacon data (target roots, committee assignments, inclusion-window +// blocks) is fetched once via a shared epochCache and reused for every +// validator, and the validators' indices and statuses are resolved in a single +// batched beacon call. +// +// The returned slice is positionally aligned with pubkeys. A fatal error +// (returned as the second value) only occurs for failures that prevent the +// whole batch from running, such as being unable to read the beacon config or +// resolve any validator statuses; per-validator problems are reported in each +// entry's Err field instead. +func VerifyPerformanceBatch( + rp *rocketpool.RocketPool, + bc pubkeyBeaconClient, + pubkeys []rptypes.ValidatorPubkey, + startEpoch uint64, + endEpoch uint64, +) ([]BatchValidatorResult, error) { + if endEpoch < startEpoch { + return nil, fmt.Errorf("end epoch %d is before start epoch %d", endEpoch, startEpoch) + } + + cfg, err := bc.GetEth2Config() + if err != nil { + return nil, fmt.Errorf("error getting beacon config: %w", err) + } + if cfg.SlotsPerEpoch == 0 { + return nil, fmt.Errorf("invalid beacon config: SlotsPerEpoch is 0") + } + + // Fetch the pDAO performance threshold once for the whole batch. + thresholdPct, err := GetPerformanceThresholdPct(rp) + if err != nil { + return nil, err + } + + // Resolve every non-zero pubkey to its index and status in a single call. + uniquePubkeys := make([]rptypes.ValidatorPubkey, 0, len(pubkeys)) + seen := map[rptypes.ValidatorPubkey]struct{}{} + for _, pk := range pubkeys { + if pk == (rptypes.ValidatorPubkey{}) { + continue + } + if _, ok := seen[pk]; ok { + continue + } + seen[pk] = struct{}{} + uniquePubkeys = append(uniquePubkeys, pk) + } + + statusByPubkey := map[rptypes.ValidatorPubkey]beacon.ValidatorStatus{} + if len(uniquePubkeys) > 0 { + statusByPubkey, err = bc.GetValidatorStatuses(uniquePubkeys, nil) + if err != nil { + return nil, fmt.Errorf("error resolving validator statuses: %w", err) + } + } + + // Build the set of indices to track and a pre-populated status map keyed by + // index string so the cache never has to re-query a status. + indexSet := map[string]struct{}{} + statusByIndex := map[string]beacon.ValidatorStatus{} + for _, status := range statusByPubkey { + if !status.Exists || status.Index == "" { + continue + } + indexSet[status.Index] = struct{}{} + statusByIndex[status.Index] = status + } + + cache := newEpochCache(bc, cfg, indexSet, statusByIndex) + + results := make([]BatchValidatorResult, len(pubkeys)) + for i, pk := range pubkeys { + results[i].Pubkey = pk + if pk == (rptypes.ValidatorPubkey{}) { + results[i].Err = fmt.Errorf("validator has no pubkey on-chain yet (not deposited?)") + continue + } + status, ok := statusByPubkey[pk] + if !ok || !status.Exists || status.Index == "" { + results[i].Err = fmt.Errorf("validator %s not found on the beacon chain yet", pk.Hex()) + continue + } + results[i].Active = isActiveValidatorState(status.Status) + indexU64, err := strconv.ParseUint(status.Index, 10, 64) + if err != nil { + results[i].Err = fmt.Errorf("error parsing validator index %q: %w", status.Index, err) + continue + } + summary, err := cache.computeSummary(status.Index, indexU64, startEpoch, endEpoch) + if err != nil { + results[i].Err = err + continue + } + results[i].Response = summaryToResponse(pk, summary, thresholdPct) + } + + return results, nil +} + +// CheckTargetPerformance evaluates a single validator's target-vote +// performance over the inclusive epoch range [startEpoch, endEpoch] by +// reading the canonical target root, the validator's committee assignment, +// and the attestations in the inclusion window per epoch. +func CheckTargetPerformance( + bc PerformanceBeaconClient, + cfg beacon.Eth2Config, + validatorIndex uint64, + startEpoch uint64, + endEpoch uint64, +) (*PerformanceSummary, error) { + if endEpoch < startEpoch { + return nil, fmt.Errorf("end epoch %d is before start epoch %d", endEpoch, startEpoch) + } + if cfg.SlotsPerEpoch == 0 { + return nil, fmt.Errorf("invalid beacon config: SlotsPerEpoch is 0") + } + + indexStr := strconv.FormatUint(validatorIndex, 10) + cache := newEpochCache(bc, cfg, map[string]struct{}{indexStr: {}}, nil) + return cache.computeSummary(indexStr, validatorIndex, startEpoch, endEpoch) +} + +// CheckEpochTargetVote returns true if the validator made a timely target +// vote for the given epoch. Returns (false, nil) for missed-target epochs; +// errors are reserved for I/O / parsing failures. If the validator was not +// in a committee for this epoch, the result is (true, nil) — there was no +// duty to perform, so it is not exit-eligible under RPIP-73. +// +// This is the lightweight single-epoch entry point intended for use by a +// challenge defender, who only needs to find one timely epoch within the +// challenged range. +func CheckEpochTargetVote( + bc PerformanceBeaconClient, + cfg beacon.Eth2Config, + validatorIndex uint64, + epoch uint64, +) (bool, error) { + indexStr := strconv.FormatUint(validatorIndex, 10) + cache := newEpochCache(bc, cfg, map[string]struct{}{indexStr: {}}, nil) + state, err := cache.evaluateEpoch(indexStr, validatorIndex, epoch) + if err != nil { + return false, err + } + return state != epochResultMissed, nil +} + +// FindFirstTimelyTargetVote scans the supplied validator indices over the +// supplied epochs and returns the first validator index (and the epoch) that +// made a valid target vote +func FindFirstTimelyTargetVote( + bc PerformanceBeaconClient, + cfg beacon.Eth2Config, + validatorIndices []uint64, + epochs []uint64, +) (validatorIndex uint64, epoch uint64, found bool, err error) { + if cfg.SlotsPerEpoch == 0 { + return 0, 0, false, fmt.Errorf("invalid beacon config: SlotsPerEpoch is 0") + } + + // Build the tracked index set (keyed by index string, as the cache keys on + // those) while de-duplicating the input. + indexSet := make(map[string]struct{}, len(validatorIndices)) + indexStrByIndex := make(map[uint64]string, len(validatorIndices)) + uniqueIndices := make([]uint64, 0, len(validatorIndices)) + for _, idx := range validatorIndices { + if _, seen := indexStrByIndex[idx]; seen { + continue + } + s := strconv.FormatUint(idx, 10) + indexSet[s] = struct{}{} + indexStrByIndex[idx] = s + uniqueIndices = append(uniqueIndices, idx) + } + + cache := newEpochCache(bc, cfg, indexSet, nil) + + // Inspect each validator over the challenged epochs, returning as soon as + // one of them is found to have made a timely target vote. + for _, idx := range uniqueIndices { + for _, ep := range epochs { + state, err := cache.evaluateEpoch(indexStrByIndex[idx], idx, ep) + if err != nil { + return 0, 0, false, fmt.Errorf("error checking validator index %d in epoch %d: %w", idx, ep, err) + } + if state == epochResultTimely { + return idx, ep, true, nil + } + } + } + + return 0, 0, false, nil +} + +// epochResult enumerates the per-epoch verdicts. +type epochResult int + +const ( + epochResultMissed epochResult = iota + epochResultTimely + epochResultInactive +) + +// attestationDuty describes the unique (slot, committee, position) tuple +// assigned to a validator for an epoch. +type attestationDuty struct { + slot uint64 + committeeIndex uint64 + position int + committeeSizesAtDay map[uint64]int // committee_index -> validator count, for all committees at duty.slot +} + +// cachedBlock memoizes a single GetBeaconBlock lookup, including the "missing" +// (skipped slot) case. +type cachedBlock struct { + block beacon.BeaconBlock + exists bool +} + +// rootResult memoizes a target-root resolution, including its error. +type rootResult struct { + root common.Hash + err error +} + +// epochCache fetches and memoizes the per-epoch beacon data needed for +// target-vote verification so it can be shared across many validators in a +// single run. It is not safe for concurrent use. +type epochCache struct { + bc PerformanceBeaconClient + cfg beacon.Eth2Config + indexSet map[string]struct{} + + targetRoots map[uint64]rootResult // epoch -> target root + epochDuties map[uint64]map[string]attestationDuty // epoch -> validator index string -> duty + blocks map[uint64]cachedBlock // slot -> block + statuses map[string]beacon.ValidatorStatus // validator index string -> status +} + +// newEpochCache creates a cache that tracks the supplied validator index set. +// statuses may be nil; when provided it pre-populates validator statuses (keyed +// by index string) so the cache never has to query them again for inactive +// detection. +func newEpochCache( + bc PerformanceBeaconClient, + cfg beacon.Eth2Config, + indexSet map[string]struct{}, + statuses map[string]beacon.ValidatorStatus, +) *epochCache { + if statuses == nil { + statuses = map[string]beacon.ValidatorStatus{} + } + return &epochCache{ + bc: bc, + cfg: cfg, + indexSet: indexSet, + targetRoots: map[uint64]rootResult{}, + epochDuties: map[uint64]map[string]attestationDuty{}, + blocks: map[uint64]cachedBlock{}, + statuses: statuses, + } +} + +// computeSummary evaluates one validator over the inclusive epoch range using +// the shared cache. +func (c *epochCache) computeSummary(indexStr string, indexU64 uint64, startEpoch, endEpoch uint64) (*PerformanceSummary, error) { + summary := &PerformanceSummary{ + ValidatorIndex: indexU64, + StartEpoch: startEpoch, + EndEpoch: endEpoch, + TotalEpochs: endEpoch - startEpoch + 1, + MissedEpochList: []uint64{}, + TimelyEpochList: []uint64{}, + } + + for epoch := startEpoch; epoch <= endEpoch; epoch++ { + state, err := c.evaluateEpoch(indexStr, indexU64, epoch) + if err != nil { + return nil, fmt.Errorf("error checking epoch %d: %w", epoch, err) + } + switch state { + case epochResultTimely: + summary.TimelyEpochs++ + summary.TimelyEpochList = append(summary.TimelyEpochList, epoch) + case epochResultMissed: + summary.MissedEpochs++ + summary.MissedEpochList = append(summary.MissedEpochList, epoch) + case epochResultInactive: + summary.InactiveEpochs++ + } + } + + activeEpochs := summary.TimelyEpochs + summary.MissedEpochs + if activeEpochs > 0 { + summary.PerformancePct = float64(summary.TimelyEpochs) / float64(activeEpochs) * 100.0 + } + + return summary, nil +} + +// evaluateEpoch performs the per-epoch evaluation for a single validator. It +// returns epochResultTimely if a matching, timely target vote was found; +// epochResultMissed if the validator had a duty but no matching attestation +// landed; epochResultInactive if the validator had no committee assignment for +// the epoch (and was not required to attest). +func (c *epochCache) evaluateEpoch(indexStr string, indexU64 uint64, epoch uint64) (epochResult, error) { + // Find the validator's attestation duty for the epoch from the (cached) + // committees. + duty, found, err := c.dutyFor(epoch, indexStr) + if err != nil { + return epochResultMissed, err + } + if !found { + status, err := c.status(indexStr) + if err != nil { + return epochResultMissed, fmt.Errorf("error getting validator status for index %s: %w", indexStr, err) + } + if !validatorHadAttestationDuty(status, epoch) { + return epochResultInactive, nil + } + return epochResultMissed, fmt.Errorf( + "validator index %s was active in epoch %d but was not found in attestation committees; ensure your beacon node provides historical committee data (archival node required)", + indexStr, epoch, + ) + } + + // Resolve the canonical target block root for the epoch (cached, shared). + targetRoot, err := c.targetRoot(epoch) + if err != nil { + return epochResultMissed, err + } + + // Scan the inclusion window for a matching attestation. The inclusion + // window for the target flag is up to SLOTS_PER_EPOCH slots after the duty + // slot. Return as soon as we find a match. + inclusionEndExclusive := duty.slot + 1 + c.cfg.SlotsPerEpoch + for slot := duty.slot + 1; slot < inclusionEndExclusive; slot++ { + block, exists, err := c.block(slot) + if err != nil { + return epochResultMissed, fmt.Errorf("error getting block at slot %d: %w", slot, err) + } + if !exists { + continue + } + if matchesDuty(block.Attestations, duty, epoch, targetRoot) { + return epochResultTimely, nil + } + } + + return epochResultMissed, nil +} + +// targetRoot returns the canonical block root for epoch E, memoized per epoch. +func (c *epochCache) targetRoot(epoch uint64) (common.Hash, error) { + if r, ok := c.targetRoots[epoch]; ok { + return r.root, r.err + } + root, err := resolveTargetRoot(c.bc, c.cfg, epoch) + c.targetRoots[epoch] = rootResult{root: root, err: err} + return root, err +} + +// block returns the beacon block at the given slot, memoized per slot. The +// returned bool reports whether a block exists at that slot (false for a +// skipped slot). +func (c *epochCache) block(slot uint64) (beacon.BeaconBlock, bool, error) { + if b, ok := c.blocks[slot]; ok { + return b.block, b.exists, nil + } + block, exists, err := c.bc.GetBeaconBlock(strconv.FormatUint(slot, 10)) + if err != nil { + return beacon.BeaconBlock{}, false, err + } + c.blocks[slot] = cachedBlock{block: block, exists: exists} + return block, exists, nil +} + +// status returns the validator status for the given index string, memoized and +// using any pre-populated statuses first. +func (c *epochCache) status(indexStr string) (beacon.ValidatorStatus, error) { + if status, ok := c.statuses[indexStr]; ok { + return status, nil + } + status, err := c.bc.GetValidatorStatusByIndex(indexStr, nil) + if err != nil { + return beacon.ValidatorStatus{}, err + } + c.statuses[indexStr] = status + return status, nil +} + +// dutyFor returns the requested validator's attestation duty for the epoch, +// building (and caching) the duty map for all tracked indices on first access. +func (c *epochCache) dutyFor(epoch uint64, indexStr string) (attestationDuty, bool, error) { + if err := c.ensureEpochDuties(epoch); err != nil { + return attestationDuty{}, false, err + } + duty, found := c.epochDuties[epoch][indexStr] + return duty, found, nil +} + +// ensureEpochDuties fetches the committees for the epoch once and, in a single +// pass, records the attestation duties of every tracked validator index along +// with the committee sizes needed to compute aggregation-bits offsets. +func (c *epochCache) ensureEpochDuties(epoch uint64) error { + if _, ok := c.epochDuties[epoch]; ok { + return nil + } + + committees, err := c.bc.GetHistoricalCommitteesForEpoch(epoch) + if err != nil { + return fmt.Errorf("error getting committees for epoch %d: %w", epoch, err) + } + defer committees.Release() + + duties := map[string]attestationDuty{} + // slot -> committee_index -> validator count, for every committee in the + // epoch. Needed because post-Electra aggregation_bits pack multiple + // committees per slot, so the offset of a validator depends on the sizes of + // the lower-indexed committees at its slot. + slotSizes := map[uint64]map[uint64]int{} + + for i := 0; i < committees.Count(); i++ { + slot := committees.Slot(i) + committeeIndex := committees.Index(i) + validators := committees.Validators(i) + + if slotSizes[slot] == nil { + slotSizes[slot] = map[uint64]int{} + } + slotSizes[slot][committeeIndex] = len(validators) + + // Only bother matching when there are still tracked validators left to + // find that could be in this committee. + for pos, vIdx := range validators { + if _, tracked := c.indexSet[vIdx]; !tracked { + continue + } + if _, already := duties[vIdx]; already { + continue + } + duties[vIdx] = attestationDuty{ + slot: slot, + committeeIndex: committeeIndex, + position: pos, + } + } + } + + // Attach the committee-size map for each duty's slot. + for vIdx, duty := range duties { + duty.committeeSizesAtDay = slotSizes[duty.slot] + duties[vIdx] = duty + } + + c.epochDuties[epoch] = duties + return nil +} + +// resolveTargetRoot returns the canonical block root for epoch E, i.e. +// get_block_root(state, E) = canonical block root at slot E*SlotsPerEpoch +// (walking back if the boundary slot was skipped). +func resolveTargetRoot(bc PerformanceBeaconClient, cfg beacon.Eth2Config, epoch uint64) (common.Hash, error) { + boundarySlot := epoch * cfg.SlotsPerEpoch + for attempt := uint64(0); attempt < maxTargetRootWalkback; attempt++ { + if attempt > boundarySlot { + break + } + slot := boundarySlot - attempt + header, exists, err := bc.GetBeaconBlockHeader(strconv.FormatUint(slot, 10)) + if err != nil { + return common.Hash{}, fmt.Errorf("error getting beacon block header at slot %d: %w", slot, err) + } + if exists { + return header.Root, nil + } + } + return common.Hash{}, fmt.Errorf("could not find a non-skipped slot within %d slots of epoch %d boundary to resolve target root", maxTargetRootWalkback, epoch) +} + +// matchesDuty returns true if any attestation in atts is a timely-target +// match for the given duty. The caller has already constrained inclusion +// delay by the slot range it iterated over. +func matchesDuty(atts []beacon.AttestationInfo, duty attestationDuty, epoch uint64, targetRoot common.Hash) bool { + for _, att := range atts { + if att.SlotIndex != duty.slot { + continue + } + if att.TargetEpoch != epoch { + continue + } + if att.TargetRoot != targetRoot { + continue + } + // The attestation must cover the duty committee. Pre-Electra + // attestations cover exactly one committee; post-Electra ones may + // cover several. Either way, CommitteeIndices() returns the set. + committeeIdxInt := int(duty.committeeIndex) + hasCommittee := false + for _, ci := range att.CommitteeIndices() { + if ci == committeeIdxInt { + hasCommittee = true + break + } + } + if !hasCommittee { + continue + } + if att.ValidatorAttested(committeeIdxInt, duty.position, duty.committeeSizesAtDay) { + return true + } + } + return false +} + +// validatorHadAttestationDuty reports whether the validator was required to +// perform an attestation duty in the given epoch based on its activation and +// exit epochs. +func validatorHadAttestationDuty(status beacon.ValidatorStatus, epoch uint64) bool { + if !status.Exists { + return false + } + if status.ActivationEpoch == farFutureEpoch || status.ActivationEpoch > epoch { + return false + } + if status.ExitEpoch != farFutureEpoch && status.ExitEpoch <= epoch { + return false + } + return true +} + +// bitsPerParticipationWord matches the Solidity uint256 word width +const bitsPerParticipationWord = 256 + +// EncodeParticipationBitset encodes the missed epochs of the inclusive range +// [startEpoch, endEpoch] as the uint256[] bitset expected by the +// challengeMegapool participation calldata: the words form a single bit +// stream starting at startEpoch, LSB-first within each word, with a 1 bit +// marking a not-timely target attestation. +func EncodeParticipationBitset(startEpoch, endEpoch uint64, missedEpochs []uint64) []*big.Int { + if endEpoch < startEpoch { + return []*big.Int{} + } + totalEpochs := endEpoch - startEpoch + 1 + wordCount := (totalEpochs + bitsPerParticipationWord - 1) / bitsPerParticipationWord + words := make([]*big.Int, wordCount) + for i := range words { + words[i] = new(big.Int) + } + for _, epoch := range missedEpochs { + if epoch < startEpoch || epoch > endEpoch { + continue + } + offset := epoch - startEpoch + word := words[offset/bitsPerParticipationWord] + word.SetBit(word, int(offset%bitsPerParticipationWord), 1) + } + return words +} + +// summaryToResponse packages a PerformanceSummary into the API response, +// attaching the pDAO performance_threshold and pass/fail verdict. +func summaryToResponse(pubkey rptypes.ValidatorPubkey, summary *PerformanceSummary, thresholdPct float64) *api.VerifyPerformanceResponse { + return &api.VerifyPerformanceResponse{ + ValidatorPubkey: pubkey, + ValidatorIndex: summary.ValidatorIndex, + StartEpoch: summary.StartEpoch, + EndEpoch: summary.EndEpoch, + TotalEpochs: summary.TotalEpochs, + TimelyEpochs: summary.TimelyEpochs, + MissedEpochs: summary.MissedEpochs, + InactiveEpochs: summary.InactiveEpochs, + PerformancePct: summary.PerformancePct, + PerformanceThresholdPct: thresholdPct, + PassesThreshold: summary.PerformancePct >= thresholdPct, + MissedEpochList: summary.MissedEpochList, + TimelyEpochList: summary.TimelyEpochList, + Participation: EncodeParticipationBitset(summary.StartEpoch, summary.EndEpoch, summary.MissedEpochList), + } +} diff --git a/shared/services/performance/target-performance_test.go b/shared/services/performance/target-performance_test.go new file mode 100644 index 000000000..3a524c8bd --- /dev/null +++ b/shared/services/performance/target-performance_test.go @@ -0,0 +1,238 @@ +package performance + +import ( + "math/big" + "reflect" + "testing" + + "github.com/rocket-pool/smartnode/shared/types/api" +) + +func TestIsChallengeable(t *testing.T) { + params := ChallengeParams{ + ExitsEnabled: true, + PeriodEpochs: 1000, + ProofBufferEpochs: 225, + } + // The challenge window is period + proofBufferEpochs = 1225 epochs, so with + // currentEpoch = 10000 the oldest challengeable start epoch is 8776. + + tests := []struct { + name string + params ChallengeParams + currentEpoch uint64 + startEpoch uint64 + endEpoch uint64 + want bool + }{ + { + name: "recent full period", + params: params, + currentEpoch: 10000, + startEpoch: 9000, + endEpoch: 9999, + want: true, + }, + { + name: "exits disabled", + params: ChallengeParams{ExitsEnabled: false, PeriodEpochs: 1000, ProofBufferEpochs: 225}, + currentEpoch: 10000, + startEpoch: 9000, + endEpoch: 9999, + want: false, + }, + { + name: "range one epoch too long", + params: params, + currentEpoch: 10000, + startEpoch: 9000, + endEpoch: 10000, + want: false, + }, + { + name: "range one epoch too short", + params: params, + currentEpoch: 10000, + startEpoch: 9000, + endEpoch: 9998, + want: false, + }, + { + name: "start epoch just inside the window", + params: params, + currentEpoch: 10000, + startEpoch: 8776, + endEpoch: 9775, + want: true, + }, + { + name: "start epoch at the window boundary", + params: params, + currentEpoch: 10000, + startEpoch: 8775, + endEpoch: 9774, + want: false, + }, + { + name: "current epoch smaller than the window", + params: params, + currentEpoch: 1000, + startEpoch: 0, + endEpoch: 999, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := IsChallengeable(tc.params, tc.currentEpoch, tc.startEpoch, tc.endEpoch) + if got != tc.want { + t.Errorf("IsChallengeable(%+v, current %d, [%d, %d]) = %v, want %v", + tc.params, tc.currentEpoch, tc.startEpoch, tc.endEpoch, got, tc.want) + } + }) + } +} + +func TestExceedsChallengeThreshold(t *testing.T) { + tests := []struct { + name string + totalEpochs uint64 + missedEpochs uint64 + thresholdPct float64 + want bool + }{ + { + name: "no epochs checked", + totalEpochs: 0, + missedEpochs: 0, + thresholdPct: 94.0, + want: false, + }, + { + name: "no missed epochs", + totalEpochs: 1024, + missedEpochs: 0, + thresholdPct: 94.0, + want: false, + }, + // 16/256 = 6.25% missed; the allowed slack is 100% - threshold. + { + name: "missed share exactly at the allowed slack", + totalEpochs: 256, + missedEpochs: 16, + thresholdPct: 93.75, + want: false, + }, + { + name: "missed share above the allowed slack", + totalEpochs: 256, + missedEpochs: 16, + thresholdPct: 94.0, + want: true, + }, + { + name: "missed share below the allowed slack", + totalEpochs: 256, + missedEpochs: 16, + thresholdPct: 93.0, + want: false, + }, + { + name: "every epoch missed", + totalEpochs: 256, + missedEpochs: 256, + thresholdPct: 94.0, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resp := &api.VerifyPerformanceResponse{ + TotalEpochs: tc.totalEpochs, + MissedEpochs: tc.missedEpochs, + PerformanceThresholdPct: tc.thresholdPct, + } + got := ExceedsChallengeThreshold(resp) + if got != tc.want { + t.Errorf("ExceedsChallengeThreshold(%d missed of %d, threshold %.2f%%) = %v, want %v", + tc.missedEpochs, tc.totalEpochs, tc.thresholdPct, got, tc.want) + } + }) + } +} + +func TestEncodeParticipationBitset(t *testing.T) { + tests := []struct { + name string + startEpoch uint64 + endEpoch uint64 + missedEpochs []uint64 + want []*big.Int + }{ + { + name: "end before start", + startEpoch: 100, + endEpoch: 99, + missedEpochs: []uint64{}, + want: []*big.Int{}, + }, + { + name: "no missed epochs, range spanning two words", + startEpoch: 100, + endEpoch: 399, // 300 epochs -> 2 words + missedEpochs: []uint64{}, + want: []*big.Int{big.NewInt(0), big.NewInt(0)}, + }, + { + name: "single missed epoch at start", + startEpoch: 100, + endEpoch: 355, // exactly 256 epochs -> 1 word + missedEpochs: []uint64{100}, + want: []*big.Int{big.NewInt(1)}, + }, + { + name: "scattered bits within one word", + startEpoch: 100, + endEpoch: 200, + missedEpochs: []uint64{100, 103, 105}, + // bits 0, 3, and 5 set -> 1 + 8 + 32 = 41 + want: []*big.Int{big.NewInt(41)}, + }, + { + name: "word boundary: offsets 255 and 256", + startEpoch: 1000, + endEpoch: 1511, // 512 epochs -> 2 words + missedEpochs: []uint64{1255, 1256}, + want: []*big.Int{ + new(big.Int).Lsh(big.NewInt(1), 255), // bit 255 of word 0 + big.NewInt(1), // bit 0 of word 1 + }, + }, + { + name: "range not a multiple of the word size", + startEpoch: 0, + endEpoch: 256, // 257 epochs -> 2 words + missedEpochs: []uint64{256}, + want: []*big.Int{big.NewInt(0), big.NewInt(1)}, + }, + { + name: "epochs outside the range are ignored", + startEpoch: 100, + endEpoch: 200, + missedEpochs: []uint64{99, 150, 201}, + want: []*big.Int{new(big.Int).Lsh(big.NewInt(1), 50)}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := EncodeParticipationBitset(tc.startEpoch, tc.endEpoch, tc.missedEpochs) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("EncodeParticipationBitset(%d, %d, %v) = %v, want %v", + tc.startEpoch, tc.endEpoch, tc.missedEpochs, got, tc.want) + } + }) + } +} diff --git a/shared/services/rocketpool/megapool.go b/shared/services/rocketpool/megapool.go index 98af2bd41..2ef1769f2 100644 --- a/shared/services/rocketpool/megapool.go +++ b/shared/services/rocketpool/megapool.go @@ -1,16 +1,110 @@ package rocketpool import ( + "context" "fmt" "math/big" "net/url" "strconv" + "strings" "github.com/ethereum/go-ethereum/common" + "github.com/goccy/go-json" "github.com/rocket-pool/smartnode/shared/types/api" ) +// VerifyMegapoolValidatorPerformance computes RPIP-73 target-vote performance +// over [startEpoch, endEpoch] for one or more validators of a megapool. If +// megapoolAddress is the zero address the daemon resolves the node's own +// megapool. targets is either the literal "all" or a comma-separated list of +// validator IDs. This call has no client-side deadline because each epoch +// requires fetching attestation data that can take a while per epoch on an +// archival beacon node. +func (c *Client) VerifyMegapoolValidatorPerformance(megapoolAddress common.Address, targets string, startEpoch, endEpoch uint64) (api.VerifyPerformanceBatchResponse, error) { + values := url.Values{ + "targets": {targets}, + "startEpoch": {strconv.FormatUint(startEpoch, 10)}, + "endEpoch": {strconv.FormatUint(endEpoch, 10)}, + } + if (megapoolAddress != common.Address{}) { + values.Set("megapoolAddress", megapoolAddress.Hex()) + } + responseBytes, err := c.callHTTPAPICtx(context.Background(), "GET", "/api/megapool/verify-performance", values) + if err != nil { + return api.VerifyPerformanceBatchResponse{}, fmt.Errorf("Could not verify megapool validator performance: %w", err) + } + var response api.VerifyPerformanceBatchResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return api.VerifyPerformanceBatchResponse{}, fmt.Errorf("Could not decode verify-performance response: %w", err) + } + if response.Error != "" { + return api.VerifyPerformanceBatchResponse{}, fmt.Errorf("Could not verify megapool validator performance: %s", response.Error) + } + return response, nil +} + +// 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 { + words := make([]string, len(participation)) + for i, word := range participation { + words[i] = word.String() + } + values := url.Values{ + "validatorId": {strconv.FormatUint(uint64(validatorId), 10)}, + "startEpoch": {strconv.FormatUint(startEpoch, 10)}, + "participation": {strings.Join(words, ",")}, + } + if (megapoolAddress != common.Address{}) { + values.Set("megapoolAddress", megapoolAddress.Hex()) + } + return values +} + +// CanChallengeMegapoolPerformance checks whether the node can challenge the +// target-vote performance of a megapool validator, returning the +// challenge bond, the node's RPL balance, and the gas estimate. If +// megapoolAddress is the zero address the daemon resolves the node's own +// megapool. This call has no client-side deadline because the gas estimate +// requires downloading a beacon state to build the slot proof. +func (c *Client) CanChallengeMegapoolPerformance(megapoolAddress common.Address, 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 +} + +// ChallengeMegapoolPerformance submits a target-vote performance challenge +// against a megapool validator. This call has no client-side +// deadline because the transaction requires downloading a beacon state to +// build the slot proof. +func (c *Client) ChallengeMegapoolPerformance(megapoolAddress common.Address, 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 +} + // Get megapool status func (c *Client) MegapoolStatus(finalizedState bool) (api.MegapoolStatusResponse, error) { finalizedStr := "false" diff --git a/shared/services/rocketpool/minipool.go b/shared/services/rocketpool/minipool.go index 9c3434b1a..3c58fc4d8 100644 --- a/shared/services/rocketpool/minipool.go +++ b/shared/services/rocketpool/minipool.go @@ -1,11 +1,14 @@ package rocketpool import ( + "context" + "fmt" "math/big" "net/url" "strconv" "github.com/ethereum/go-ethereum/common" + "github.com/goccy/go-json" "github.com/rocket-pool/smartnode/shared/types/api" ) @@ -170,6 +173,30 @@ func (c *Client) GetMinipoolRescueDissolvedDetailsForNode() (api.GetMinipoolResc return c.callAPI[api.GetMinipoolRescueDissolvedDetailsForNodeResponse]("GET", "/api/minipool/get-rescue-dissolved-details-for-node", nil, "Could not get get-minipool-rescue-dissolved-details-for-node status") } +// VerifyMinipoolPerformance computes RPIP-73 target-vote performance over +// [startEpoch, endEpoch] for one or more of the node's minipools. targets is +// either the literal "all" or a comma-separated list of minipool addresses. +// This call has no client-side deadline because each epoch requires fetching +// attestation data that can take a while per epoch on an archival beacon node. +func (c *Client) VerifyMinipoolPerformance(targets string, startEpoch, endEpoch uint64) (api.VerifyPerformanceBatchResponse, error) { + responseBytes, err := c.callHTTPAPICtx(context.Background(), "GET", "/api/minipool/verify-performance", url.Values{ + "targets": {targets}, + "startEpoch": {strconv.FormatUint(startEpoch, 10)}, + "endEpoch": {strconv.FormatUint(endEpoch, 10)}, + }) + if err != nil { + return api.VerifyPerformanceBatchResponse{}, fmt.Errorf("Could not verify minipool performance: %w", err) + } + var response api.VerifyPerformanceBatchResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return api.VerifyPerformanceBatchResponse{}, fmt.Errorf("Could not decode verify-performance response: %w", err) + } + if response.Error != "" { + return api.VerifyPerformanceBatchResponse{}, fmt.Errorf("Could not verify minipool performance: %s", response.Error) + } + return response, nil +} + // Rescue a dissolved minipool by depositing ETH for it to the Beacon deposit contract func (c *Client) RescueDissolvedMinipool(address common.Address, amount *big.Int, submit bool) (api.RescueDissolvedMinipoolResponse, error) { submitStr := "false" diff --git a/shared/services/state/network-state.go b/shared/services/state/network-state.go index 7dc2b70b9..a4b844e56 100644 --- a/shared/services/state/network-state.go +++ b/shared/services/state/network-state.go @@ -103,6 +103,9 @@ type NetworkState struct { // Protocol DAO proposals ProtocolDaoProposalDetails []protocol.ProtocolDaoProposalDetails `json:"protocol_dao_proposal_details,omitempty"` + + // Saturn 2 deployed + Saturn2Deployed bool `json:"saturn2_deployed"` } func (s NetworkState) MarshalJSON() ([]byte, error) { @@ -523,6 +526,12 @@ func (m *NetworkStateManager) createNetworkState(slotNumber uint64, nodeAddresse currentStep++ m.logLine("%d/%d - Retrieved Protocol DAO proposals (total time: %s)", currentStep, steps, time.Since(start)) + // Check if Saturn 2 is deployed + state.Saturn2Deployed, err = IsSaturn2Deployed(m.rp, opts) + if err != nil { + return nil, fmt.Errorf("error checking if Saturn 2 is deployed: %w", err) + } + return state, state.Validate() } diff --git a/shared/services/state/static_bc.go b/shared/services/state/static_bc.go index 1a3bdfbb7..65eefef5a 100644 --- a/shared/services/state/static_bc.go +++ b/shared/services/state/static_bc.go @@ -210,6 +210,10 @@ func (c *StaticBeaconClient) GetCommitteesForEpoch(_ *uint64) (beacon.Committees return nil, ErrStaticMode } +func (c *StaticBeaconClient) GetHistoricalCommitteesForEpoch(_ uint64) (beacon.Committees, error) { + return nil, ErrStaticMode +} + func (c *StaticBeaconClient) ChangeWithdrawalCredentials(_ string, _ types.ValidatorPubkey, _ common.Address, _ types.ValidatorSignature) error { return ErrStaticMode } diff --git a/shared/services/state/update-checks.go b/shared/services/state/update-checks.go new file mode 100644 index 000000000..4f51e578c --- /dev/null +++ b/shared/services/state/update-checks.go @@ -0,0 +1,19 @@ +package state + +import ( + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/hashicorp/go-version" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/utils" +) + +// Check if Saturn 2 has been deployed +func IsSaturn2Deployed(rp *rocketpool.RocketPool, opts *bind.CallOpts) (bool, error) { + currentVersion, err := utils.GetCurrentVersion(rp, opts) + if err != nil { + return false, err + } + + constraint, _ := version.NewConstraint(">= 1.5.0") + return constraint.Check(currentVersion), nil +} diff --git a/shared/types/api/minipool.go b/shared/types/api/minipool.go index c6ccbcde2..d296c6b2c 100644 --- a/shared/types/api/minipool.go +++ b/shared/types/api/minipool.go @@ -301,3 +301,51 @@ type GetBondReductionEnabledResponse struct { APIResponse BondReductionEnabled bool `json:"bondReductionEnabled"` } + +// VerifyPerformanceResponse reports the result of an RPIP-73 target-vote +// performance check for a single validator over a range of epochs. +type VerifyPerformanceResponse struct { + Status string `json:"status"` + Error string `json:"error"` + ValidatorPubkey types.ValidatorPubkey `json:"validatorPubkey"` + ValidatorIndex uint64 `json:"validatorIndex"` + StartEpoch uint64 `json:"startEpoch"` + EndEpoch uint64 `json:"endEpoch"` + TotalEpochs uint64 `json:"totalEpochs"` + TimelyEpochs uint64 `json:"timelyEpochs"` + MissedEpochs uint64 `json:"missedEpochs"` + InactiveEpochs uint64 `json:"inactiveEpochs"` + PerformancePct float64 `json:"performancePct"` + PerformanceThresholdPct float64 `json:"performanceThresholdPct"` + PassesThreshold bool `json:"passesThreshold"` + MissedEpochList []uint64 `json:"missedEpochList"` + TimelyEpochList []uint64 `json:"timelyEpochList"` + // Participation is the challengeMegapool participation calldata (uint256[]): + // the epochs [StartEpoch, EndEpoch] as a bitset, 1 = not-timely target vote. + Participation []*big.Int `json:"participation"` + // Challengeable reports whether this check could back an on-chain challenge: + // performance exits are enabled, the range is exactly one performance period, + // it is recent enough to be within the proof buffer, and the validator's + // missed share of the period exceeds 100% - performance_threshold. + Challengeable bool `json:"challengeable"` +} + +// VerifyPerformanceResult is one validator's entry in a batch verify-performance +// response. Either MinipoolAddress (for minipools) or ValidatorId (for megapool +// validators) identifies the target. Performance holds the result when the +// check succeeded; Error is populated instead when that single target failed so +// one bad target does not abort the rest of the batch. +type VerifyPerformanceResult struct { + MinipoolAddress common.Address `json:"minipoolAddress"` + ValidatorId uint32 `json:"validatorId"` + Performance *VerifyPerformanceResponse `json:"performance"` + Error string `json:"error"` +} + +// VerifyPerformanceBatchResponse reports RPIP-73 target-vote performance for one +// or more validators verified in a single run. +type VerifyPerformanceBatchResponse struct { + Status string `json:"status"` + Error string `json:"error"` + Results []VerifyPerformanceResult `json:"results"` +} diff --git a/shared/types/api/node.go b/shared/types/api/node.go index 218accdd0..74b36ed08 100644 --- a/shared/types/api/node.go +++ b/shared/types/api/node.go @@ -749,6 +749,21 @@ type NotifyValidatorExitResponse struct { TxHash common.Hash `json:"txHash"` } +type CanChallengeMegapoolPerformanceResponse struct { + Status string `json:"status"` + Error string `json:"error"` + CanChallenge bool `json:"canChallenge"` + InsufficientRplBalance bool `json:"insufficientRplBalance"` + ChallengeBond *big.Int `json:"challengeBond"` + RplBalance *big.Int `json:"rplBalance"` + GasLimits gaslimit.Limits `json:"gasLimits"` +} +type ChallengeMegapoolPerformanceResponse struct { + Status string `json:"status"` + Error string `json:"error"` + TxHash common.Hash `json:"txHash"` +} + type CanNotifyFinalBalanceResponse struct { APIResponse CanExit bool `json:"canExit"` diff --git a/shared/types/api/pdao.go b/shared/types/api/pdao.go index 075418eeb..2d362270d 100644 --- a/shared/types/api/pdao.go +++ b/shared/types/api/pdao.go @@ -69,7 +69,8 @@ type ExecutePDAOProposalResponse struct { type GetPDAOSettingsResponse struct { APIResponse - Auction struct { + Saturn2Deployed bool `json:"saturn2Deployed"` + Auction struct { IsCreateLotEnabled bool `json:"isCreateLotEnabled"` IsBidOnLotEnabled bool `json:"isBidOnLotEnabled"` LotMinimumEthValue *big.Int `json:"lotMinimumEthValue"` @@ -178,7 +179,24 @@ type GetPDAOSettingsResponse struct { UserDistributeDelay uint64 `json:"userDistributeDelay"` UserDistributeDelayWithShortfall uint64 `json:"userDistributeDelayWithShortfall"` PenaltyThreshold *big.Int `json:"penaltyThreshold"` + PrestakeChallengePeriod time.Duration `json:"prestakeChallengePeriod"` } `json:"megapool"` + + Performance struct { + ExitsEnabled bool `json:"exitsEnabled"` + Period uint64 `json:"period"` + ProofBuffer uint64 `json:"proofBuffer"` + Threshold *big.Int `json:"threshold"` + ChallengePeriod time.Duration `json:"challengePeriod"` + ChallengeBond *big.Int `json:"challengeBond"` + } `json:"performance"` + + Exit struct { + CooperativeExitPhase time.Duration `json:"cooperativeExitPhase"` + DidNotExitPenaltyBase *big.Int `json:"didNotExitPenaltyBase"` + DidNotExitBase time.Duration `json:"didNotExitBase"` + DidNotExitBackoff *big.Int `json:"didNotExitBackoff"` + } `json:"exit"` } type CanProposePDAOSettingResponse struct { diff --git a/shared/types/eth2/fork/deneb/state_deneb.go b/shared/types/eth2/fork/deneb/state_deneb.go index 48c9e34b2..0dbd6bfe6 100644 --- a/shared/types/eth2/fork/deneb/state_deneb.go +++ b/shared/types/eth2/fork/deneb/state_deneb.go @@ -243,3 +243,7 @@ func (state *BeaconState) GetValidators() []*generic.Validator { func (state *BeaconState) GetSlot() uint64 { return state.Slot } + +func (state *BeaconState) GetPreviousEpochParticipation() []byte { + return state.PreviousEpochParticipation +} diff --git a/shared/types/eth2/fork/electra/state_electra.go b/shared/types/eth2/fork/electra/state_electra.go index d711cef65..08ad0c458 100644 --- a/shared/types/eth2/fork/electra/state_electra.go +++ b/shared/types/eth2/fork/electra/state_electra.go @@ -279,7 +279,84 @@ func (state *BeaconState) GetSlot() uint64 { return state.Slot } +func (state *BeaconState) GetPreviousEpochParticipation() []byte { + return state.PreviousEpochParticipation +} + // Added for compatibility func (state *BeaconState) BlockHeaderProof() ([][]byte, error) { return nil, nil } + +func (state *BeaconState) PreviousEpochParticipationChunkProof(validatorIndex uint64) ([32]byte, [][]byte, error) { + return [32]byte{}, nil, fmt.Errorf("participation proofs are not supported for electra states") +} + +// HistoricalSummaryStateRootProof proves that the state root of the given +// slot is part of the HistoricalSummary covering its era, using this state's +// state_roots vector. The state must be aligned at the end of the 8192 slot +// era containing slot, so its state_roots vector is the one summarised by +// historical_summaries[slot / 8192]. +func (state *BeaconState) HistoricalSummaryStateRootProof(slot int) ([][]byte, error) { + // If the state isn't aligned at the end of an 8192 slot era, throw an error + if state.Slot%generic.SlotsPerHistoricalRoot != 0 { + return nil, fmt.Errorf("state is not aligned at the end of an 8192 slot era") + } + + hsls := generic.HistoricalSummaryLists{ + BlockRoots: state.BlockRoots, + StateRoots: state.StateRoots, + } + + idx := slot % int(generic.SlotsPerHistoricalRoot) + tree, err := generic.SSZ.GetTree(&hsls) + if err != nil { + return nil, fmt.Errorf("could not get historical summary lists tree: %w", err) + } + + gid := uint64(1) + gid = gid*2 + 1 // Now at state_roots + gid = gid * generic.SlotsPerHistoricalRoot // Now at the first state_root + gid = gid + uint64(idx) // Now at the correct state_root + + proof, err := tree.Prove(int(gid)) + if err != nil { + return nil, fmt.Errorf("could not get proof for historical summary: %w", err) + } + + return proof.Hashes, nil +} + +// StateRootProof proves the state root of a recent past slot from this +// state's state_roots vector, up to this state's root (no block-header cap) +func (state *BeaconState) StateRootProof(slot uint64) ([][]byte, error) { + if slot >= state.Slot { + return nil, fmt.Errorf("slot %d is not in the past of the state at slot %d", slot, state.Slot) + } + // Note: a distance of exactly SlotsPerHistoricalRoot is still recent - + // state_roots[slot % 8192] holds the root of slot (state.Slot - 8192) + if slot+generic.SlotsPerHistoricalRoot < state.Slot { + return nil, fmt.Errorf("slot %d is more than %d slots in the past from the state at slot %d, you must build a proof from the historical_summaries instead", slot, generic.SlotsPerHistoricalRoot, state.Slot) + } + + tree, err := generic.SSZ.GetTree(state) + if err != nil { + return nil, fmt.Errorf("could not get state tree: %w", err) + } + + gid := uint64(1) + + // Navigate to the state_roots + gid = gid*beaconStateChunkCeil + generic.BeaconStateStateRootsFieldIndex + + // We're now at the state_roots vector, which is the root of a slotsPerHistoricalRoot slots vector. + // The index we care about is given by slot % slotsPerHistoricalRoot. + gid = gid*generic.BeaconStateBlockRootsMaxLength + (slot % generic.SlotsPerHistoricalRoot) + + proof, err := tree.Prove(int(gid)) + if err != nil { + return nil, fmt.Errorf("could not get proof for state root: %w", err) + } + + return proof.Hashes, nil +} diff --git a/shared/types/eth2/fork/fulu/state_fulu.go b/shared/types/eth2/fork/fulu/state_fulu.go index d28338ba3..e8b0a70e0 100644 --- a/shared/types/eth2/fork/fulu/state_fulu.go +++ b/shared/types/eth2/fork/fulu/state_fulu.go @@ -88,6 +88,46 @@ func GetGeneralizedIndexForNextWithdrawalIndex() uint64 { return generic.ContainerFieldGindex(getStateChunkSize(), generic.BeaconStateNextWithdrawalIndexFieldIndex) } +func GetGeneralizedIndexForPreviousEpochParticipation() uint64 { + return generic.ContainerFieldGindex(getStateChunkSize(), generic.BeaconStatePreviousEpochParticipationFieldIndex) +} + +// PreviousEpochParticipationChunkProof proves the previous_epoch_participation +// chunk containing validatorIndex's participation flags up to the state root +// (no block-header cap; the proof is anchored via a separate state root proof +// from a more recent state). chunk is the 32-byte merkle leaf holding the +// flags of validators [chunkIndex*32, chunkIndex*32+31], with the validator's +// own flags at byte validatorIndex % 32. +func (state *BeaconState) PreviousEpochParticipationChunkProof(validatorIndex uint64) ([32]byte, [][]byte, error) { + if validatorIndex >= uint64(len(state.PreviousEpochParticipation)) { + return [32]byte{}, nil, errors.New("validator index out of bounds of the previous epoch participation list") + } + + // Pack the expected leaf chunk locally: 32 participation flag bytes, + // zero-padded at the tail of the list. + chunkIndex := validatorIndex / 32 + var chunk [32]byte + copy(chunk[:], state.PreviousEpochParticipation[chunkIndex*32:]) + + stateTree, err := generic.SSZ.GetTree(state) + if err != nil { + return [32]byte{}, nil, fmt.Errorf("could not get state tree: %w", err) + } + + chunkGid := generic.GetGeneralizedIndexForParticipationChunk(chunkIndex, GetGeneralizedIndexForPreviousEpochParticipation()) + participationStateProof, err := stateTree.Prove(int(chunkGid)) + if err != nil { + return [32]byte{}, nil, fmt.Errorf("could not get proof for participation chunk: %w", err) + } + + // Sanity check that the proof leaf matches the locally packed chunk + if !bytes.Equal(participationStateProof.Leaf, chunk[:]) { + return [32]byte{}, nil, fmt.Errorf("proof leaf does not match expected participation chunk") + } + + return chunk, participationStateProof.Hashes, nil +} + // ValidatorAndSlotProof produces both the validator proof and the slot proof // for the state's current slot func (state *BeaconState) ValidatorAndSlotProof(validatorIndex uint64) ([][]byte, [][]byte, error) { @@ -225,6 +265,75 @@ func (state *BeaconState) HistoricalSummaryBlockRootProof(slot int) ([][]byte, e return proof.Hashes, nil } +// HistoricalSummaryStateRootProof proves that the state root of the given +// slot is part of the HistoricalSummary covering its era, using this state's +// state_roots vector. The state must be aligned at the end of the 8192 slot +// era containing slot, so its state_roots vector is the one summarised by +// historical_summaries[slot / 8192]. +func (state *BeaconState) HistoricalSummaryStateRootProof(slot int) ([][]byte, error) { + // If the state isn't aligned at the end of an 8192 slot era, throw an error + if state.Slot%generic.SlotsPerHistoricalRoot != 0 { + return nil, fmt.Errorf("state is not aligned at the end of an 8192 slot era") + } + + hsls := generic.HistoricalSummaryLists{ + BlockRoots: state.BlockRoots, + StateRoots: state.StateRoots, + } + + idx := slot % int(generic.SlotsPerHistoricalRoot) + tree, err := generic.SSZ.GetTree(&hsls) + if err != nil { + return nil, fmt.Errorf("could not get historical summary lists tree: %w", err) + } + + gid := uint64(1) + gid = gid*2 + 1 // Now at state_roots + gid = gid * generic.SlotsPerHistoricalRoot // Now at the first state_root + gid = gid + uint64(idx) // Now at the correct state_root + + proof, err := tree.Prove(int(gid)) + if err != nil { + return nil, fmt.Errorf("could not get proof for historical summary: %w", err) + } + + return proof.Hashes, nil +} + +// StateRootProof proves the state root of a recent past slot from this +// state's state_roots vector, up to this state's root (no block-header cap). +func (state *BeaconState) StateRootProof(slot uint64) ([][]byte, error) { + if slot >= state.Slot { + return nil, fmt.Errorf("slot %d is not in the past of the state at slot %d", slot, state.Slot) + } + // Note: a distance of exactly SlotsPerHistoricalRoot is still recent - + // state_roots[slot % 8192] holds the root of slot (state.Slot - 8192) + if slot+generic.SlotsPerHistoricalRoot < state.Slot { + return nil, fmt.Errorf("slot %d is more than %d slots in the past from the state at slot %d, you must build a proof from the historical_summaries instead", slot, generic.SlotsPerHistoricalRoot, state.Slot) + } + + tree, err := generic.SSZ.GetTree(state) + if err != nil { + return nil, fmt.Errorf("could not get state tree: %w", err) + } + + gid := uint64(1) + + // Navigate to the state_roots + gid = gid*beaconStateChunkCeil + generic.BeaconStateStateRootsFieldIndex + + // We're now at the state_roots vector, which is the root of a slotsPerHistoricalRoot slots vector. + // The index we care about is given by slot % slotsPerHistoricalRoot. + gid = gid*generic.BeaconStateBlockRootsMaxLength + (slot % generic.SlotsPerHistoricalRoot) + + proof, err := tree.Prove(int(gid)) + if err != nil { + return nil, fmt.Errorf("could not get proof for state root: %w", err) + } + + return proof.Hashes, nil +} + func (state *BeaconState) BlockRootProof(slot uint64) ([][]byte, error) { if generic.IsHistoricalProof(state.Slot, slot) { return nil, fmt.Errorf("slot %d is more than %d slots in the past from the state at slot %d, you must build a proof from the historical_summaries instead", slot, generic.SlotsPerHistoricalRoot, state.Slot) @@ -297,3 +406,7 @@ func (state *BeaconState) GetValidators() []*generic.Validator { func (state *BeaconState) GetSlot() uint64 { return state.Slot } + +func (state *BeaconState) GetPreviousEpochParticipation() []byte { + return state.PreviousEpochParticipation +} diff --git a/shared/types/eth2/fork/gloas/gloas_proofs_test.go b/shared/types/eth2/fork/gloas/gloas_proofs_test.go index 5dbd8bbe4..0dad405e9 100644 --- a/shared/types/eth2/fork/gloas/gloas_proofs_test.go +++ b/shared/types/eth2/fork/gloas/gloas_proofs_test.go @@ -22,6 +22,11 @@ func TestGloasFieldGindices(t *testing.T) { {"historical_summaries", GetGeneralizedIndexForHistoricalSummaries(), 2950}, {"validators[0]", GetGeneralizedIndexForValidator(0), 1432}, {"validators[1]", GetGeneralizedIndexForValidator(1), 11464}, + {"previous_epoch_participation", GetGeneralizedIndexForPreviousEpochParticipation(), 362}, + {"participation chunk 0", GetGeneralizedIndexForParticipationChunk(0), 1448}, + {"participation chunk 1", GetGeneralizedIndexForParticipationChunk(1), 11592}, + {"participation chunk 4", GetGeneralizedIndexForParticipationChunk(4), 11595}, + {"participation chunk 5", GetGeneralizedIndexForParticipationChunk(5), 92768}, } for _, tc := range cases { if tc.got != tc.want { @@ -442,3 +447,127 @@ func TestHistoricalBoundarySplit(t *testing.T) { t.Fatalf("W-1 at distance 8193 should use the historical path") } } + +func TestPreviousEpochParticipationChunkProofProgressive(t *testing.T) { + state := minimalBeaconState() + + // 180 flag bytes span packed chunks 0-5: chunks 0-4 fill progressive + // levels 0 and 1, chunk 5 is the first (partial) chunk of level 2. + const numValidators = 180 + state.PreviousEpochParticipation = make([]byte, numValidators) + for i := range state.PreviousEpochParticipation { + state.PreviousEpochParticipation[i] = byte(i % 8) + } + + tree, err := generic.SSZ.GetTree(state) + if err != nil { + t.Fatalf("GetTree: %v", err) + } + + cases := []struct { + validatorIndex uint64 + wantDepth int + }{ + {0, 10}, // chunk 0 (progressive level 0) + {31, 10}, // chunk 0, last byte + {32, 13}, // chunk 1, first chunk of level 1 + {70, 13}, // chunk 2, mid level 1 + {127, 13}, // chunk 3, last byte + {128, 13}, // chunk 4, last chunk of level 1 + {160, 16}, // chunk 5, first chunk of level 2 + {179, 16}, // chunk 5, tail of the partial chunk + } + for _, tc := range cases { + chunk, hashes, err := state.PreviousEpochParticipationChunkProof(tc.validatorIndex) + if err != nil { + t.Fatalf("chunk proof for validator %d: %v", tc.validatorIndex, err) + } + if got, want := chunk[tc.validatorIndex%32], state.PreviousEpochParticipation[tc.validatorIndex]; got != want { + t.Fatalf("validator %d: chunk byte %d != flags %d", tc.validatorIndex, got, want) + } + if len(hashes) != tc.wantDepth { + t.Fatalf("validator %d: proof depth %d, want %d", tc.validatorIndex, len(hashes), tc.wantDepth) + } + + gid := GetGeneralizedIndexForParticipationChunk(tc.validatorIndex / 32) + direct, err := tree.Prove(int(gid)) + if err != nil { + t.Fatalf("direct Prove gid %d: %v", gid, err) + } + if !bytes.Equal(direct.Leaf, chunk[:]) { + t.Fatalf("validator %d: direct leaf %x != chunk %x", tc.validatorIndex, direct.Leaf, chunk) + } + if ok, err := treeproof.VerifyProof(tree.Hash(), direct); err != nil || !ok { + t.Fatalf("validator %d: verify participation chunk: ok=%v err=%v", tc.validatorIndex, ok, err) + } + if len(hashes) != len(direct.Hashes) { + t.Fatalf("validator %d: helper proof length %d != direct %d", tc.validatorIndex, len(hashes), len(direct.Hashes)) + } + for i := range hashes { + if !bytes.Equal(hashes[i], direct.Hashes[i]) { + t.Fatalf("validator %d: proof hash[%d] mismatch", tc.validatorIndex, i) + } + } + } + + // Out-of-bounds index must error. + if _, _, err := state.PreviousEpochParticipationChunkProof(numValidators); err == nil { + t.Fatalf("expected out-of-bounds error") + } + + // An empty participation list must reject every index. + empty := minimalBeaconState() + if _, _, err := empty.PreviousEpochParticipationChunkProof(0); err == nil { + t.Fatalf("expected error for empty participation list") + } +} + +func TestPreviousEpochParticipationChunkProofLargeIndex(t *testing.T) { + state := minimalBeaconState() + + // A registry of over a million validators: index 1,048,600 sits in packed + // chunk 32768, which lives in progressive level 8 of the list, so the + // proof must be 10 + 3*8 = 34 hashes deep. + const validatorIndex = uint64(1_048_600) + const numValidators = validatorIndex + 50 + state.PreviousEpochParticipation = make([]byte, numValidators) + for i := range state.PreviousEpochParticipation { + state.PreviousEpochParticipation[i] = byte(i % 8) + } + + tree, err := generic.SSZ.GetTree(state) + if err != nil { + t.Fatalf("GetTree: %v", err) + } + + chunk, hashes, err := state.PreviousEpochParticipationChunkProof(validatorIndex) + if err != nil { + t.Fatalf("chunk proof for validator %d: %v", validatorIndex, err) + } + if got, want := chunk[validatorIndex%32], state.PreviousEpochParticipation[validatorIndex]; got != want { + t.Fatalf("chunk byte %d != flags %d", got, want) + } + if len(hashes) != 34 { + t.Fatalf("proof depth %d, want 34", len(hashes)) + } + + gid := GetGeneralizedIndexForParticipationChunk(validatorIndex / 32) + direct, err := tree.Prove(int(gid)) + if err != nil { + t.Fatalf("direct Prove gid %d: %v", gid, err) + } + if !bytes.Equal(direct.Leaf, chunk[:]) { + t.Fatalf("direct leaf %x != chunk %x", direct.Leaf, chunk) + } + if ok, err := treeproof.VerifyProof(tree.Hash(), direct); err != nil || !ok { + t.Fatalf("verify participation chunk: ok=%v err=%v", ok, err) + } + if len(hashes) != len(direct.Hashes) { + t.Fatalf("helper proof length %d != direct %d", len(hashes), len(direct.Hashes)) + } + for i := range hashes { + if !bytes.Equal(hashes[i], direct.Hashes[i]) { + t.Fatalf("proof hash[%d] mismatch", i) + } + } +} diff --git a/shared/types/eth2/fork/gloas/state_gloas.go b/shared/types/eth2/fork/gloas/state_gloas.go index 621af530d..8233917a1 100644 --- a/shared/types/eth2/fork/gloas/state_gloas.go +++ b/shared/types/eth2/fork/gloas/state_gloas.go @@ -20,6 +20,8 @@ const ( beaconStateBalancesFieldIndex = generic.BeaconStateBalancesFieldIndex // 12 beaconStateNextWithdrawalIndexFieldIndex = generic.BeaconStateNextWithdrawalIndexFieldIndex // 25 beaconStateHistoricalSummariesFieldIndex = generic.BeaconStateHistoricalSummariesFieldIndex // 27 + + beaconStatePreviousEpochParticipationFieldIndex = generic.BeaconStatePreviousEpochParticipationFieldIndex // 15 // New in Gloas (EIP-7732): payload_expected_withdrawals commits to the // withdrawals of the execution payload bid on at this slot. beaconStatePayloadExpectedWithdrawalsFieldIndex = 44 @@ -188,6 +190,23 @@ func GetGeneralizedIndexForBalanceChunk(validatorIndex uint64) uint64 { ) } +// GetGeneralizedIndexForPreviousEpochParticipation returns the gindex of the +// previous_epoch_participation field root inside the Gloas ProgressiveContainer BeaconState. +func GetGeneralizedIndexForPreviousEpochParticipation() uint64 { + return generic.ProgressiveContainerFieldGindex(beaconStatePreviousEpochParticipationFieldIndex) +} + +// GetGeneralizedIndexForParticipationChunk returns the gindex of packed 32-byte +// chunk chunkIndex of previous_epoch_participation (ProgressiveContainer field +// + ProgressiveList packed chunk). The list holds basic uint8 elements, so the +// leaves are the packed chunks, not the individual bytes. +func GetGeneralizedIndexForParticipationChunk(chunkIndex uint64) uint64 { + return generic.GetGeneralizedIndexForProgressiveListElement( + GetGeneralizedIndexForPreviousEpochParticipation(), + chunkIndex, + ) +} + // ValidatorAndSlotProof produces both the validator proof and the slot proof // for the state's current slot, using EIP-7688 progressive g-indices. func (state *BeaconState) ValidatorAndSlotProof(validatorIndex uint64) ([][]byte, [][]byte, error) { @@ -533,3 +552,40 @@ func (state *BeaconState) GetSlot() uint64 { func (state *BeaconState) GetPreviousEpochParticipation() []byte { return state.PreviousEpochParticipation } + +// PreviousEpochParticipationChunkProof proves the previous_epoch_participation +// chunk containing validatorIndex's participation flags up to the state root +// chunk is the 32-byte merkle leaf holding the flags of validators +// [chunkIndex*32, chunkIndex*32+31], with the validator's own flags at byte +// validatorIndex % 32. Unlike pre-Gloas forks, the branch length is variable: +// the list merkleizes progressively (EIP-7916), so the proof grows by 3 hashes +// per progressive level of the chunk index. +func (state *BeaconState) PreviousEpochParticipationChunkProof(validatorIndex uint64) ([32]byte, [][]byte, error) { + if validatorIndex >= uint64(len(state.PreviousEpochParticipation)) { + return [32]byte{}, nil, errors.New("validator index out of bounds of the previous epoch participation list") + } + + // Pack the expected leaf chunk locally: 32 participation flag bytes, + // zero-padded at the tail of the list. + chunkIndex := validatorIndex / 32 + var chunk [32]byte + copy(chunk[:], state.PreviousEpochParticipation[chunkIndex*32:]) + + stateTree, err := generic.SSZ.GetTree(state) + if err != nil { + return [32]byte{}, nil, fmt.Errorf("could not get state tree: %w", err) + } + + chunkGid := GetGeneralizedIndexForParticipationChunk(chunkIndex) + participationStateProof, err := stateTree.Prove(int(chunkGid)) + if err != nil { + return [32]byte{}, nil, fmt.Errorf("could not get proof for participation chunk: %w", err) + } + + // Sanity check that the proof leaf matches the locally packed chunk + if !bytes.Equal(participationStateProof.Leaf, chunk[:]) { + return [32]byte{}, nil, fmt.Errorf("proof leaf does not match expected participation chunk") + } + + return chunk, participationStateProof.Hashes, nil +} diff --git a/shared/types/eth2/generic/state.go b/shared/types/eth2/generic/state.go index 2e9cc82a8..062989e47 100644 --- a/shared/types/eth2/generic/state.go +++ b/shared/types/eth2/generic/state.go @@ -21,8 +21,27 @@ const BeaconStateHistoricalSummariesMaxLength uint64 = 1 << 24 const BeaconStateBlockRootsMaxLength uint64 = 1 << 13 const BeaconStateBlockRootsFieldIndex uint64 = 5 const BeaconStateStateRootsMaxLength uint64 = 1 << 13 + +// BeaconStateStateRootsFieldIndex is the field offset of the StateRoots field in the BeaconState struct const BeaconStateStateRootsFieldIndex uint64 = 6 +// BeaconStatePreviousEpochParticipationFieldIndex is the field offset of the +// PreviousEpochParticipation field in the BeaconState struct +const BeaconStatePreviousEpochParticipationFieldIndex uint64 = 15 + +// BeaconStateParticipationMaxChunks is the chunk count of the epoch +// participation byte lists: VALIDATOR_REGISTRY_LIMIT (2^40) one-byte flags +// packed 32 per chunk. +const BeaconStateParticipationMaxChunks uint64 = 1 << 35 + +// GetGeneralizedIndexForParticipationChunk returns the generalized index of +// the 32-byte chunk of a participation byte list, starting from the +// generalized index of the list field itself. +func GetGeneralizedIndexForParticipationChunk(chunkIndex uint64, participationFieldGid uint64) uint64 { + // Lists have a base index of 2 (the length mixin occupies the sibling). + return participationFieldGid*2*BeaconStateParticipationMaxChunks + chunkIndex +} + type PendingDeposit struct { Pubkey []byte `json:"pubkey" ssz-size:"48"` WithdrawalCredentials []byte `json:"withdrawal_credentials" ssz-size:"32"` diff --git a/shared/types/eth2/participation_proof_test.go b/shared/types/eth2/participation_proof_test.go new file mode 100644 index 000000000..74caa9ab2 --- /dev/null +++ b/shared/types/eth2/participation_proof_test.go @@ -0,0 +1,276 @@ +package eth2 + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "encoding/json" + "testing" + + hexutils "github.com/rocket-pool/smartnode/shared/hex" + "github.com/rocket-pool/smartnode/shared/types/eth2/fork/fulu" + "github.com/rocket-pool/smartnode/shared/types/eth2/generic" +) + +// newTestFuluState builds a minimal but SSZ-valid fulu beacon state with +// numValidators validators and per-validator previous-epoch participation +// flags of (i % 8). +func newTestFuluState(t *testing.T, numValidators int, slot uint64) *fulu.BeaconState { + t.Helper() + + validators := make([]*generic.Validator, numValidators) + balances := make([]uint64, numValidators) + inactivityScores := make([]uint64, numValidators) + previousParticipation := make([]byte, numValidators) + currentParticipation := make([]byte, numValidators) + for i := range validators { + pubkey := make([]byte, 48) + pubkey[0] = byte(i + 1) + validators[i] = &generic.Validator{ + Pubkey: make([]byte, 48), + WithdrawalCredentials: make([]byte, 32), + EffectiveBalance: 32e9, + } + copy(validators[i].Pubkey, pubkey) + balances[i] = 32e9 + previousParticipation[i] = byte(i % 8) + currentParticipation[i] = byte((i + 1) % 8) + } + + randaoMixes := make([][]byte, 65536) + for i := range randaoMixes { + randaoMixes[i] = make([]byte, 32) + } + + syncCommittee := func() *generic.SyncCommittee { + pubkeys := make([][]byte, 512) + for i := range pubkeys { + pubkeys[i] = make([]byte, 48) + } + return &generic.SyncCommittee{PubKeys: pubkeys} + } + + parentRoot := make([]byte, 32) + parentRoot[0] = 0xaa + bodyRoot := make([]byte, 32) + bodyRoot[0] = 0xbb + + return &fulu.BeaconState{ + GenesisValidatorsRoot: make([]byte, 32), + Slot: slot, + Fork: &generic.Fork{ + PreviousVersion: make([]byte, 4), + CurrentVersion: make([]byte, 4), + }, + LatestBlockHeader: &generic.BeaconBlockHeader{ + Slot: slot, + ProposerIndex: 1, + ParentRoot: parentRoot, + StateRoot: make([]byte, 32), + BodyRoot: bodyRoot, + }, + HistoricalRoots: [][]byte{}, + Eth1Data: &generic.Eth1Data{ + DepositRoot: make([]byte, 32), + BlockHash: make([]byte, 32), + }, + Eth1DataVotes: []*generic.Eth1Data{}, + Validators: validators, + Balances: balances, + RandaoMixes: randaoMixes, + Slashings: make([]uint64, 8192), + PreviousEpochParticipation: previousParticipation, + CurrentEpochParticipation: currentParticipation, + PreviousJustifiedCheckpoint: &generic.Checkpoint{Root: make([]byte, 32)}, + CurrentJustifiedCheckpoint: &generic.Checkpoint{Root: make([]byte, 32)}, + FinalizedCheckpoint: &generic.Checkpoint{Root: make([]byte, 32)}, + InactivityScores: inactivityScores, + CurrentSyncCommittee: syncCommittee(), + NextSyncCommittee: syncCommittee(), + LatestExecutionPayloadHeader: &generic.ExecutionPayloadHeader{}, + HistoricalSummaries: []*generic.HistoricalSummary{}, + ProposerLookahead: make([]uint64, 64), + } +} + +// walkProof walks a merkle branch from leaf to root, consuming the witnesses +// leaf-first, and returns the reconstructed root. Fails if the witness count +// doesn't match the gid depth. +func walkProof(t *testing.T, leaf []byte, proof [][]byte, gid uint64) []byte { + t.Helper() + + currentHash := leaf + for _, proofRow := range proof { + if gid == 1 { + t.Fatalf("too many witnesses for gid depth") + } + neighborIsLeft := gid%2 == 1 + gid /= 2 + currentHash = hash(currentHash, proofRow, neighborIsLeft) + } + if gid != 1 { + t.Fatalf("too few witnesses for gid depth, remaining gid: %d", gid) + } + return currentHash +} + +// validateFuluStateProofToStateRoot walks a state-internal proof from leaf to +// root and checks it lands on the state's hash tree root. Returns the state +// root. +func validateFuluStateProofToStateRoot(t *testing.T, leaf []byte, proof [][]byte, gid uint64, state *fulu.BeaconState) []byte { + t.Helper() + + currentHash := walkProof(t, leaf, proof, gid) + + stateRoot, err := generic.SSZ.HashTreeRoot(state) + if err != nil { + t.Fatalf("Failed to get state root: %v", err) + } + if !bytes.Equal(currentHash, stateRoot[:]) { + t.Fatalf("final hash %x does not match state root %x", currentHash, stateRoot) + } + return stateRoot[:] +} + +func TestPreviousEpochParticipationChunkProof(t *testing.T) { + const numValidators = 100 + const slot = uint64(105002*32 + 31) // last slot of some epoch + state := newTestFuluState(t, numValidators, slot) + + // Validators in different chunks of the participation byte list (32 flag + // bytes per chunk), including the last validator (partially filled chunk). + for _, validatorIndex := range []uint64{0, 31, 32, 70, numValidators - 1} { + chunk, participationBranch, err := state.PreviousEpochParticipationChunkProof(validatorIndex) + if err != nil { + t.Fatalf("PreviousEpochParticipationChunkProof(%d) failed: %v", validatorIndex, err) + } + + // The chunk must hold the validator's flags byte at its in-chunk offset. + chunkOffset := validatorIndex % 32 + if chunk[chunkOffset] != state.PreviousEpochParticipation[validatorIndex] { + t.Fatalf("chunk byte %d = %x, want %x", chunkOffset, chunk[chunkOffset], state.PreviousEpochParticipation[validatorIndex]) + } + + // The participation branch must connect the chunk to the state root. + chunkGid := generic.GetGeneralizedIndexForParticipationChunk(validatorIndex/32, fulu.GetGeneralizedIndexForPreviousEpochParticipation()) + validateFuluStateProofToStateRoot(t, chunk[:], participationBranch, chunkGid, state) + + // The witness count must match the on-chain path length: + // 6 (state fields) + 1 (list length mixin) + 35 (chunk index) = 42 + if len(participationBranch) != 42 { + t.Fatalf("participation branch length = %d, want 42", len(participationBranch)) + } + } + + // Out-of-bounds validator index must error. + if _, _, err := state.PreviousEpochParticipationChunkProof(numValidators); err == nil { + t.Fatalf("expected an error for an out-of-bounds validator index") + } +} + +func TestStateRootProof(t *testing.T) { + const numValidators = 10 + const slot = uint64(105002*32 + 31) + state := newTestFuluState(t, numValidators, slot) + + // Fill state_roots with distinct values + for i := range state.StateRoots { + binary.LittleEndian.PutUint64(state.StateRoots[i][:], uint64(i)+1) + } + + for _, targetSlot := range []uint64{slot - 1, slot - 5000, slot - generic.SlotsPerHistoricalRoot} { + proof, err := state.StateRootProof(targetSlot) + if err != nil { + t.Fatalf("StateRootProof(%d) failed: %v", targetSlot, err) + } + + // The witness count must match the on-chain path length: + // 6 (state fields) + 13 (vector index) = 19 + if len(proof) != 19 { + t.Fatalf("state root proof length = %d, want 19", len(proof)) + } + + idx := targetSlot % generic.SlotsPerHistoricalRoot + gid := (uint64(1)*64+generic.BeaconStateStateRootsFieldIndex)*generic.BeaconStateBlockRootsMaxLength + idx + validateFuluStateProofToStateRoot(t, state.StateRoots[idx][:], proof, gid, state) + } + + // A slot not in the past must error + if _, err := state.StateRootProof(slot); err == nil { + t.Fatalf("expected an error for a slot not in the past") + } + // A slot more than 8192 slots in the past must error + if _, err := state.StateRootProof(slot - generic.SlotsPerHistoricalRoot - 1); err == nil { + t.Fatalf("expected an error for a historical slot") + } +} + +func TestHistoricalSummaryStateRootProof(t *testing.T) { + // Reuse the mainnet fixture for the 8192 slot era that ended at slot + // 11567103; the era boundary state is at slot 11567104 + var roots testRoots + err := json.Unmarshal(testRootsJSON, &roots) + if err != nil { + t.Fatalf("Failed to unmarshal test roots: %v", err) + } + + const eraBoundarySlot = uint64(11567104) + if eraBoundarySlot%generic.SlotsPerHistoricalRoot != 0 { + t.Fatalf("era boundary slot %d is not aligned", eraBoundarySlot) + } + state := newTestFuluState(t, 10, eraBoundarySlot) + for i, blockRoot := range roots.BlockRoots { + blockRootBytes, err := hex.DecodeString(hexutils.RemovePrefix(blockRoot)) + if err != nil { + t.Fatalf("Failed to decode block root: %v", err) + } + copy(state.BlockRoots[i][:], blockRootBytes) + } + for i, stateRoot := range roots.StateRoots { + stateRootBytes, err := hex.DecodeString(hexutils.RemovePrefix(stateRoot)) + if err != nil { + t.Fatalf("Failed to decode state root: %v", err) + } + copy(state.StateRoots[i][:], stateRootBytes) + } + + // A misaligned state must error + misaligned := newTestFuluState(t, 10, eraBoundarySlot+1) + if _, err := misaligned.HistoricalSummaryStateRootProof(int(eraBoundarySlot - 100)); err == nil { + t.Fatalf("expected an error for a misaligned state") + } + + // Prove a state root within the era [11558912, 11567103] + const targetSlot = uint64(11560000) + proof, err := state.HistoricalSummaryStateRootProof(int(targetSlot)) + if err != nil { + t.Fatalf("HistoricalSummaryStateRootProof(%d) failed: %v", targetSlot, err) + } + + // The witness count must match the on-chain path length: + // 13 (vector index) + 1 (state_summary_root vs block_summary_root) = 14 + if len(proof) != 14 { + t.Fatalf("historical summary state root proof length = %d, want 14", len(proof)) + } + + // Walk from state_roots[idx] up to the HistoricalSummary container root + idx := targetSlot % generic.SlotsPerHistoricalRoot + gid := (uint64(1)*2+1)*generic.SlotsPerHistoricalRoot + idx + summaryRoot := walkProof(t, state.StateRoots[idx][:], proof, gid) + + // The HistoricalSummary root equals sha256(block_summary_root ++ + // state_summary_root); the fixture era's summary roots are known mainnet + // values (see TestBlockRootProof) + expectedBlockSummaryRoot, err := hex.DecodeString("9d73b29c6e80e8300cedfa9e53aff89523affb98f3bd3f6752ecc159a2058858") + if err != nil { + t.Fatalf("Failed to decode expected block summary root: %v", err) + } + expectedStateSummaryRoot, err := hex.DecodeString("8a38d8b000dc65641eff8f7ff2e0b6f3b129957410cb9ecf183537484630b289") + if err != nil { + t.Fatalf("Failed to decode expected state summary root: %v", err) + } + expectedSummaryRoot := hash(expectedBlockSummaryRoot, expectedStateSummaryRoot, false) + if !bytes.Equal(summaryRoot, expectedSummaryRoot) { + t.Fatalf("summary root %x does not match expected %x", summaryRoot, expectedSummaryRoot) + } +} diff --git a/shared/types/eth2/types.go b/shared/types/eth2/types.go index 22e0fc671..e566e84d5 100644 --- a/shared/types/eth2/types.go +++ b/shared/types/eth2/types.go @@ -28,9 +28,17 @@ type BeaconState interface { ValidatorAndSlotProof(validatorIndex uint64) (validatorProof [][]byte, slotProof [][]byte, err error) HistoricalSummaryProof(slot uint64, capellaOffset uint64) ([][]byte, error) HistoricalSummaryBlockRootProof(slot int) ([][]byte, error) + HistoricalSummaryStateRootProof(slot int) ([][]byte, error) BlockRootProof(slot uint64) ([][]byte, error) + StateRootProof(slot uint64) ([][]byte, error) BlockHeaderProof() ([][]byte, error) GetValidators() []*generic.Validator + GetPreviousEpochParticipation() []byte + // PreviousEpochParticipationChunkProof proves the previous_epoch_participation + // chunk containing validatorIndex's flags up to the state root (no + // block-header cap). chunk is the 32-byte merkle leaf; the validator's flags + // are at byte validatorIndex % 32 within it + PreviousEpochParticipationChunkProof(validatorIndex uint64) (chunk [32]byte, participationProofBytes [][]byte, err error) } type SignedBeaconBlock interface {