diff --git a/rocketpool-cli/node/commands.go b/rocketpool-cli/node/commands.go index f6bb82a27..4f455a760 100644 --- a/rocketpool-cli/node/commands.go +++ b/rocketpool-cli/node/commands.go @@ -408,6 +408,40 @@ func RegisterCommands(app *cli.App, name string, aliases []string) { }, }, + { + Name: "eth-to-reth", + Aliases: []string{"e2r"}, + Usage: "Swap ETH to rETH", + UsageText: "rocketpool node eth-to-reth [options]", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "amount, a", + Usage: "The amount of ETH to swap to rETH (or 'max' which keeps ~0.1 ETH in your wallet to pay for gas in future transactions)", + }, + cli.BoolFlag{ + Name: "yes, y", + Usage: "Automatically confirm ETH conversion", + }, + }, + Action: func(c *cli.Context) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 0); err != nil { + return err + } + + // Validate flags + if c.String("amount") != "" && c.String("amount") != "all" { + if _, err := cliutils.ValidatePositiveEthAmount("swap amount", c.String("amount")); err != nil { + return err + } + } + + // Run + return nodeSwapToReth(c) + }, + }, + { Name: "set-voting-delegate", Aliases: []string{"sv"}, diff --git a/rocketpool-cli/node/status.go b/rocketpool-cli/node/status.go index 56ad26938..e008717d4 100644 --- a/rocketpool-cli/node/status.go +++ b/rocketpool-cli/node/status.go @@ -59,11 +59,12 @@ func getStatus(c *cli.Context) error { // Account address & balances fmt.Printf("%s=== Account and Balances ===%s\n", colorGreen, colorReset) fmt.Printf( - "The node %s%s%s has a balance of %.6f ETH and %.6f RPL.\n", + "The node %s%s%s has a balance of %.6f ETH, %.6f rETH and %.6f RPL.\n", colorBlue, status.AccountAddress.Hex(), colorReset, math.RoundDown(eth.WeiToEth(status.AccountBalances.ETH), 6), + math.RoundDown(eth.WeiToEth(status.AccountBalances.RETH), 6), math.RoundDown(eth.WeiToEth(status.AccountBalances.RPL), 6)) if status.AccountBalances.FixedSupplyRPL.Cmp(big.NewInt(0)) > 0 { fmt.Printf("The node has a balance of %.6f old RPL which can be swapped for new RPL.\n", math.RoundDown(eth.WeiToEth(status.AccountBalances.FixedSupplyRPL), 6)) diff --git a/rocketpool-cli/node/swap-reth.go b/rocketpool-cli/node/swap-reth.go new file mode 100644 index 000000000..c313af699 --- /dev/null +++ b/rocketpool-cli/node/swap-reth.go @@ -0,0 +1,152 @@ +package node + +import ( + "fmt" + "math/big" + "strconv" + + "github.com/rocket-pool/rocketpool-go/utils/eth" + "github.com/urfave/cli" + + "github.com/rocket-pool/smartnode/shared/services/gas" + "github.com/rocket-pool/smartnode/shared/services/rocketpool" + cliutils "github.com/rocket-pool/smartnode/shared/utils/cli" + "github.com/rocket-pool/smartnode/shared/utils/math" +) + +func nodeSwapToReth(c *cli.Context) error { + + // Get RP client + rp, err := rocketpool.NewClientFromCtx(c) + if err != nil { + return err + } + defer rp.Close() + + // Check and assign the EC status + err = cliutils.CheckClientStatus(rp) + if err != nil { + return err + } + + // Get swap amount + var amountWei *big.Int + + if c.String("amount") == "max" { + + // get data + nodeStatus, err := rp.NodeStatus() + if err != nil { + return err + } + queueStatus, err := rp.QueueStatus() + if err != nil { + return err + } + + var availableAmountWeiWithGasBuffer big.Int + if availableAmountWeiWithGasBuffer.Sub(nodeStatus.AccountBalances.ETH, eth.EthToWei(0.1)).Sign() == -1 { + return fmt.Errorf("You need at least 0.1 ETH to be able to pay gas for future transactions.") + } + maxAmount := availableAmountWeiWithGasBuffer + if availableAmountWeiWithGasBuffer.Cmp(queueStatus.MaxDepositPoolBalance.Sub(queueStatus.MaxDepositPoolBalance, queueStatus.DepositPoolBalance)) > 0 { + maxAmount = *queueStatus.MaxDepositPoolBalance + } + amountWei = &maxAmount + + } else if c.String("amount") != "" { + + // Parse amount + swapAmount, err := strconv.ParseFloat(c.String("amount"), 64) + if err != nil { + return fmt.Errorf("Invalid swap amount '%s': %w", c.String("amount"), err) + } + amountWei = eth.EthToWei(swapAmount) + + } else { + + nodeStatus, err := rp.NodeStatus() + if err != nil { + return err + } + queueStatus, err := rp.QueueStatus() + if err != nil { + return err + } + + var maxAmount big.Int + maxAmount.Sub(nodeStatus.AccountBalances.ETH, eth.EthToWei(0.1)) + if maxAmount.Sign() == 1 && maxAmount.Cmp(queueStatus.MaxDepositPoolBalance.Sub(queueStatus.MaxDepositPoolBalance, queueStatus.DepositPoolBalance)) > 0 { + maxAmount = *queueStatus.MaxDepositPoolBalance + } + + // Prompt for deposit max amount if possible + if maxAmount.Sign() > 0 && cliutils.Confirm(fmt.Sprintf("Would you like to swap the maximum available ETH balance (%.6f ETH) (and keep some ETH to pay for future gas costs)?", math.RoundDown(eth.WeiToEth(&maxAmount), 6))){ + + amountWei = &maxAmount + + } else { + + // Prompt for custom amount + inputAmount := cliutils.Prompt("Please enter an amount of ETH to swap. Remember that you will need sufficient ETH to execute future transactions!", "^\\d+(\\.\\d+)?$", "Invalid amount") + swapAmount, err := strconv.ParseFloat(inputAmount, 64) + if err != nil { + return fmt.Errorf("Invalid swap amount '%s': %w", inputAmount, err) + } + amountWei = eth.EthToWei(swapAmount) + + } + + } + + // Check ETH can be swapped + canStake, err := rp.CanStakeEth(amountWei) + if err != nil { + return err + } + if !canStake.CanStake { + fmt.Println("Cannot stake ETH:") + if canStake.InsufficientBalance { + fmt.Println("The node's ETH balance is insufficient.") + } + if canStake.DepositDisabled { + fmt.Println("ETH deposits are currently disabled.") + } + if canStake.BelowMinStakeAmount { + fmt.Println("The stake amount is below the minimum accepted value.") + } + if canStake.DepositPoolFull { + fmt.Println("No space left in deposit pool.") + } + return nil + } + fmt.Println("Stake ETH Gas Info:") + // Assign max fees + err = gas.AssignMaxFeeAndLimit(canStake.GasInfo, rp, c.Bool("yes")) + if err != nil { + return err + } + + // Prompt for confirmation + if !(c.Bool("yes") || cliutils.Confirm(fmt.Sprintf("Are you sure you want to stake %.6f ETH for %.6f rETH?", math.RoundDown(eth.WeiToEth(amountWei), 6), math.RoundDown(eth.WeiToEth(canStake.RethAmount), 6)))) { + fmt.Println("Cancelled.") + return nil + } + + // Stake ETH + stakeResponse, err := rp.StakeEth(amountWei) + if err != nil { + return err + } + + fmt.Printf("Staking ETH...\n") + cliutils.PrintTransactionHash(rp, stakeResponse.StakeTxHash) + if _, err = rp.WaitForTransaction(stakeResponse.StakeTxHash); err != nil { + return err + } + + // Log & return + fmt.Printf("Successfully staked %.6f ETH in return for %.6f rETH.\n", math.RoundDown(eth.WeiToEth(amountWei), 6), math.RoundDown(eth.WeiToEth(canStake.RethAmount), 6)) + return nil + +} diff --git a/rocketpool/api/node/commands.go b/rocketpool/api/node/commands.go index 5cffcbeeb..9238137bc 100644 --- a/rocketpool/api/node/commands.go +++ b/rocketpool/api/node/commands.go @@ -773,6 +773,49 @@ func RegisterSubcommands(command *cli.Command, name string, aliases []string) { }, }, + { + Name: "can-stake-eth", + Usage: "Check whether the node can swap ETH to rETH via the deposit pool", + UsageText: "rocketpool api node can-stake-eth amount", + Action: func(c *cli.Context) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + amountWei, err := cliutils.ValidatePositiveWeiAmount("swap amount", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + api.PrintResponse(canSwapEth(c, amountWei)) + return nil + + }, + }, + { + Name: "stake-eth", + Usage: "Swap ETH to rETH via the deposit pool", + UsageText: "rocketpool api node stake-eth amount", + Action: func(c *cli.Context) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + amountWei, err := cliutils.ValidatePositiveWeiAmount("swap amount", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + api.PrintResponse(swapEth(c, amountWei)) + return nil + + }, + }, + { Name: "sign", Usage: "Signs a transaction with the node's private key. The TX must be serialized as a hex string.", diff --git a/rocketpool/api/node/swap-reth.go b/rocketpool/api/node/swap-reth.go new file mode 100644 index 000000000..c8d54c3d6 --- /dev/null +++ b/rocketpool/api/node/swap-reth.go @@ -0,0 +1,184 @@ +package node + +import ( + "context" + "fmt" + "math/big" + + "github.com/rocket-pool/rocketpool-go/deposit" + "github.com/rocket-pool/rocketpool-go/settings/protocol" + "github.com/rocket-pool/rocketpool-go/tokens" + "github.com/rocket-pool/rocketpool-go/utils/eth" + "github.com/urfave/cli" + "golang.org/x/sync/errgroup" + + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/types/api" + "github.com/rocket-pool/smartnode/shared/utils/eth1" +) + +func canSwapEth(c *cli.Context, amountWei *big.Int) (*api.CanStakeEthResponse, error) { + + // Get services + if err := services.RequireNodeWallet(c); err != nil { + return nil, err + } + if err := services.RequireRocketStorage(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 + } + + // Response + response := api.CanStakeEthResponse{} + + // Get node account + nodeAccount, err := w.GetNodeAccount() + if err != nil { + return nil, err + } + + // Data + var wg1 errgroup.Group + var maxPoolSize *big.Int + var currentPoolSize *big.Int + var depositFee *big.Int + var amountWeiReth *big.Int + + // Check node balance + wg1.Go(func() error { + ethBalanceWei, err := ec.BalanceAt(context.Background(), nodeAccount.Address, nil) + if err == nil { + response.InsufficientBalance = (amountWei.Cmp(ethBalanceWei) > 0) + } + return err + }) + + // Check deposits are enabled + wg1.Go(func() error { + depositEnabled, err := protocol.GetDepositEnabled(rp, nil) + if err == nil { + response.DepositDisabled = !depositEnabled + } + return err + }) + + // Check amount is above minimum + wg1.Go(func() error { + minDeposit, err := protocol.GetMinimumDeposit(rp, nil) + if err == nil { + response.BelowMinStakeAmount = amountWei.Cmp(minDeposit) < 0 + } + return err + }) + + // Get max pool size + wg1.Go(func() error { + var err error + maxPoolSize, err = protocol.GetMaximumDepositPoolSize(rp, nil) + return err + }) + + // Get current pool size + wg1.Go(func() error { + var err error + currentPoolSize, err = deposit.GetBalance(rp, nil) + return err + }) + + // Get deposit fee + wg1.Go(func() error { + var err error + depositFee, err = protocol.GetDepositFee(rp, nil) + return err + }) + + // Get reth amount + wg1.Go(func() error { + var err error + amountWeiReth, err = tokens.GetRETHValueOfETH(rp, amountWei, nil) + return err + }) + + // Get gas estimates + wg1.Go(func() error { + opts, err := w.GetNodeAccountTransactor() + if err != nil { + return err + } + opts.Value = amountWei + + gasInfo, err := deposit.EstimateDepositGas(rp, opts) + if err == nil { + response.GasInfo = gasInfo + } + return err + }) + + // Wait for data + if err := wg1.Wait(); err != nil { + return nil, err + } + + // Note: There might be space after the minipool queue has been cleared, if possible + response.DepositPoolFull = amountWei.Cmp(currentPoolSize.Sub(maxPoolSize, currentPoolSize)) > 0 + + // Update & return response + response.CanStake = !(response.InsufficientBalance || response.DepositDisabled || response.BelowMinStakeAmount || response.DepositPoolFull) + if response.CanStake { + var tmp big.Int + var amountWeiRethWithFees big.Int + tmp.Mul(amountWeiReth, depositFee) + tmp.Quo(&tmp, eth.EthToWei(1)) + amountWeiRethWithFees.Sub(amountWeiReth, &tmp) + response.RethAmount = &amountWeiRethWithFees + } + return &response, nil + +} + +func swapEth(c *cli.Context, amountWei *big.Int) (*api.StakeEthResponse, error) { + + // Get services + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + + // Response + response := api.StakeEthResponse{} + + // Swap ETH for rETH + opts, err := w.GetNodeAccountTransactor() + if err != nil { + return nil, err + } + opts.Value = amountWei + err = eth1.CheckForNonceOverride(c, opts) + if err != nil { + return nil, fmt.Errorf("Error checking for nonce override: %w", err) + } + if hash, err := deposit.Deposit(rp, opts); err != nil { + return nil, err + } else { + response.StakeTxHash = hash + } + + // Return response + return &response, nil + +} diff --git a/rocketpool/api/queue/status.go b/rocketpool/api/queue/status.go index b60bef223..bafa2c809 100644 --- a/rocketpool/api/queue/status.go +++ b/rocketpool/api/queue/status.go @@ -3,6 +3,7 @@ package queue import ( "github.com/rocket-pool/rocketpool-go/deposit" "github.com/rocket-pool/rocketpool-go/minipool" + "github.com/rocket-pool/rocketpool-go/settings/protocol" "github.com/urfave/cli" "golang.org/x/sync/errgroup" @@ -34,6 +35,13 @@ func getStatus(c *cli.Context) (*api.QueueStatusResponse, error) { return err }) + // Get deposit pool max capacity + wg.Go(func() error { + var err error + response.MaxDepositPoolBalance, err = protocol.GetMaximumDepositPoolSize(rp, nil) + return err + }) + // Get minipool queue length wg.Go(func() error { var err error diff --git a/shared/services/rocketpool/node.go b/shared/services/rocketpool/node.go index 4f04b0acb..d1b50ea7f 100644 --- a/shared/services/rocketpool/node.go +++ b/shared/services/rocketpool/node.go @@ -589,6 +589,38 @@ func (c *Client) DepositContractInfo() (api.DepositContractInfoResponse, error) return response, nil } +// Estimate the gas required to set a voting snapshot delegate +func (c *Client) CanStakeEth(amountWei *big.Int) (api.CanStakeEthResponse, error) { + responseBytes, err := c.callAPI(fmt.Sprintf("node can-stake-eth %s", amountWei.String())) + if err != nil { + return api.CanStakeEthResponse{}, fmt.Errorf("Could not get can node stake ETH status: %w", err) + } + var response api.CanStakeEthResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return api.CanStakeEthResponse{}, fmt.Errorf("Could not decode can node stake ETH response: %w", err) + } + if response.Error != "" { + return api.CanStakeEthResponse{}, fmt.Errorf("Could not get can node stake ETH status: %s", response.Error) + } + return response, nil +} + +// Estimate the gas required to set a voting snapshot delegate +func (c *Client) StakeEth(amountWei *big.Int) (api.StakeEthResponse, error) { + responseBytes, err := c.callAPI(fmt.Sprintf("node stake-eth %s", amountWei.String())) + if err != nil { + return api.StakeEthResponse{}, fmt.Errorf("Could not stake node's ETH: %w", err) + } + var response api.StakeEthResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return api.StakeEthResponse{}, fmt.Errorf("Could not decode node stake ETH response: %w", err) + } + if response.Error != "" { + return api.StakeEthResponse{}, fmt.Errorf("Could not stake node's ETH: %s", response.Error) + } + return response, nil +} + // Estimate the gas required to set a voting snapshot delegate func (c *Client) EstimateSetSnapshotDelegateGas(address common.Address) (api.EstimateSetSnapshotDelegateGasResponse, error) { responseBytes, err := c.callAPI(fmt.Sprintf("node estimate-set-snapshot-delegate-gas %s", address.Hex())) diff --git a/shared/types/api/node.go b/shared/types/api/node.go index 5e72f698c..f868c55b3 100644 --- a/shared/types/api/node.go +++ b/shared/types/api/node.go @@ -118,7 +118,7 @@ type CanNodeSwapRplResponse struct { Error string `json:"error"` CanSwap bool `json:"canSwap"` InsufficientBalance bool `json:"insufficientBalance"` - GasInfo rocketpool.GasInfo `json:"GasInfo"` + GasInfo rocketpool.GasInfo `json:"gasInfo"` } type NodeSwapRplApproveGasResponse struct { Status string `json:"status"` @@ -287,6 +287,23 @@ type DepositContractInfoResponse struct { SufficientSync bool `json:"sufficientSync"` } +type CanStakeEthResponse struct { + Status string `json:"status"` + Error string `json:"error"` + CanStake bool `json:"canStake"` + InsufficientBalance bool `json:"insufficientBalance"` + DepositDisabled bool `json:"depositDisabled"` + BelowMinStakeAmount bool `json:"belowMinStakeAmount"` + DepositPoolFull bool `json:"depositPoolFull"` + RethAmount *big.Int `json:"rethAmount"` + GasInfo rocketpool.GasInfo `json:"gasInfo"` +} +type StakeEthResponse struct { + Status string `json:"status"` + Error string `json:"error"` + StakeTxHash common.Hash `json:"stakeTxHash"` +} + type NodeSignResponse struct { Status string `json:"status"` Error string `json:"error"` diff --git a/shared/types/api/queue.go b/shared/types/api/queue.go index 7cb90d670..864325713 100644 --- a/shared/types/api/queue.go +++ b/shared/types/api/queue.go @@ -11,6 +11,7 @@ type QueueStatusResponse struct { Status string `json:"status"` Error string `json:"error"` DepositPoolBalance *big.Int `json:"depositPoolBalance"` + MaxDepositPoolBalance *big.Int `json:"maxDepositPoolBalance"` MinipoolQueueLength uint64 `json:"minipoolQueueLength"` MinipoolQueueCapacity *big.Int `json:"minipoolQueueCapacity"` }