diff --git a/bindings/node/deposit.go b/bindings/node/deposit.go index 903a2f227..e0066975b 100644 --- a/bindings/node/deposit.go +++ b/bindings/node/deposit.go @@ -14,6 +14,16 @@ import ( "github.com/rocket-pool/smartnode/bindings/utils/eth" ) +type NodeDeposit struct { + BondAmount *big.Int `json:"bondAmount"` + UseExpressTicket bool `json:"useExpressTicket"` + ValidatorPubkey []byte `json:"validatorPubkey"` + ValidatorSignature []byte `json:"validatorSignature"` + DepositDataRoot common.Hash `json:"depositDataRoot"` +} + +type Deposits []NodeDeposit + // Estimate the gas of Deposit func EstimateDepositGas(rp *rocketpool.RocketPool, bondAmount *big.Int, useExpressTicket bool, validatorPubkey rptypes.ValidatorPubkey, validatorSignature rptypes.ValidatorSignature, depositDataRoot common.Hash, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { rocketNodeDeposit, err := getRocketNodeDeposit(rp, nil) @@ -36,6 +46,28 @@ func Deposit(rp *rocketpool.RocketPool, bondAmount *big.Int, useExpressTicket bo return tx, nil } +// Estimate the gas of DepositMulti +func EstimateDepositMultiGas(rp *rocketpool.RocketPool, deposits Deposits, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { + rocketNodeDeposit, err := getRocketNodeDeposit(rp, nil) + if err != nil { + return rocketpool.GasInfo{}, err + } + return rocketNodeDeposit.GetTransactionGasInfo(opts, "depositMulti", deposits) +} + +// Make multiple node deposits +func DepositMulti(rp *rocketpool.RocketPool, deposits Deposits, opts *bind.TransactOpts) (*types.Transaction, error) { + rocketNodeDeposit, err := getRocketNodeDeposit(rp, nil) + if err != nil { + return nil, err + } + tx, err := rocketNodeDeposit.Transact(opts, "depositMulti", deposits) + if err != nil { + return nil, fmt.Errorf("error making multiple node deposits: %w", err) + } + return tx, nil +} + // Estimate the gas to WithdrawETH func EstimateWithdrawEthGas(rp *rocketpool.RocketPool, nodeAccount common.Address, ethAmount *big.Int, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { rocketNodeDeposit, err := getRocketNodeDeposit(rp, nil) diff --git a/rocketpool-cli/megapool/commands.go b/rocketpool-cli/megapool/commands.go index ad074d06b..9798b712b 100644 --- a/rocketpool-cli/megapool/commands.go +++ b/rocketpool-cli/megapool/commands.go @@ -30,8 +30,8 @@ func RegisterCommands(app *cli.App, name string, aliases []string) { { Name: "deposit", Aliases: []string{"d"}, - Usage: "Make a deposit and create a new validator on the megapool", - UsageText: "rocketpool node deposit [options]", + Usage: "Make a deposit and create a new validator on the megapool. Optionally specify count to make multiple deposits.", + UsageText: "rocketpool megapool deposit [options]", Flags: []cli.Flag{ cli.BoolFlag{ Name: "yes, y", @@ -41,6 +41,11 @@ func RegisterCommands(app *cli.App, name string, aliases []string) { Name: "use-express-ticket, e", Usage: "Use an express ticket to create a new validator", }, + cli.UintFlag{ + Name: "count, c", + Usage: "Number of deposits to make", + Value: 0, + }, }, Action: func(c *cli.Context) error { diff --git a/rocketpool-cli/megapool/deposit.go b/rocketpool-cli/megapool/deposit.go index d4ac04114..7c25312ee 100644 --- a/rocketpool-cli/megapool/deposit.go +++ b/rocketpool-cli/megapool/deposit.go @@ -3,6 +3,7 @@ package megapool import ( "fmt" "math/big" + "strconv" "github.com/rocket-pool/smartnode/bindings/utils/eth" "github.com/urfave/cli" @@ -18,13 +19,11 @@ import ( // Config const ( - colorReset string = "\033[0m" - colorRed string = "\033[31m" - colorGreen string = "\033[32m" - colorYellow string = "\033[33m" - smoothingPoolLink string = "https://docs.rocketpool.net/guides/redstone/whats-new.html#smoothing-pool" - signallingAddressLink string = "https://docs.rocketpool.net/guides/houston/participate#setting-your-snapshot-signalling-address" - maxAlertItems int = 3 + colorReset string = "\033[0m" + colorRed string = "\033[31m" + colorGreen string = "\033[32m" + colorYellow string = "\033[33m" + maxCount uint64 = 35 ) func nodeMegapoolDeposit(c *cli.Context) error { @@ -64,24 +63,6 @@ func nodeMegapoolDeposit(c *cli.Context) error { return nil } - /* - // Check if the fee distributor has been initialized - isInitializedResponse, err := rp.IsFeeDistributorInitialized() - if err != nil { - return err - } - if !isInitializedResponse.IsInitialized { - fmt.Println("Your fee distributor has not been initialized yet so you cannot create a new validator.\nPlease run `rocketpool node initialize-fee-distributor` to initialize it first.") - return nil - } - - // Post a warning about fee distribution - if !(c.Bool("yes") || prompt.Confirm(fmt.Sprintf("%sNOTE: By creating a new validator, your node will automatically claim and distribute any balance you have in your fee distributor contract. If you don't want to claim your balance at this time, you should not create a new minipool.%s\nWould you like to continue?", colorYellow, colorReset))) { - fmt.Println("Cancelled.") - return nil - } - */ - useExpressTicket := false var wg errgroup.Group @@ -118,7 +99,19 @@ func nodeMegapoolDeposit(c *cli.Context) error { return err } - if !(c.Bool("yes") || prompt.Confirm(fmt.Sprintf("%sNOTE: You are about to create a new megapool validator with a %.0f ETH deposit.%s\nWould you like to continue?", colorYellow, amount, colorReset))) { + count := c.Uint64("count") + + // If the count was not provided, prompt the user for the number of deposits + for count == 0 || count > maxCount { + countStr := prompt.Prompt(fmt.Sprintf("How many validators would you like to create? (max: %d)", maxCount), "^\\d+$", "Invalid number.") + count, err = strconv.ParseUint(countStr, 10, 64) + if err != nil { + fmt.Println("Invalid number. Please try again.") + continue + } + } + + if !(c.Bool("yes") || prompt.Confirm(fmt.Sprintf("%sNOTE: You are about to create %d new megapool validators, each with a %.0f ETH deposit (total: %.0f ETH).%s\nWould you like to continue?", colorYellow, count, amount, amount*float64(count), colorReset))) { fmt.Println("Cancelled.") return nil } @@ -139,7 +132,7 @@ func nodeMegapoolDeposit(c *cli.Context) error { fmt.Printf("You have %d express tickets available.", expressTicketCount) fmt.Println() // Prompt for confirmation - if c.Bool("yes") || prompt.Confirm("Would you like to use an express ticket?") { + if c.Bool("yes") || prompt.Confirm("Would you like to use your express tickets?") { useExpressTicket = true } } @@ -149,12 +142,12 @@ func nodeMegapoolDeposit(c *cli.Context) error { minNodeFee := 0.0 // Check deposit can be made - canDeposit, err := rp.CanNodeDeposit(amountWei, minNodeFee, big.NewInt(0), useExpressTicket) + canDeposit, err := rp.CanNodeDeposits(count, amountWei, minNodeFee, big.NewInt(0), useExpressTicket) if err != nil { return err } if !canDeposit.CanDeposit { - fmt.Println("Cannot make node deposit:") + fmt.Printf("Cannot make %d node deposits:\n", count) if canDeposit.NodeHasDebt { fmt.Println("The node has debt. You must repay the debt before creating a new validator. Use the `rocketpool megapool repay-debt` command to repay the debt.") } @@ -165,7 +158,11 @@ func nodeMegapoolDeposit(c *cli.Context) error { if canDeposit.InsufficientBalance { nodeBalance := eth.WeiToEth(canDeposit.NodeBalance) creditBalance := eth.WeiToEth(canDeposit.CreditBalance) - fmt.Printf("The node's balance of %.6f ETH and credit balance of %.6f ETH are not enough to create a megapool validator with a %.1f ETH bond.", nodeBalance, creditBalance, amount) + if count > 1 { + fmt.Printf("The node's balance of %.6f ETH and credit balance of %.6f ETH are not enough to create %d megapool validators with a %.1f ETH bond each (total: %.1f ETH).", nodeBalance, creditBalance, count, amount, amount*float64(count)) + } else { + fmt.Printf("The node's balance of %.6f ETH and credit balance of %.6f ETH are not enough to create a megapool validator with a %.1f ETH bond.", nodeBalance, creditBalance, amount) + } } if canDeposit.InvalidAmount { fmt.Println("The deposit amount is invalid.") @@ -177,12 +174,13 @@ func nodeMegapoolDeposit(c *cli.Context) error { } useCreditBalance := false + totalAmountWei := big.NewInt(0).Mul(amountWei, big.NewInt(int64(count))) fmt.Printf("You currently have %.2f ETH in your credit balance plus ETH staked on your behalf.\n", eth.WeiToEth(canDeposit.CreditBalance)) if canDeposit.CreditBalance.Cmp(big.NewInt(0)) > 0 { if canDeposit.CanUseCredit { useCreditBalance = true // Get how much credit to use - remainingAmount := big.NewInt(0).Sub(amountWei, canDeposit.CreditBalance) + remainingAmount := big.NewInt(0).Sub(totalAmountWei, canDeposit.CreditBalance) if remainingAmount.Cmp(big.NewInt(0)) > 0 { fmt.Printf("This deposit will use all %.6f ETH from your credit balance plus ETH staked on your behalf and %.6f ETH from your node.\n\n", eth.WeiToEth(canDeposit.CreditBalance), eth.WeiToEth(remainingAmount)) } else { @@ -192,6 +190,7 @@ func nodeMegapoolDeposit(c *cli.Context) error { fmt.Printf("%sNOTE: Your credit balance *cannot* currently be used to create a new megapool validator; there is not enough ETH in the staking pool to cover the initial deposit on your behalf (it needs at least 1 ETH but only has %.2f ETH).%s\nIf you want to continue creating this megapool validator now, you will have to pay for the full bond amount.\n\n", colorYellow, eth.WeiToEth(canDeposit.DepositBalance), colorReset) } } + // Prompt for confirmation if !(c.Bool("yes") || prompt.Confirm("Would you like to continue?")) { fmt.Println("Cancelled.") @@ -227,40 +226,77 @@ func nodeMegapoolDeposit(c *cli.Context) error { } // Prompt for confirmation + if !(c.Bool("yes") || prompt.Confirm(fmt.Sprintf( - "You are about to deposit %.6f ETH to create a new megapool validator.\n"+ - "%sARE YOU SURE YOU WANT TO DO THIS? Exiting this validator and retrieving your capital cannot be done until the validator has been *active* on the Beacon Chain for 256 epochs (approx. 27 hours).%s\n", + "You are about to deposit %.6f ETH to create %d new megapool validators (%.6f ETH total).\n"+ + "%sARE YOU SURE YOU WANT TO DO THIS? Exiting these validators and retrieving your capital cannot be done until each validator has been *active* on the Beacon Chain for 256 epochs (approx. 27 hours).%s\n", math.RoundDown(eth.WeiToEth(amountWei), 6), + count, + math.RoundDown(eth.WeiToEth(amountWei), 6)*float64(count), colorYellow, colorReset))) { fmt.Println("Cancelled.") return nil } - // Make deposit - response, err := rp.NodeDeposit(amountWei, minNodeFee, big.NewInt(0), useCreditBalance, useExpressTicket, true) - if err != nil { - return err - } + // Make deposit(s) + if count == 1 { + // Single deposit + response, err := rp.NodeDeposit(amountWei, minNodeFee, big.NewInt(0), useCreditBalance, useExpressTicket, true) + if err != nil { + return err + } - // Log and wait for the megapool validator deposit - fmt.Printf("Creating megapool validator...\n") - cliutils.PrintTransactionHash(rp, response.TxHash) - _, err = rp.WaitForTransaction(response.TxHash) - if err != nil { - return err - } + // Log and wait for the megapool validator deposit + fmt.Printf("Creating megapool validator...\n") + cliutils.PrintTransactionHash(rp, response.TxHash) + _, err = rp.WaitForTransaction(response.TxHash) + if err != nil { + return err + } - // Log & return - fmt.Printf("The node deposit of %.6f ETH was made successfully!\n", math.RoundDown(eth.WeiToEth(amountWei), 6)) - fmt.Printf("The validator pubkey is: %s\n\n", response.ValidatorPubkey.Hex()) + // Log & return + fmt.Printf("The node deposit of %.6f ETH was made successfully!\n", math.RoundDown(eth.WeiToEth(amountWei), 6)) + fmt.Printf("The validator pubkey is: %s\n\n", response.ValidatorPubkey.Hex()) - fmt.Println("The new megapool validator has been created.") - fmt.Println("Once your validator progresses through the queue, ETH will be assigned and a 1 ETH prestake submitted.") - fmt.Printf("After the prestake, your node will automatically perform a stake transaction, to complete the progress.") - fmt.Println("") - fmt.Println("To check the status of your validators use `rocketpool megapool validators`") - fmt.Println("To monitor the stake transaction use `rocketpool service logs node`") + fmt.Println("The new megapool validator has been created.") + fmt.Println("Once your validator progresses through the queue, ETH will be assigned and a 1 ETH prestake submitted.") + fmt.Printf("After the prestake, your node will automatically perform a stake transaction, to complete the progress.") + fmt.Println("") + fmt.Println("To check the status of your validators use `rocketpool megapool validators`") + fmt.Println("To monitor the stake transaction use `rocketpool service logs node`") + } else { + // Multiple deposits + responses, err := rp.NodeDeposits(count, amountWei, minNodeFee, big.NewInt(0), useCreditBalance, useExpressTicket, true) + if err != nil { + return err + } + + // Log and wait for the megapool validator deposits + fmt.Printf("Creating %d megapool validators in a single transaction...\n", count) + cliutils.PrintTransactionHash(rp, responses.TxHash) + _, err = rp.WaitForTransaction(responses.TxHash) + if err != nil { + return err + } + + // Log & return + fmt.Printf("The node deposits of %.6f ETH each (%.6f ETH total) were made successfully!\n", + math.RoundDown(eth.WeiToEth(amountWei), 6), + math.RoundDown(eth.WeiToEth(amountWei), 6)*float64(count)) + fmt.Printf("Validator pubkeys:\n") + for i, pubkey := range responses.ValidatorPubkeys { + fmt.Printf(" %d. %s\n", i+1, pubkey.Hex()) + } + fmt.Println() + + fmt.Printf("The %d new megapool validators have been created.\n", count) + fmt.Println("Once your validators progress through the queue, ETH will be assigned and a 1 ETH prestake submitted for each.") + fmt.Printf("After the prestake, your node will automatically perform a stake transaction for each validator, to complete the progress.") + fmt.Println("") + fmt.Println("To check the status of your validators use `rocketpool megapool validators`") + fmt.Println("To monitor the stake transactions use `rocketpool service logs node`") + } return nil diff --git a/rocketpool/api/node/commands.go b/rocketpool/api/node/commands.go index 67eb04aaf..8e6796fcd 100644 --- a/rocketpool/api/node/commands.go +++ b/rocketpool/api/node/commands.go @@ -936,14 +936,15 @@ func RegisterSubcommands(command *cli.Command, name string, aliases []string) { { Name: "can-deposit", - Usage: "Check whether the node can make a deposit", - UsageText: "rocketpool api node can-deposit amount min-fee salt use-express-ticket", + Usage: "Check whether the node can make a deposit. Optionally specify count to check multiple deposits.", + UsageText: "rocketpool api node can-deposit amount min-fee salt use-express-ticket count", Action: func(c *cli.Context) error { // Validate args - if err := cliutils.ValidateArgCount(c, 4); err != nil { + if err := cliutils.ValidateArgCount(c, 5); err != nil { return err } + amountWei, err := cliutils.ValidatePositiveWeiAmount("deposit amount", c.Args().Get(0)) if err != nil { return err @@ -958,14 +959,18 @@ func RegisterSubcommands(command *cli.Command, name string, aliases []string) { return err } - useExpressTicketString := c.Args().Get(3) - useExpressTicket, err := cliutils.ValidateBool("use-express-ticket", useExpressTicketString) + useExpressTicket, err := cliutils.ValidateBool("use-express-ticket", c.Args().Get(3)) + if err != nil { + return err + } + + count, err := cliutils.ValidateUint("count", c.Args().Get(4)) if err != nil { return err } // Run - api.PrintResponse(canNodeDeposit(c, amountWei, minNodeFee, salt, useExpressTicket)) + api.PrintResponse(canNodeDeposits(c, count, amountWei, minNodeFee, salt, useExpressTicket)) return nil }, @@ -973,14 +978,15 @@ func RegisterSubcommands(command *cli.Command, name string, aliases []string) { { Name: "deposit", Aliases: []string{"d"}, - Usage: "Make a deposit and create a minipool, or just make and sign the transaction (when submit = false)", - UsageText: "rocketpool api node deposit amount min-node-fee salt use-credit-balance use-express-ticket submit", + Usage: "Make a deposit and create a minipool, or just make and sign the transaction (when submit = false). Optionally specify count to make multiple deposits.", + UsageText: "rocketpool api node deposit amount min-node-fee salt use-credit-balance use-express-ticket submit count", Action: func(c *cli.Context) error { // Validate args - if err := cliutils.ValidateArgCount(c, 6); err != nil { + if err := cliutils.ValidateArgCount(c, 7); err != nil { return err } + amountWei, err := cliutils.ValidatePositiveWeiAmount("deposit amount", c.Args().Get(0)) if err != nil { return err @@ -1012,15 +1018,17 @@ func RegisterSubcommands(command *cli.Command, name string, aliases []string) { return err } + // Check if count is provided + count, err := cliutils.ValidateUint("count", c.Args().Get(6)) if err != nil { return err } // Run - response, err := nodeDeposit(c, amountWei, minNodeFee, salt, useCreditBalance, useExpressTicket, submit) + response, err := nodeDeposits(c, count, amountWei, minNodeFee, salt, useCreditBalance, useExpressTicket, submit) if submit { api.PrintResponse(response, err) - } // else nodeDeposit already printed the encoded transaction + } // else nodeDeposits already printed the encoded transaction return nil }, diff --git a/rocketpool/api/node/deposit.go b/rocketpool/api/node/deposit.go index 96c38ae67..cb447595b 100644 --- a/rocketpool/api/node/deposit.go +++ b/rocketpool/api/node/deposit.go @@ -311,6 +311,224 @@ func canNodeDeposit(c *cli.Context, amountWei *big.Int, minNodeFee float64, salt } +func canNodeDeposits(c *cli.Context, count uint64, amountWei *big.Int, minNodeFee float64, salt *big.Int, useExpressTicket bool) (*api.CanNodeDepositResponse, error) { + + // Get services + if err := services.RequireNodeRegistered(c); err != nil { + return nil, err + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + ec, err := services.GetEthClient(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 + } + + // Get eth2 config + eth2Config, err := bc.GetEth2Config() + if err != nil { + return nil, err + } + + // Response + response := api.CanNodeDepositResponse{ + ValidatorPubkeys: make([]rptypes.ValidatorPubkey, count), + } + + // Get node account + nodeAccount, err := w.GetNodeAccount() + if err != nil { + return nil, err + } + + saturnDeployed, err := state.IsSaturnDeployed(rp, nil) + if err != nil { + return nil, err + } + + if !saturnDeployed { + return nil, fmt.Errorf("Multiple deposits are only supported after Saturn deployment") + } + + // Data + var wg1 errgroup.Group + var depositPoolBalance *big.Int + var expressTicketCount uint64 + + // Check credit balance + wg1.Go(func() error { + ethBalanceWei, err := node.GetNodeCreditAndBalance(rp, nodeAccount.Address, nil) + if err == nil { + response.CreditBalance = ethBalanceWei + } + return err + }) + + // Check node balance + wg1.Go(func() error { + ethBalanceWei, err := ec.BalanceAt(context.Background(), nodeAccount.Address, nil) + if err == nil { + response.NodeBalance = ethBalanceWei + } + return err + }) + + // Check node deposits are enabled + wg1.Go(func() error { + depositEnabled, err := protocol.GetNodeDepositEnabled(rp, nil) + if err == nil { + response.DepositDisabled = !depositEnabled + } + return err + }) + + // Get deposit pool balance + wg1.Go(func() error { + var err error + depositPoolBalance, err = deposit.GetBalance(rp, nil) + return err + }) + + // Get the express ticket count + wg1.Go(func() error { + var err error + expressTicketCount, err = node.GetExpressTicketCount(rp, nodeAccount.Address, nil) + return err + }) + + // Wait for data + if err := wg1.Wait(); err != nil { + return nil, err + } + + // Calculate total amount needed for all deposits + totalAmountWei := big.NewInt(0).Mul(amountWei, big.NewInt(int64(count))) + + // Check for insufficient balance + totalBalance := big.NewInt(0).Add(response.NodeBalance, response.CreditBalance) + response.InsufficientBalance = (totalAmountWei.Cmp(totalBalance) > 0) + + // Check if the credit balance can be used + response.DepositBalance = depositPoolBalance + response.CanUseCredit = (depositPoolBalance.Cmp(eth.EthToWei(1)) >= 0) && totalBalance.Cmp(totalAmountWei) >= 0 + + // Update response + response.CanDeposit = !(response.InsufficientBalance || response.InvalidAmount || response.DepositDisabled) + if !response.CanDeposit { + return &response, nil + } + + if response.CanDeposit && !response.CanUseCredit && response.NodeBalance.Cmp(totalAmountWei) < 0 { + // Can't use credit and there's not enough ETH in the node wallet to deposit so error out + response.InsufficientBalanceWithoutCredit = true + response.CanDeposit = false + } + + // Break before the gas estimator if depositing won't work + if !response.CanDeposit { + return &response, nil + } + + // Get gas estimate + opts, err := w.GetNodeAccountTransactor() + if err != nil { + return nil, err + } + + // Get how much credit to use + if response.CanUseCredit { + remainingAmount := big.NewInt(0).Sub(amountWei, response.CreditBalance) + if remainingAmount.Cmp(big.NewInt(0)) > 0 { + // Send the remaining amount if the credit isn't enough to cover the whole deposit + opts.Value = remainingAmount + } + } else { + opts.Value = amountWei + } + + // Get the megapool address + megapoolAddress, err := megapool.GetMegapoolExpectedAddress(rp, nodeAccount.Address, nil) + if err != nil { + return nil, err + } + + // Get the withdrawal credentials + withdrawalCredentials := services.CalculateMegapoolWithdrawalCredentials(megapoolAddress) + + // Create deposit data for all deposits (for gas estimation) + // We need to create unique validator keys for each deposit to get accurate gas estimates + depositAmount := uint64(1e9) // 1 ETH in gwei + depositsSlice := node.Deposits{} + + keyCount, err := w.GetValidatorKeyCount() + if err != nil { + return nil, err + } + + // Get the next validator key for gas estimation + validatorKeys, err := w.GetValidatorKeys(keyCount, uint(count)) + if err != nil { + return nil, err + } + + canUseExpressTicket := expressTicketCount > 0 && useExpressTicket + for i := uint64(0); i < count; i++ { + + // If we can use an express ticket count the number of tickets used + if canUseExpressTicket && expressTicketCount > 0 { + expressTicketCount-- + } else { + canUseExpressTicket = false + } + // Get validator deposit data and associated parameters + depositData, depositDataRoot, err := validator.GetDepositData(validatorKeys[i].PrivateKey, withdrawalCredentials, eth2Config, depositAmount) + if err != nil { + return nil, err + } + pubKey := rptypes.BytesToValidatorPubkey(depositData.PublicKey) + signature := rptypes.BytesToValidatorSignature(depositData.Signature) + + // Add to deposits array + depositsSlice = append(depositsSlice, node.NodeDeposit{ + BondAmount: amountWei, + UseExpressTicket: canUseExpressTicket, + ValidatorPubkey: pubKey[:], + ValidatorSignature: signature[:], + DepositDataRoot: depositDataRoot, + }) + + // Store the pubkey in the response + response.ValidatorPubkeys[i] = pubKey + } + + opts.Value = amountWei + + // Run the deposit multi gas estimator + // Ensure count is valid + if count == 0 { + return nil, fmt.Errorf("count must be greater than 0") + } + + gasInfo, err := node.EstimateDepositMultiGas(rp, depositsSlice, opts) + if err != nil { + return nil, fmt.Errorf("error estimating gas for depositMulti: %w", err) + } + response.GasInfo = gasInfo + + return &response, nil + +} + func nodeDeposit(c *cli.Context, amountWei *big.Int, minNodeFee float64, salt *big.Int, useCreditBalance bool, useExpressTicket bool, submit bool) (*api.NodeDepositResponse, error) { // Get services @@ -542,6 +760,198 @@ func nodeDeposit(c *cli.Context, amountWei *big.Int, minNodeFee float64, salt *b } +func nodeDeposits(c *cli.Context, count uint64, amountWei *big.Int, minNodeFee float64, salt *big.Int, useCreditBalance bool, useExpressTicket bool, submit bool) (*api.NodeDepositsResponse, error) { + + // Get services + if err := services.RequireNodeRegistered(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 + } + + // Get eth2 config + eth2Config, err := bc.GetEth2Config() + if err != nil { + return nil, err + } + + // Get node account + nodeAccount, err := w.GetNodeAccount() + if err != nil { + return nil, err + } + + saturnDeployed, err := state.IsSaturnDeployed(rp, nil) + if err != nil { + return nil, err + } + + // Response + response := api.NodeDepositsResponse{} + + if !saturnDeployed { + return nil, fmt.Errorf("Multiple deposits are only supported after Saturn deployment") + } + + // Make sure ETH2 is on the correct chain + depositContractInfo, err := getDepositContractInfo(c) + if err != nil { + return nil, err + } + if depositContractInfo.RPNetwork != depositContractInfo.BeaconNetwork || + depositContractInfo.RPDepositContract != depositContractInfo.BeaconDepositContract { + return nil, fmt.Errorf("Beacon network mismatch! Expected %s on chain %d, but beacon is using %s on chain %d.", + depositContractInfo.RPDepositContract.Hex(), + depositContractInfo.RPNetwork, + depositContractInfo.BeaconDepositContract.Hex(), + depositContractInfo.BeaconNetwork) + } + + // Get the scrub period + scrubPeriodUnix, err := trustednode.GetScrubPeriod(rp, nil) + if err != nil { + return nil, err + } + scrubPeriod := time.Duration(scrubPeriodUnix) * time.Second + response.ScrubPeriod = scrubPeriod + + // Get the megapool address + megapoolAddress, err := megapool.GetMegapoolExpectedAddress(rp, nodeAccount.Address, nil) + if err != nil { + return nil, err + } + + // Get the withdrawal credentials + withdrawalCredentials := services.CalculateMegapoolWithdrawalCredentials(megapoolAddress) + + // Get transactor + opts, err := w.GetNodeAccountTransactor() + if err != nil { + return nil, err + } + + // Calculate total amount needed + totalAmountWei := big.NewInt(0).Mul(amountWei, big.NewInt(int64(count))) + + // For multi-deposit, credit balance is not supported, so send the full amount + opts.Value = totalAmountWei + + // Create validator keys and deposit data for all deposits + // Since gas estimation already created the keys, we reuse them instead of creating new ones + depositAmount := uint64(1e9) // 1 ETH in gwei + depositsSlice := make([]node.NodeDeposit, count) + response.ValidatorPubkeys = make([]rptypes.ValidatorPubkey, count) + + for i := uint64(0); i < count; i++ { + validatorKey, err := w.CreateValidatorKey() + if err != nil { + return nil, err + } + // Get validator deposit data and associated parameters + depositData, depositDataRoot, err := validator.GetDepositData(validatorKey, withdrawalCredentials, eth2Config, depositAmount) + if err != nil { + return nil, err + } + pubKey := rptypes.BytesToValidatorPubkey(depositData.PublicKey) + signature := rptypes.BytesToValidatorSignature(depositData.Signature) + + // Make sure a validator with this pubkey doesn't already exist + status, err := bc.GetValidatorStatus(pubKey, nil) + if err != nil { + return nil, fmt.Errorf("Error checking for existing validator status for deposit %d/%d: %w\nYour funds have not been deposited for your own safety.", i+1, count, err) + } + if status.Exists { + return nil, fmt.Errorf("**** ALERT ****\n"+ + "The following validator pubkey is already in use on the Beacon chain:\n\t%s\n"+ + "Rocket Pool will not allow you to deposit this validator for your own safety so you do not get slashed.\n"+ + "PLEASE REPORT THIS TO THE ROCKET POOL DEVELOPERS.\n"+ + "***************\n", pubKey.Hex()) + } + + // Do a final sanity check + err = validateDepositInfo(eth2Config, depositAmount, pubKey, withdrawalCredentials, signature) + if err != nil { + return nil, fmt.Errorf("Your deposit %d/%d failed the validation safety check: %w\n"+ + "For your safety, this deposit will not be submitted and your ETH will not be staked.\n"+ + "PLEASE REPORT THIS TO THE ROCKET POOL DEVELOPERS and include the following information:\n"+ + "\tDomain Type: 0x%s\n"+ + "\tGenesis Fork Version: 0x%s\n"+ + "\tGenesis Validator Root: 0x%s\n"+ + "\tDeposit Amount: %d gwei\n"+ + "\tValidator Pubkey: %s\n"+ + "\tWithdrawal Credentials: %s\n"+ + "\tSignature: %s\n", + i+1, count, err, + hex.EncodeToString(eth2types.DomainDeposit[:]), + hex.EncodeToString(eth2Config.GenesisForkVersion), + hex.EncodeToString(eth2types.ZeroGenesisValidatorsRoot), + depositAmount, + pubKey.Hex(), + withdrawalCredentials.Hex(), + signature.Hex(), + ) + } + + // Set deposits array element + depositsSlice[i] = node.NodeDeposit{ + BondAmount: amountWei, + UseExpressTicket: useExpressTicket, + ValidatorPubkey: pubKey[:], + ValidatorSignature: signature[:], + DepositDataRoot: depositDataRoot, + } + + response.ValidatorPubkeys[i] = pubKey + } + + // Override the provided pending TX if requested + err = eth1.CheckForNonceOverride(c, opts) + if err != nil { + return nil, fmt.Errorf("Error checking for nonce override: %w", err) + } + + // Do not send transaction unless requested + opts.NoSend = !submit + + // Make multiple deposits in a single transaction + // Convert to Deposits type for proper ABI encoding + deposits := node.Deposits(depositsSlice) + tx, err := node.DepositMulti(rp, deposits, opts) + if err != nil { + return nil, err + } + + // Save wallet + if err := w.Save(); err != nil { + return nil, err + } + + // Print transaction if requested + if !submit { + b, err := tx.MarshalBinary() + if err != nil { + return nil, err + } + fmt.Printf("%x\n", b) + } + + response.TxHash = tx.Hash() + + // Return response + return &response, nil + +} + func validateDepositInfo(eth2Config beacon.Eth2Config, depositAmount uint64, pubkey rptypes.ValidatorPubkey, withdrawalCredentials common.Hash, signature rptypes.ValidatorSignature) error { // Get the deposit domain based on the eth2 config diff --git a/shared/services/rocketpool/node.go b/shared/services/rocketpool/node.go index 4bcf62758..7434b691b 100644 --- a/shared/services/rocketpool/node.go +++ b/shared/services/rocketpool/node.go @@ -713,6 +713,22 @@ func (c *Client) CanNodeDeposit(amountWei *big.Int, minFee float64, salt *big.In return response, nil } +// Check whether the node can make multiple deposits +func (c *Client) CanNodeDeposits(count uint64, amountWei *big.Int, minFee float64, salt *big.Int, useExpressTicket bool) (api.CanNodeDepositResponse, error) { + responseBytes, err := c.callAPI(fmt.Sprintf("node can-deposit %s %f %s %t %d", amountWei.String(), minFee, salt.String(), useExpressTicket, count)) + if err != nil { + return api.CanNodeDepositResponse{}, fmt.Errorf("Could not get can node deposits status: %w", err) + } + var response api.CanNodeDepositResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return api.CanNodeDepositResponse{}, fmt.Errorf("Could not decode can node deposits response: %w", err) + } + if response.Error != "" { + return api.CanNodeDepositResponse{}, fmt.Errorf("Could not get can node deposits status: %s", response.Error) + } + return response, nil +} + // Make a node deposit func (c *Client) NodeDeposit(amountWei *big.Int, minFee float64, salt *big.Int, useCreditBalance bool, useExpressTicket bool, submit bool) (api.NodeDepositResponse, error) { responseBytes, err := c.callAPI(fmt.Sprintf("node deposit %s %f %s %t %t %t", amountWei.String(), minFee, salt.String(), useCreditBalance, useExpressTicket, submit)) @@ -729,6 +745,22 @@ func (c *Client) NodeDeposit(amountWei *big.Int, minFee float64, salt *big.Int, return response, nil } +// Make multiple node deposits +func (c *Client) NodeDeposits(count uint64, amountWei *big.Int, minFee float64, salt *big.Int, useCreditBalance bool, useExpressTicket bool, submit bool) (api.NodeDepositsResponse, error) { + responseBytes, err := c.callAPI(fmt.Sprintf("node deposit %s %f %s %t %t %t %d", amountWei.String(), minFee, salt.String(), useCreditBalance, useExpressTicket, submit, count)) + if err != nil { + return api.NodeDepositsResponse{}, fmt.Errorf("Could not make node deposits: %w", err) + } + var response api.NodeDepositsResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return api.NodeDepositsResponse{}, fmt.Errorf("Could not decode node deposits response: %w", err) + } + if response.Error != "" { + return api.NodeDepositsResponse{}, fmt.Errorf("Could not make node deposits: %s", response.Error) + } + return response, nil +} + // Check whether the node can send tokens func (c *Client) CanNodeSend(amountRaw float64, token string, toAddress common.Address) (api.CanNodeSendResponse, error) { responseBytes, err := c.callAPI(fmt.Sprintf("node can-send %.10f %s %s", amountRaw, token, toAddress.Hex())) diff --git a/shared/types/api/node.go b/shared/types/api/node.go index 343968daa..9063430c2 100644 --- a/shared/types/api/node.go +++ b/shared/types/api/node.go @@ -428,22 +428,23 @@ type CanNodeWithdrawRplv1_3_1Response struct { } type CanNodeDepositResponse struct { - Status string `json:"status"` - Error string `json:"error"` - CanDeposit bool `json:"canDeposit"` - CreditBalance *big.Int `json:"creditBalance"` - DepositBalance *big.Int `json:"depositBalance"` - CanUseCredit bool `json:"canUseCredit"` - NodeBalance *big.Int `json:"nodeBalance"` - InsufficientBalance bool `json:"insufficientBalance"` - InsufficientBalanceWithoutCredit bool `json:"insufficientBalanceWithoutCredit"` - InvalidAmount bool `json:"invalidAmount"` - DepositDisabled bool `json:"depositDisabled"` - InConsensus bool `json:"inConsensus"` - NodeHasDebt bool `json:"nodeHasDebt"` - MinipoolAddress common.Address `json:"minipoolAddress"` - MegapoolAddress common.Address `json:"megapoolAddress"` - GasInfo rocketpool.GasInfo `json:"gasInfo"` + Status string `json:"status"` + Error string `json:"error"` + CanDeposit bool `json:"canDeposit"` + CreditBalance *big.Int `json:"creditBalance"` + DepositBalance *big.Int `json:"depositBalance"` + CanUseCredit bool `json:"canUseCredit"` + NodeBalance *big.Int `json:"nodeBalance"` + InsufficientBalance bool `json:"insufficientBalance"` + InsufficientBalanceWithoutCredit bool `json:"insufficientBalanceWithoutCredit"` + InvalidAmount bool `json:"invalidAmount"` + DepositDisabled bool `json:"depositDisabled"` + InConsensus bool `json:"inConsensus"` + NodeHasDebt bool `json:"nodeHasDebt"` + MinipoolAddress common.Address `json:"minipoolAddress"` + MegapoolAddress common.Address `json:"megapoolAddress"` + ValidatorPubkeys []rptypes.ValidatorPubkey `json:"validatorPubkeys"` + GasInfo rocketpool.GasInfo `json:"gasInfo"` } type NodeDepositResponse struct { Status string `json:"status"` @@ -454,6 +455,33 @@ type NodeDepositResponse struct { ScrubPeriod time.Duration `json:"scrubPeriod"` } +type CanNodeDepositsResponse struct { + Status string `json:"status"` + Error string `json:"error"` + CanDeposit bool `json:"canDeposit"` + CreditBalance *big.Int `json:"creditBalance"` + DepositBalance *big.Int `json:"depositBalance"` + CanUseCredit bool `json:"canUseCredit"` + NodeBalance *big.Int `json:"nodeBalance"` + InsufficientBalance bool `json:"insufficientBalance"` + InsufficientBalanceWithoutCredit bool `json:"insufficientBalanceWithoutCredit"` + InvalidAmount bool `json:"invalidAmount"` + DepositDisabled bool `json:"depositDisabled"` + InConsensus bool `json:"inConsensus"` + MinipoolAddress common.Address `json:"minipoolAddress"` + MegapoolAddress common.Address `json:"megapoolAddress"` + ValidatorPubkeys []rptypes.ValidatorPubkey `json:"validatorPubkeys"` + GasInfo rocketpool.GasInfo `json:"gasInfo"` +} + +type NodeDepositsResponse struct { + Status string `json:"status"` + Error string `json:"error"` + TxHash common.Hash `json:"txHash"` + ValidatorPubkeys []rptypes.ValidatorPubkey `json:"validatorPubkeys"` + ScrubPeriod time.Duration `json:"scrubPeriod"` +} + type CanCreateVacantMinipoolResponse struct { Status string `json:"status"` Error string `json:"error"`