diff --git a/bindings/megapool/megapool-contract.go b/bindings/megapool/megapool-contract.go index 3923066ab..81f0de7f0 100644 --- a/bindings/megapool/megapool-contract.go +++ b/bindings/megapool/megapool-contract.go @@ -193,7 +193,11 @@ func (mp *megapoolV1) GetValidatorInfo(validatorId uint32, opts *bind.CallOpts) return ValidatorInfo{}, fmt.Errorf("error creating calldata for getValidatorInfo: %w", err) } - response, err := mp.Contract.Client.CallContract(context.Background(), ethereum.CallMsg{To: mp.Contract.Address, Data: callData}, nil) + var blockNumber *big.Int + if opts != nil && opts.BlockNumber != nil { + blockNumber = opts.BlockNumber + } + response, err := mp.Contract.Client.CallContract(context.Background(), ethereum.CallMsg{To: mp.Contract.Address, Data: callData}, blockNumber) if err != nil { return ValidatorInfo{}, fmt.Errorf("error calling getValidatorInfo: %w", err) } diff --git a/bindings/network/exit.go b/bindings/network/exit.go index f58e017e8..954e754b6 100644 --- a/bindings/network/exit.go +++ b/bindings/network/exit.go @@ -165,6 +165,31 @@ func ForceMegapoolExit(rp *rocketpool.RocketPool, megapoolAddress common.Address return tx.Hash(), nil } +// Estimate the gas to exit a list of megapool validators. The caller must fund +// getExitFee() * len(validatorIds) through opts.Value. Non-owner callers must +// satisfy the contract's projected deficit check. +func EstimateExitMegapoolValidatorsGas(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorIds []uint32, opts *bind.TransactOpts) (gaslimit.Limits, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return gaslimit.Limits{}, err + } + return rocketNetworkExit.GetTransactionGasInfo(opts, "exitMegapoolValidators", megapoolAddress, validatorIds) +} + +// Exit a list of megapool validators through RocketNetworkExit. The caller +// supplies the total EIP-7002 fee in opts.Value. +func ExitMegapoolValidators(rp *rocketpool.RocketPool, megapoolAddress common.Address, validatorIds []uint32, opts *bind.TransactOpts) (common.Hash, error) { + rocketNetworkExit, err := getRocketNetworkExit(rp, nil) + if err != nil { + return common.Hash{}, err + } + tx, err := rocketNetworkExit.Transact(opts, "exitMegapoolValidators", megapoolAddress, validatorIds) + if err != nil { + return common.Hash{}, fmt.Errorf("error exiting validators for megapool %s: %w", megapoolAddress.Hex(), 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) diff --git a/bindings/settings/protocol/megapool.go b/bindings/settings/protocol/megapool.go index 4d4c37912..7fc90f842 100644 --- a/bindings/settings/protocol/megapool.go +++ b/bindings/settings/protocol/megapool.go @@ -29,6 +29,19 @@ const ( MegapoolPrestakeChallengePeriodPath string = "prestake.challenge.period" ) +// Get the deficit threshold in wei for permissionless megapool exits (Saturn 2). +func GetMegapoolExitDeficit(rp *rocketpool.RocketPool, opts *bind.CallOpts) (*big.Int, error) { + contract, err := getMegapoolSettingsContract(rp, opts) + if err != nil { + return nil, err + } + value := new(*big.Int) + if err := contract.Call(opts, value, "getExitDeficit"); err != nil { + return nil, fmt.Errorf("error getting megapool exit deficit: %w", err) + } + return *value, nil +} + // How long after an assignment a watcher must wait to dissolve a megapool validator func GetMegapoolTimeBeforeDissolve(rp *rocketpool.RocketPool, opts *bind.CallOpts) (uint64, error) { megapoolSettingsContract, err := getMegapoolSettingsContract(rp, opts) diff --git a/rocketpool-cli/megapool/commands.go b/rocketpool-cli/megapool/commands.go index f0719f8d8..f024ecfa6 100644 --- a/rocketpool-cli/megapool/commands.go +++ b/rocketpool-cli/megapool/commands.go @@ -268,6 +268,33 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { return dissolveValidator(validatorId, c.Bool("yes")) }, }, + { + Name: "exit-deficit", + Usage: "Force exits from another operator's megapool when its deficit permits (Saturn 2 only)", + UsageText: "rocketpool megapool exit-deficit megapool-address [options]", + Description: "Exits validators on another operator's megapool while its deficit still permits a permissionless exit. Exits the fewest validators that bring the deficit under the exit threshold when possible, and otherwise exits every eligible validator. Your node pays transaction gas and the EIP-7002 exit fees.", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "Automatically confirm the exits and fees", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + + // Validate args + if err := cliutils.ValidateArgCount(c, 1); err != nil { + return err + } + address, err := cliutils.ValidateAddress("megapool address", c.Args().Get(0)) + if err != nil { + return err + } + + // Run + return exitDeficit(address, c.Bool("yes")) + }, + }, { Name: "exit-validator", Aliases: []string{"t"}, diff --git a/rocketpool-cli/megapool/exit-deficit.go b/rocketpool-cli/megapool/exit-deficit.go new file mode 100644 index 000000000..ab423d6b8 --- /dev/null +++ b/rocketpool-cli/megapool/exit-deficit.go @@ -0,0 +1,86 @@ +package megapool + +import ( + "fmt" + "math/big" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + + cliutils "github.com/rocket-pool/smartnode/rocketpool-cli/cli" + "github.com/rocket-pool/smartnode/rocketpool-cli/cli/prompt" + "github.com/rocket-pool/smartnode/shared/math" + "github.com/rocket-pool/smartnode/shared/services/gas" + "github.com/rocket-pool/smartnode/shared/services/rocketpool" +) + +func exitDeficit(address common.Address, yes bool) error { + rp, err := rocketpool.NewClient().WithReady() + if err != nil { + return err + } + defer rp.Close() + + plan, err := rp.MegapoolDeficit(address) + if err != nil { + return err + } + if !plan.Saturn2Deployed { + fmt.Println(cliutils.Saturn2NotDeployedMessage) + return nil + } + formatETH := func(value *big.Int) string { + return new(big.Rat).SetFrac(value, big.NewInt(1e18)).FloatString(18) + } + fmt.Printf("Megapool: %s\n", address.Hex()) + fmt.Printf("Current deficit: %s ETH\nDeficit required to permit an exit: %s ETH\n", formatETH(plan.Deficit), formatETH(plan.ExitDeficit)) + if plan.ExistingExits > 0 { + fmt.Printf("Validators already exiting: %d\nDeficit after those exits: %s ETH\n", plan.ExistingExits, formatETH(plan.DeficitAfterPendingExits)) + } + if !plan.CanExit { + fmt.Println(plan.Reason) + return nil + } + fmt.Printf("Validators to exit: %d\nProjected deficit after these exits: %s ETH\nValidator IDs: %v\n", len(plan.ValidatorIds), formatETH(plan.ProjectedDeficit), plan.ValidatorIds) + if plan.ProjectedDeficit.Cmp(plan.ExitDeficit) >= 0 { + fmt.Println("The deficit stays at or above the amount required to permit an exit.") + } + ids := make([]string, len(plan.ValidatorIds)) + for i, id := range plan.ValidatorIds { + ids[i] = strconv.FormatUint(uint64(id), 10) + } + validatorIds := strings.Join(ids, ",") + can, err := rp.CanExitMegapoolDeficit(address, validatorIds) + if err != nil { + return err + } + if !can.CanExit { + return fmt.Errorf("the selected validators cannot be exited based on this megapool's deficit") + } + if can.ExitFee == nil { + return fmt.Errorf("the API did not return the total exit fee") + } + + exitFeeEth := math.RoundDown(math.WeiToEth(can.ExitFee), 6) + fmt.Printf("Total EIP-7002 exit fee: %.6f ETH, in addition to transaction gas.\n", exitFeeEth) + if err := gas.AssignMaxFeeAndLimit(can.GasLimits, rp, yes); err != nil { + return err + } + if prompt.Declined(yes, "Force exit %d validators from megapool %s? This pays %.6f ETH in EIP-7002 exit fees plus gas.", len(can.ValidatorIds), address.Hex(), exitFeeEth) { + fmt.Println("Cancelled.") + return nil + } + + result, err := rp.ExitMegapoolDeficit(address, validatorIds, can.ExitFee) + if err != nil { + return err + } + fmt.Println("Submitting the deficit-triggered exits...") + cliutils.PrintTransactionHash(rp, result.TxHash) + if _, err := rp.WaitForTransaction(result.TxHash); err != nil { + return err + } + fmt.Printf("Successfully submitted exit requests for %d validators. Beacon-chain exit processing is pending.\n", len(can.ValidatorIds)) + return nil +} diff --git a/rocketpool/api/megapool/deficit.go b/rocketpool/api/megapool/deficit.go new file mode 100644 index 000000000..3d3807d4a --- /dev/null +++ b/rocketpool/api/megapool/deficit.go @@ -0,0 +1,293 @@ +package megapool + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/urfave/cli/v3" + "golang.org/x/sync/errgroup" + + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/node" + "github.com/rocket-pool/smartnode/bindings/settings/protocol" + "github.com/rocket-pool/smartnode/rocketpool/api/response" + "github.com/rocket-pool/smartnode/rocketpool/api/snroute" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/state" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +func getDeficit(c *cli.Command, address common.Address) (*api.MegapoolDeficitResponse, error) { + if err := services.RequireRocketStorage(c); err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + // Read all inputs at one block so the displayed deficit and selected count + // describe the same state. Estimation and submission recheck current state. + header, err := rp.Client.HeaderByNumber(context.Background(), nil) + if err != nil { + return nil, err + } + opts := &bind.CallOpts{BlockNumber: header.Number} + saturn2Deployed, err := state.IsSaturn2Deployed(rp, opts) + if err != nil { + return nil, fmt.Errorf("error checking if Saturn 2 is deployed: %w", err) + } + if !saturn2Deployed { + return &api.MegapoolDeficitResponse{Saturn2Deployed: false}, nil + } + mp, err := megapool.NewMegapool(rp, address, opts) + if err != nil { + return nil, err + } + if mp.GetVersion() < 2 { + return nil, fmt.Errorf("megapool %s must upgrade to delegate version 2 or later to support forced exits", address.Hex()) + } + nodeAddress, err := mp.GetNodeAddress(opts) + if err != nil { + return nil, err + } + var debt, bond, queuedBond, refund, creditAndBalance, threshold *big.Int + var rewards megapool.RewardSplit + var active, exiting uint32 + var wg errgroup.Group + wg.Go(func() error { + var err error + debt, err = mp.GetDebt(opts) + return err + }) + wg.Go(func() error { + var err error + bond, err = mp.GetNodeBond(opts) + return err + }) + wg.Go(func() error { + var err error + queuedBond, err = mp.GetNodeQueuedBond(opts) + return err + }) + wg.Go(func() error { + var err error + refund, err = mp.GetRefundValue(opts) + return err + }) + wg.Go(func() error { + var err error + rewards, err = mp.CalculatePendingRewards(opts) + return err + }) + wg.Go(func() error { + var err error + active, err = mp.GetActiveValidatorCount(opts) + return err + }) + wg.Go(func() error { + var err error + exiting, err = mp.GetExitingValidatorCount(opts) + return err + }) + wg.Go(func() error { + var err error + creditAndBalance, err = node.GetNodeCreditAndBalance(rp, nodeAddress, opts) + return err + }) + wg.Go(func() error { + var err error + threshold, err = protocol.GetMegapoolExitDeficit(rp, opts) + return err + }) + if err := wg.Wait(); err != nil { + return nil, err + } + assets := new(big.Int).Add(creditAndBalance, rewards.NodeRewards) + assets.Add(assets, refund) + bondRequirement := func(remaining uint32) (*big.Int, error) { + return node.GetBondRequirement(rp, new(big.Int).SetUint64(uint64(remaining)), opts) + } + result, err := planDeficitExits(debt, assets, bond, queuedBond, threshold, active, exiting, bondRequirement) + if err != nil { + return nil, err + } + result.Saturn2Deployed = true + if !result.CanExit { + return result, nil + } + ids, err := firstDeficitExitValidators(mp, result.ValidatorsRequired, opts) + if err != nil { + return nil, err + } + if err := applyEligibleValidators(result, ids, func(additional uint32) (*big.Int, bool, error) { + return projectDeficitExit(debt, assets, bond, queuedBond, threshold, active, exiting, additional, bondRequirement) + }); err != nil { + return nil, err + } + return result, nil +} + +// planDeficitExits selects a validator count RocketNetworkExit will accept. +// A permissionless caller may exit while the deficit before the last requested +// exit is still at least the exit threshold, so the last exit may cross below +// it. The smallest count that does cross below is also the largest count the +// contract accepts. When no count crosses below, every remaining validator is +// still eligible and is selected. +func planDeficitExits(debt, assets, bond, queuedBond, threshold *big.Int, active, exiting uint32, bondRequirement func(uint32) (*big.Int, error)) (*api.MegapoolDeficitResponse, error) { + if exiting > active { + return nil, fmt.Errorf("exiting validator count exceeds active validator count") + } + project := func(additional uint32) (*big.Int, bool, error) { + return projectDeficitExit(debt, assets, bond, queuedBond, threshold, active, exiting, additional, bondRequirement) + } + current := new(big.Int).Sub(debt, assets) + if current.Sign() < 0 { + current.SetInt64(0) + } + result := &api.MegapoolDeficitResponse{ + Deficit: current, ExitDeficit: new(big.Int).Set(threshold), ExistingExits: exiting, + } + pending, pendingBelow, err := project(0) + if err != nil { + return nil, err + } + result.DeficitAfterPendingExits = pending + result.ProjectedDeficit = pending + if pendingBelow { + result.Reason = "No additional exits are permitted; the deficit is already below the amount required for a permissionless exit." + return result, nil + } + maximum := active - exiting + if maximum == 0 { + result.Reason = "No active validators remain to exit." + return result, nil + } + lowest, lowestBelow, err := project(maximum) + if err != nil { + return nil, err + } + if !lowestBelow { + result.ValidatorsRequired = maximum + result.ProjectedDeficit = lowest + result.CanExit = true + return result, nil + } + // Bond requirements are monotonic, so the minimum count that crosses below + // the threshold can be found without one query per validator. + low, high := uint32(1), maximum + for low < high { + mid := low + (high-low)/2 + _, below, err := project(mid) + if err != nil { + return nil, err + } + if below { + high = mid + } else { + low = mid + 1 + } + } + result.ValidatorsRequired = low + result.ProjectedDeficit, _, err = project(low) + if err != nil { + return nil, err + } + result.CanExit = true + return result, nil +} + +// Queued bond is included because the active validator count includes queued +// validators. Release is clamped at zero when underbonded, then capped by +// exiting principal and by the node bond. +func releasedNodeBond(bond, queuedBond, required *big.Int, totalExiting uint32) *big.Int { + released := new(big.Int).Add(bond, queuedBond) + released.Sub(released, required) + if released.Sign() < 0 { + return new(big.Int) + } + principal := new(big.Int).Mul(new(big.Int).SetUint64(uint64(totalExiting)), new(big.Int).Mul(big.NewInt(32), big.NewInt(1e18))) + if released.Cmp(principal) > 0 { + released.Set(principal) + } + if released.Cmp(bond) > 0 { + released.Set(bond) + } + return released +} + +// projectDeficitExit reports the clamped deficit after additional exits and +// whether the unclamped value is strictly under the exit threshold. +func projectDeficitExit(debt, assets, bond, queuedBond, threshold *big.Int, active, exiting, additional uint32, bondRequirement func(uint32) (*big.Int, error)) (*big.Int, bool, error) { + required, err := bondRequirement(active - (exiting + additional)) + if err != nil { + return nil, false, err + } + raw := new(big.Int).Sub(debt, assets) + raw.Sub(raw, releasedNodeBond(bond, queuedBond, required, exiting+additional)) + below := raw.Cmp(threshold) < 0 + if raw.Sign() < 0 { + raw.SetInt64(0) + } + return raw, below, nil +} + +// applyEligibleValidators shortens the plan when fewer validators can be +// force-exited than the count derived from the active-validator total. A +// shorter positive count still passes the contract's pre-last check unless +// the bond curve is not monotonic. +func applyEligibleValidators(result *api.MegapoolDeficitResponse, ids []uint32, project func(uint32) (*big.Int, bool, error)) error { + result.ValidatorIds = ids + eligible := uint32(len(ids)) + if !result.CanExit || eligible >= result.ValidatorsRequired { + return nil + } + if eligible > 0 { + _, below, err := project(eligible - 1) + if err != nil { + return err + } + if !below { + projected, _, err := project(eligible) + if err != nil { + return err + } + result.ValidatorsRequired = eligible + result.ProjectedDeficit = projected + return nil + } + } + result.CanExit = false + result.Reason = fmt.Sprintf("%d validator exits are required, but only %d validators are eligible for forced exit.", result.ValidatorsRequired, eligible) + return nil +} + +func firstDeficitExitValidators(mp megapool.Megapool, count uint32, opts *bind.CallOpts) ([]uint32, error) { + total, err := mp.GetValidatorCount(opts) + if err != nil { + return nil, err + } + ids := []uint32{} + for id := uint32(0); id < total && uint32(len(ids)) < count; id++ { + info, err := mp.GetValidatorInfo(id, opts) + if err != nil { + return nil, err + } + if info.Staked && !info.Dissolved && !info.Exiting && !info.Exited { + ids = append(ids, id) + } + } + return ids, nil +} + +func deficitHandler(ctx snroute.Context) { + address, err := parseDeficitMegapoolAddress(ctx.Request) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + result, err := getDeficit(ctx.Command(), address) + response.WriteResponse(ctx.Writer, result, err) +} diff --git a/rocketpool/api/megapool/deficit_test.go b/rocketpool/api/megapool/deficit_test.go new file mode 100644 index 000000000..28bf1411b --- /dev/null +++ b/rocketpool/api/megapool/deficit_test.go @@ -0,0 +1,171 @@ +package megapool + +import ( + "errors" + "math/big" + "reflect" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +func deficitTestWei(eth string) *big.Int { + value, ok := new(big.Rat).SetString(eth) + if !ok { + panic("invalid test amount") + } + value.Mul(value, new(big.Rat).SetInt(big.NewInt(1e18))) + if !value.IsInt() { + panic("test amount is not an integer wei value") + } + return new(big.Int).Set(value.Num()) +} + +func TestPlanDeficitExits(t *testing.T) { + for _, tc := range []struct { + name string + debt, assets, bond, queued string + active, exiting, wantCount uint32 + wantBefore, wantAfter string + wantCanExit bool + }{ + {"below threshold", "0.1", "0", "16", "0", 4, 0, 0, "0.1", "0.1", false}, + {"at threshold", "0.2", "0", "16", "0", 4, 0, 1, "0.2", "0", true}, + {"strictly below threshold", "4.2", "0", "16", "0", 4, 0, 2, "4.2", "0", true}, + {"assets offset debt", "4.4", "0.3", "16", "0", 4, 0, 1, "4.1", "0.1", true}, + {"assets exceed debt", "1", "2", "16", "0", 4, 0, 0, "0", "0", false}, + {"pending exits already enough", "4.1", "0", "16", "0", 4, 1, 0, "0.1", "0.1", false}, + {"pending exits counted once", "4.2", "0", "16", "0", 4, 1, 1, "0.2", "0", true}, + {"underbonded release clamped", "1", "0", "6", "0", 3, 0, 2, "1", "0", true}, + {"queued bond included", "1", "0", "8", "4", 3, 0, 1, "1", "0", true}, + {"principal release capped", "32.2", "0", "100", "0", 3, 0, 2, "32.2", "0", true}, + {"release capped at node bond", "1.2", "0", "1", "100", 3, 0, 3, "1.2", "0.2", true}, + {"deficit remains above threshold", "10", "0", "4", "0", 1, 0, 1, "10", "6", true}, + {"exit every validator while above threshold", "10", "0", "4", "0", 3, 0, 3, "10", "6", true}, + {"no validators", "1", "0", "0", "0", 0, 0, 0, "1", "1", false}, + } { + t.Run(tc.name, func(t *testing.T) { + debt, assets, bond, queued := deficitTestWei(tc.debt), deficitTestWei(tc.assets), deficitTestWei(tc.bond), deficitTestWei(tc.queued) + plan, err := planDeficitExits(debt, assets, bond, queued, deficitTestWei("0.2"), tc.active, tc.exiting, func(count uint32) (*big.Int, error) { + return new(big.Int).Mul(new(big.Int).SetUint64(uint64(count)), deficitTestWei("4")), nil + }) + if err != nil { + t.Fatal(err) + } + if plan.CanExit != tc.wantCanExit || plan.ValidatorsRequired != tc.wantCount { + t.Fatalf("got canExit=%v count=%d, want %v %d (%s)", plan.CanExit, plan.ValidatorsRequired, tc.wantCanExit, tc.wantCount, plan.Reason) + } + if plan.DeficitAfterPendingExits.Cmp(deficitTestWei(tc.wantBefore)) != 0 || plan.ProjectedDeficit.Cmp(deficitTestWei(tc.wantAfter)) != 0 { + t.Fatalf("got before=%s after=%s, want %s ETH and %s ETH", plan.DeficitAfterPendingExits, plan.ProjectedDeficit, tc.wantBefore, tc.wantAfter) + } + if debt.Cmp(deficitTestWei(tc.debt)) != 0 || assets.Cmp(deficitTestWei(tc.assets)) != 0 || bond.Cmp(deficitTestWei(tc.bond)) != 0 || queued.Cmp(deficitTestWei(tc.queued)) != 0 { + t.Fatal("planning mutated its input balances") + } + }) + } +} + +func TestPlanDeficitExitsUsesBondCurve(t *testing.T) { + curve := []string{"0", "4", "8", "10", "12"} + plan, err := planDeficitExits(deficitTestWei("2.2"), new(big.Int), deficitTestWei("12"), new(big.Int), deficitTestWei("0.2"), 4, 0, func(count uint32) (*big.Int, error) { + return deficitTestWei(curve[count]), nil + }) + if err != nil || plan.ValidatorsRequired != 2 { + t.Fatalf("expected two exits across the bond curve, got %+v, %v", plan, err) + } +} + +func TestPlanDeficitExitsErrors(t *testing.T) { + wantErr := errors.New("bond query failed") + _, err := planDeficitExits(deficitTestWei("1"), new(big.Int), deficitTestWei("4"), new(big.Int), deficitTestWei("0.2"), 1, 0, func(uint32) (*big.Int, error) { + return nil, wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("got %v, want bond query error", err) + } + _, err = planDeficitExits(nil, nil, nil, nil, nil, 1, 2, nil) + if err == nil { + t.Fatal("expected inconsistent counts to be rejected") + } +} + +func TestApplyEligibleValidators(t *testing.T) { + project := func(additional uint32) (*big.Int, bool, error) { + if additional == 0 { + return deficitTestWei("10"), false, nil + } + return deficitTestWei("6"), false, nil + } + result := &api.MegapoolDeficitResponse{ + CanExit: true, ValidatorsRequired: 3, ProjectedDeficit: deficitTestWei("0"), DeficitAfterPendingExits: deficitTestWei("10"), + } + if err := applyEligibleValidators(result, []uint32{4}, project); err != nil { + t.Fatal(err) + } + if !result.CanExit || result.ValidatorsRequired != 1 || len(result.ValidatorIds) != 1 || result.ValidatorIds[0] != 4 || result.ProjectedDeficit.Cmp(deficitTestWei("6")) != 0 { + t.Fatalf("shorter list was not accepted: %+v", result) + } + + refused := &api.MegapoolDeficitResponse{CanExit: true, ValidatorsRequired: 2, ProjectedDeficit: deficitTestWei("0")} + if err := applyEligibleValidators(refused, []uint32{}, func(uint32) (*big.Int, bool, error) { + return deficitTestWei("0"), true, nil + }); err != nil || refused.CanExit || refused.ValidatorsRequired != 2 { + t.Fatalf("empty eligible set should refuse the planned count, got %+v, %v", refused, err) + } + + disallowed := &api.MegapoolDeficitResponse{CanExit: true, ValidatorsRequired: 3, ProjectedDeficit: deficitTestWei("0")} + if err := applyEligibleValidators(disallowed, []uint32{1}, func(additional uint32) (*big.Int, bool, error) { + return deficitTestWei("0.1"), additional == 0, nil + }); err != nil || disallowed.CanExit || disallowed.ValidatorsRequired != 3 { + t.Fatalf("shorter list below the floor should be refused, got %+v, %v", disallowed, err) + } +} + +type deficitValidatorPool struct { + megapool.Megapool + validators []megapool.ValidatorInfo +} + +func (mp *deficitValidatorPool) GetValidatorCount(opts *bind.CallOpts) (uint32, error) { + if err := requirePinnedBlock(opts); err != nil { + return 0, err + } + return uint32(len(mp.validators)), nil +} + +func (mp *deficitValidatorPool) GetValidatorInfo(id uint32, opts *bind.CallOpts) (megapool.ValidatorInfo, error) { + if err := requirePinnedBlock(opts); err != nil { + return megapool.ValidatorInfo{}, err + } + return mp.validators[id], nil +} + +func requirePinnedBlock(opts *bind.CallOpts) error { + if opts == nil || opts.BlockNumber == nil || opts.BlockNumber.Cmp(big.NewInt(123)) != 0 { + return errors.New("validator info was not read at the pinned block") + } + return nil +} + +func TestFirstDeficitExitValidators(t *testing.T) { + mp := &deficitValidatorPool{validators: []megapool.ValidatorInfo{ + {InQueue: true}, + {Staked: true, Exiting: true}, + {Staked: true}, + {Staked: true, Dissolved: true}, + {Staked: true, Exited: true}, + {Staked: true, Locked: true}, // forceExit also unlocks the validator. + {Staked: true}, + }} + for _, tc := range []struct { + count uint32 + want []uint32 + }{{2, []uint32{2, 5}}, {4, []uint32{2, 5, 6}}, {0, []uint32{}}} { + ids, err := firstDeficitExitValidators(mp, tc.count, &bind.CallOpts{BlockNumber: big.NewInt(123)}) + if err != nil || !reflect.DeepEqual(ids, tc.want) { + t.Fatalf("count %d: got %v, %v; want %v", tc.count, ids, err, tc.want) + } + } +} diff --git a/rocketpool/api/megapool/exit-deficit.go b/rocketpool/api/megapool/exit-deficit.go new file mode 100644 index 000000000..7974f938a --- /dev/null +++ b/rocketpool/api/megapool/exit-deficit.go @@ -0,0 +1,199 @@ +package megapool + +import ( + "fmt" + "math/big" + "net/http" + "strconv" + "strings" + + "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/network" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/bindings/storage" + "github.com/rocket-pool/smartnode/rocketpool/api/response" + "github.com/rocket-pool/smartnode/rocketpool/api/snroute" + "github.com/rocket-pool/smartnode/shared/services" + "github.com/rocket-pool/smartnode/shared/services/state" + "github.com/rocket-pool/smartnode/shared/types/api" +) + +func checkSaturn2Deployed(rp *rocketpool.RocketPool, opts *bind.CallOpts) error { + saturn2Deployed, err := state.IsSaturn2Deployed(rp, opts) + if err != nil { + return fmt.Errorf("error checking if Saturn 2 is deployed: %w", err) + } + if !saturn2Deployed { + return fmt.Errorf("deficit exits are not available until Saturn 2 is deployed") + } + return nil +} + +// prepareDeficitExit is shared by estimation and submission so a direct POST +// cannot use the contract's owner exemption. +func prepareDeficitExit(rp *rocketpool.RocketPool, address common.Address, validatorIds []uint32, opts *bind.TransactOpts) error { + if address == (common.Address{}) || len(validatorIds) == 0 { + return fmt.Errorf("a megapool address and at least one validator ID are required") + } + mp, err := megapool.NewMegapool(rp, address, nil) + if err != nil { + return err + } + if mp.GetVersion() < 2 { + return fmt.Errorf("megapool %s must upgrade to delegate version 2 or later to support forced exits", address.Hex()) + } + nodeAddress, err := mp.GetNodeAddress(nil) + if err != nil { + return err + } + withdrawalAddress, err := storage.GetNodeWithdrawalAddress(rp, nodeAddress, nil) + if err != nil { + return err + } + if opts.From == nodeAddress || opts.From == withdrawalAddress { + return fmt.Errorf("deficit exits must be submitted by a caller other than the target node or its withdrawal address; the contract bypasses the deficit check for these addresses") + } + fee, err := network.GetExitFee(rp, nil) + if err != nil { + return err + } + opts.Value = new(big.Int).Mul(fee, new(big.Int).SetUint64(uint64(len(validatorIds)))) + return nil +} + +func canExitDeficit(c *cli.Command, address common.Address, validatorIds []uint32) (*api.CanExitMegapoolDeficitResponse, error) { + if err := services.RequireNodeWallet(c); err != nil { + return nil, err + } + if err := services.RequireRocketStorage(c); err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + opts, err := w.GetNodeAccountTransactor() + if err != nil { + return nil, err + } + return estimateDeficitExit(rp, address, validatorIds, opts) +} + +func estimateDeficitExit(rp *rocketpool.RocketPool, address common.Address, validatorIds []uint32, opts *bind.TransactOpts) (*api.CanExitMegapoolDeficitResponse, error) { + result := &api.CanExitMegapoolDeficitResponse{ValidatorIds: validatorIds} + if err := checkSaturn2Deployed(rp, nil); err != nil { + return nil, err + } + if err := prepareDeficitExit(rp, address, validatorIds, opts); err != nil { + return nil, err + } + result.ExitFee = opts.Value + // The contract checks registration, validator eligibility, and the projected + // deficit including existing exits and the requested list size. + var err error + result.GasLimits, err = network.EstimateExitMegapoolValidatorsGas(rp, address, validatorIds, opts) + if err != nil { + return nil, fmt.Errorf("cannot exit the selected validators: %w", err) + } + result.CanExit = true + return result, nil +} + +func exitDeficit(c *cli.Command, address common.Address, validatorIds []uint32, maxExitFee *big.Int, t *snroute.TransactOpts) (*api.ExitMegapoolDeficitResponse, error) { + if err := services.RequireNodeWallet(c); err != nil { + return nil, err + } + if err := services.RequireRocketStorage(c); err != nil { + return nil, err + } + rp, err := services.GetRocketPool(c) + if err != nil { + return nil, err + } + return submitDeficitExit(rp, address, validatorIds, maxExitFee, t.Opts()) +} + +func submitDeficitExit(rp *rocketpool.RocketPool, address common.Address, validatorIds []uint32, maxExitFee *big.Int, opts *bind.TransactOpts) (*api.ExitMegapoolDeficitResponse, error) { + if err := checkSaturn2Deployed(rp, nil); err != nil { + return nil, err + } + if err := prepareDeficitExit(rp, address, validatorIds, opts); err != nil { + return nil, err + } + if maxExitFee == nil || maxExitFee.Sign() < 0 || opts.Value.Cmp(maxExitFee) > 0 { + return nil, fmt.Errorf("current total exit fee exceeds the approved maximum; estimate and confirm the exit again") + } + hash, err := network.ExitMegapoolValidators(rp, address, validatorIds, opts) + if err != nil { + return nil, err + } + return &api.ExitMegapoolDeficitResponse{TxHash: hash}, nil +} + +func parseDeficitMegapoolAddress(r *http.Request) (common.Address, error) { + rawAddress := strings.TrimSpace(r.FormValue("megapoolAddress")) + if !common.IsHexAddress(rawAddress) || common.HexToAddress(rawAddress) == (common.Address{}) { + return common.Address{}, &response.BadRequestError{Err: fmt.Errorf("a valid nonzero megapoolAddress is required")} + } + return common.HexToAddress(rawAddress), nil +} + +func parseDeficitExitParams(r *http.Request) (common.Address, []uint32, error) { + address, err := parseDeficitMegapoolAddress(r) + if err != nil { + return common.Address{}, nil, err + } + parts := strings.Split(r.FormValue("validatorIds"), ",") + ids := make([]uint32, 0, len(parts)) + seen := make(map[uint32]bool, len(parts)) + for _, part := range parts { + id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32) + if err != nil { + return common.Address{}, nil, &response.BadRequestError{Err: fmt.Errorf("invalid validator ID %q: expected comma-separated uint32 IDs", part)} + } + if seen[uint32(id)] { + return common.Address{}, nil, &response.BadRequestError{Err: fmt.Errorf("duplicate validator ID %d", id)} + } + seen[uint32(id)] = true + ids = append(ids, uint32(id)) + } + return address, ids, nil +} + +func canExitDeficitHandler(ctx snroute.Context) { + address, ids, err := parseDeficitExitParams(ctx.Request) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + result, err := canExitDeficit(ctx.Command(), address, ids) + response.WriteResponse(ctx.Writer, result, err) +} + +func exitDeficitHandler(ctx snroute.WriteContext) { + address, ids, err := parseDeficitExitParams(ctx.Request) + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + maxExitFee, ok := new(big.Int).SetString(ctx.Request.FormValue("maxExitFee"), 10) + if !ok || maxExitFee.Sign() < 0 { + response.WriteErrorResponse(ctx.Writer, &response.BadRequestError{Err: fmt.Errorf("maxExitFee must be a non-negative total exit fee in wei")}) + return + } + opts, err := ctx.Transactor() + if err != nil { + response.WriteErrorResponse(ctx.Writer, err) + return + } + result, err := exitDeficit(ctx.Command(), address, ids, maxExitFee, opts) + response.WriteResponse(ctx.Writer, result, err) +} diff --git a/rocketpool/api/megapool/routes.go b/rocketpool/api/megapool/routes.go index 129af9d50..8e69be8f6 100644 --- a/rocketpool/api/megapool/routes.go +++ b/rocketpool/api/megapool/routes.go @@ -31,6 +31,9 @@ func RegisterRoutes(router *snroute.Router) { snroute.Write("/api/megapool/dissolve-with-proof", dissolveWithProofHandler).RegisterTo(router) snroute.Read("/api/megapool/can-exit-validator", canExitValidatorHandler).RegisterTo(router) snroute.Write("/api/megapool/exit-validator", exitValidatorHandler).RegisterTo(router) + snroute.Read("/api/megapool/can-exit-deficit", canExitDeficitHandler).RegisterTo(router) + snroute.Read("/api/megapool/deficit", deficitHandler).RegisterTo(router) + snroute.Write("/api/megapool/exit-deficit", exitDeficitHandler).RegisterTo(router) snroute.Read("/api/megapool/can-notify-validator-exit", canNotifyValidatorExitHandler).RegisterTo(router) snroute.Write("/api/megapool/notify-validator-exit", notifyValidatorExitHandler).RegisterTo(router) snroute.Read("/api/megapool/can-notify-final-balance", canNotifyFinalBalanceHandler).RegisterTo(router) diff --git a/shared/services/rocketpool/megapool.go b/shared/services/rocketpool/megapool.go index 2ef1769f2..0c97eb9b4 100644 --- a/shared/services/rocketpool/megapool.go +++ b/shared/services/rocketpool/megapool.go @@ -105,6 +105,36 @@ func (c *Client) ChallengeMegapoolPerformance(megapoolAddress common.Address, va return response, nil } +// MegapoolDeficit returns the deficit and the first eligible validators needed +// to bring it below the exit threshold, accounting for pending exits. +func (c *Client) MegapoolDeficit(megapoolAddress common.Address) (api.MegapoolDeficitResponse, error) { + return c.callAPI[api.MegapoolDeficitResponse]("GET", "/api/megapool/deficit", url.Values{ + "megapoolAddress": {megapoolAddress.Hex()}, + }, "Could not get megapool deficit") +} + +// CanExitMegapoolDeficit estimates a caller-funded deficit exit. validatorIds +// is a comma-separated list of internal megapool validator IDs. +func (c *Client) CanExitMegapoolDeficit(megapoolAddress common.Address, validatorIds string) (api.CanExitMegapoolDeficitResponse, error) { + return c.callAPI[api.CanExitMegapoolDeficitResponse]("GET", "/api/megapool/can-exit-deficit", url.Values{ + "megapoolAddress": {megapoolAddress.Hex()}, + "validatorIds": {validatorIds}, + }, "Could not estimate megapool deficit exit") +} + +// ExitMegapoolDeficit submits a deficit exit, capped at the total EIP-7002 fee +// approved during preflight. Transaction gas is configured separately. +func (c *Client) ExitMegapoolDeficit(megapoolAddress common.Address, validatorIds string, maxExitFee *big.Int) (api.ExitMegapoolDeficitResponse, error) { + if maxExitFee == nil || maxExitFee.Sign() < 0 { + return api.ExitMegapoolDeficitResponse{}, fmt.Errorf("a non-negative maximum exit fee is required") + } + return c.callAPI[api.ExitMegapoolDeficitResponse]("POST", "/api/megapool/exit-deficit", url.Values{ + "megapoolAddress": {megapoolAddress.Hex()}, + "validatorIds": {validatorIds}, + "maxExitFee": {maxExitFee.String()}, + }, "Could not submit megapool deficit exit") +} + // Get megapool status func (c *Client) MegapoolStatus(finalizedState bool) (api.MegapoolStatusResponse, error) { finalizedStr := "false" diff --git a/shared/types/api/megapool.go b/shared/types/api/megapool.go index aff5ae461..658cce126 100644 --- a/shared/types/api/megapool.go +++ b/shared/types/api/megapool.go @@ -14,6 +14,33 @@ import ( "github.com/rocket-pool/smartnode/shared/services/beacon" ) +type MegapoolDeficitResponse struct { + APIResponse + Saturn2Deployed bool `json:"saturn2Deployed"` + Deficit *big.Int `json:"deficit"` + ExitDeficit *big.Int `json:"exitDeficit"` + DeficitAfterPendingExits *big.Int `json:"deficitAfterPendingExits"` + ProjectedDeficit *big.Int `json:"projectedDeficit"` + ExistingExits uint32 `json:"existingExits"` + ValidatorsRequired uint32 `json:"validatorsRequired"` + ValidatorIds []uint32 `json:"validatorIds"` + CanExit bool `json:"canExit"` + Reason string `json:"reason"` +} + +type CanExitMegapoolDeficitResponse struct { + APIResponse + CanExit bool `json:"canExit"` + ValidatorIds []uint32 `json:"validatorIds"` + ExitFee *big.Int `json:"exitFee"` // Total EIP-7002 fee in wei, excluding transaction gas. + GasLimits gaslimit.Limits `json:"gasLimits"` +} + +type ExitMegapoolDeficitResponse struct { + APIResponse + TxHash common.Hash `json:"txHash"` +} + type MegapoolStatusResponse struct { APIResponse Megapool MegapoolDetails `json:"megapoolDetails"`