Skip to content

Commit f58315d

Browse files
authored
Merge pull request #1112 from rocket-pool/bundle-distribution
Add Flashbots bundle support for minipool close
2 parents 6604659 + f5aa3fc commit f58315d

12 files changed

Lines changed: 1227 additions & 11 deletions

File tree

bindings/minipool/minipool-contract-v3.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type MinipoolV3 interface {
3333
GetUserDistributed(opts *bind.CallOpts) (bool, error)
3434
EstimateDistributeBalanceGas(rewardsOnly bool, opts *bind.TransactOpts) (rocketpool.GasInfo, error)
3535
DistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (common.Hash, error)
36+
PrepareDistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (*types.Transaction, error)
3637
}
3738

3839
// Minipool contract
@@ -388,6 +389,20 @@ func (mp *minipool_v3) DistributeBalance(rewardsOnly bool, opts *bind.TransactOp
388389
return tx.Hash(), nil
389390
}
390391

392+
// PrepareDistributeBalance is like DistributeBalance but forces NoSend and returns the signed transaction
393+
// (instead of sending it). Useful for assembling Flashbots bundles.
394+
func (mp *minipool_v3) PrepareDistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (*types.Transaction, error) {
395+
if opts == nil {
396+
opts = &bind.TransactOpts{}
397+
}
398+
opts.NoSend = true
399+
tx, err := mp.Contract.Transact(opts, "distributeBalance", rewardsOnly)
400+
if err != nil {
401+
return nil, fmt.Errorf("error preparing distribute tx for minipool %s: %w", mp.Address.Hex(), err)
402+
}
403+
return tx, nil
404+
}
405+
391406
// Estimate the gas of Stake
392407
func (mp *minipool_v3) EstimateStakeGas(validatorSignature rptypes.ValidatorSignature, depositDataRoot common.Hash, opts *bind.TransactOpts) (rocketpool.GasInfo, error) {
393408
return mp.Contract.GetTransactionGasInfo(opts, "stake", validatorSignature[:], depositDataRoot)

bindings/node/distributor.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/ethereum/go-ethereum/accounts/abi/bind"
99
"github.com/ethereum/go-ethereum/common"
10+
"github.com/ethereum/go-ethereum/core/types"
1011

1112
"github.com/rocket-pool/smartnode/bindings/rocketpool"
1213
)
@@ -62,6 +63,20 @@ func (d *Distributor) Distribute(opts *bind.TransactOpts) (common.Hash, error) {
6263
return tx.Hash(), nil
6364
}
6465

66+
// PrepareDistribute is like Distribute but forces NoSend and returns the signed transaction
67+
// (instead of sending it). Useful for assembling Flashbots bundles.
68+
func (d *Distributor) PrepareDistribute(opts *bind.TransactOpts) (*types.Transaction, error) {
69+
if opts == nil {
70+
opts = &bind.TransactOpts{}
71+
}
72+
opts.NoSend = true
73+
tx, err := d.Contract.Transact(opts, "distribute")
74+
if err != nil {
75+
return nil, fmt.Errorf("error preparing distribute tx for distributor %s: %w", d.Address.Hex(), err)
76+
}
77+
return tx, nil
78+
}
79+
6580
// Gets the node share of the distributor's current balance
6681
func (d *Distributor) GetNodeShare(opts *bind.CallOpts) (*big.Int, error) {
6782
nodeShare := new(*big.Int)

rocketpool-cli/minipool/close.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import (
2121
"github.com/rocket-pool/smartnode/shared/utils/math"
2222
)
2323

24-
func closeMinipools(minipool string, confirmSlashing, yes bool) error {
24+
func closeMinipools(minipool string, confirmSlashing, yes, bundle bool) error {
2525

2626
// Get RP client
2727
rp, err := rocketpool.NewClient().WithReady()
@@ -272,7 +272,7 @@ func closeMinipools(minipool string, confirmSlashing, yes bool) error {
272272
// Close minipools
273273
for _, minipool := range selectedMinipools {
274274

275-
response, err := rp.CloseMinipool(minipool.Address)
275+
response, err := rp.CloseMinipool(minipool.Address, bundle)
276276
if err != nil {
277277
fmt.Printf("Could not close minipool %s: %s.\n", minipool.Address.Hex(), err.Error())
278278
continue

rocketpool-cli/minipool/commands.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,11 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) {
265265
Name: "confirm-slashing",
266266
Usage: "Reserved for acknowledging situations where you've been slashed by the Beacon Chain, and closing a minipool will result in the complete loss of the ETH bond and your RPL collateral. DO NOT use this flag unless you have been explicitly instructed to do so.",
267267
},
268+
&cli.BoolFlag{
269+
Name: "bundle",
270+
Aliases: []string{"b"},
271+
Usage: "Force closing via a Flashbots bundle (distribute + finalise). Without this flag, a bundle is still used automatically if your fee distributor has a balance. Mainnet only.",
272+
},
268273
},
269274
Action: func(ctx context.Context, c *cli.Command) error {
270275

@@ -281,7 +286,7 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) {
281286
}
282287

283288
// Run
284-
return closeMinipools(c.String("minipool"), c.Bool("confirm-slashing"), c.Bool("yes"))
289+
return closeMinipools(c.String("minipool"), c.Bool("confirm-slashing"), c.Bool("yes"), c.Bool("bundle"))
285290

286291
},
287292
},

rocketpool/api/minipool/close.go

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/ethereum/go-ethereum/accounts/abi/bind"
99
"github.com/ethereum/go-ethereum/common"
10+
gethtypes "github.com/ethereum/go-ethereum/core/types"
1011
"github.com/urfave/cli/v3"
1112
"golang.org/x/sync/errgroup"
1213

@@ -18,9 +19,13 @@ import (
1819

1920
"github.com/rocket-pool/smartnode/shared/services"
2021
"github.com/rocket-pool/smartnode/shared/services/beacon"
22+
"github.com/rocket-pool/smartnode/shared/services/flashbots"
2123
"github.com/rocket-pool/smartnode/shared/types/api"
2224
)
2325

26+
// Gas can't be estimated against current chain state (the fee distributor's distribute() in the bundle hasn't executed yet)
27+
const distributeBalanceBundleGasLimit uint64 = 600000
28+
2429
func getMinipoolCloseDetailsForNode(c *cli.Command) (*api.GetMinipoolCloseDetailsForNodeResponse, error) {
2530

2631
// Get services
@@ -307,7 +312,7 @@ func getMinipoolCloseDetails(rp *rocketpool.RocketPool, minipoolAddress common.A
307312

308313
}
309314

310-
func closeMinipool(c *cli.Command, minipoolAddress common.Address, opts *bind.TransactOpts) (*api.CloseMinipoolResponse, error) {
315+
func closeMinipool(c *cli.Command, minipoolAddress common.Address, opts *bind.TransactOpts, bundle bool) (*api.CloseMinipoolResponse, error) {
311316

312317
// Get services
313318
if err := services.RequireNodeRegistered(c); err != nil {
@@ -373,12 +378,91 @@ func closeMinipool(c *cli.Command, minipoolAddress common.Address, opts *bind.Tr
373378
}
374379
response.TxHash = hash
375380
} else {
376-
// Do a distribution, which will finalize it
377-
hash, err := mpv3.DistributeBalance(false, opts)
381+
cfg, err := services.GetConfig(c)
378382
if err != nil {
379-
return nil, err
383+
return nil, fmt.Errorf("error getting config: %w", err)
380384
}
381-
response.TxHash = hash
385+
386+
ec, err := services.GetEthClient(c)
387+
if err != nil {
388+
return nil, fmt.Errorf("error getting eth client: %w", err)
389+
}
390+
391+
relayUrl := cfg.Smartnode.GetFlashbotsRelayUrl()
392+
useBundle := bundle
393+
if useBundle && relayUrl == "" {
394+
return nil, fmt.Errorf("a bundle was requested but Flashbots bundles are only supported on mainnet; there is no relay for this network")
395+
}
396+
397+
var distributorAddress common.Address
398+
if relayUrl != "" {
399+
w, err := services.GetWallet(c)
400+
if err != nil {
401+
return nil, err
402+
}
403+
nodeAccount, err := w.GetNodeAccount()
404+
if err != nil {
405+
return nil, err
406+
}
407+
distributorAddress, err = node.GetDistributorAddress(rp, nodeAccount.Address, nil)
408+
if err != nil {
409+
return nil, fmt.Errorf("error getting fee distributor address: %w", err)
410+
}
411+
412+
if !useBundle {
413+
distributorBalance, err := ec.BalanceAt(context.Background(), distributorAddress, nil)
414+
if err != nil {
415+
return nil, fmt.Errorf("error getting fee distributor balance: %w", err)
416+
}
417+
useBundle = distributorBalance.Cmp(big.NewInt(1)) == 0
418+
}
419+
}
420+
421+
if !useBundle {
422+
// Do a plain distribution, which will finalize it
423+
hash, err := mpv3.DistributeBalance(false, opts)
424+
if err != nil {
425+
return nil, err
426+
}
427+
response.TxHash = hash
428+
return &response, nil
429+
}
430+
431+
// Empty the fee distributor and distribute the minipool balance (which also finalizes
432+
// it) atomically in the same block via a Flashbots bundle.
433+
distributor, err := node.NewDistributor(rp, distributorAddress, nil)
434+
if err != nil {
435+
return nil, fmt.Errorf("error creating fee distributor binding: %w", err)
436+
}
437+
438+
// First tx: fee distributor distribute()
439+
distributorTx, err := distributor.PrepareDistribute(opts)
440+
if err != nil {
441+
return nil, fmt.Errorf("error preparing fee distributor distribute tx for bundle: %w", err)
442+
}
443+
444+
// Second tx: minipool distributeBalance(), with bumped nonce and a fixed gas limit
445+
opts.Nonce = new(big.Int).SetUint64(distributorTx.Nonce() + 1)
446+
opts.GasLimit = distributeBalanceBundleGasLimit
447+
448+
distBalTx, err := mpv3.PrepareDistributeBalance(false, opts)
449+
if err != nil {
450+
return nil, fmt.Errorf("error preparing distribute balance tx for bundle on minipool %s: %w", minipoolAddress.Hex(), err)
451+
}
452+
453+
// Send the 2-tx bundle (distribute then distributeBalance)
454+
timeoutCtx, cancel := context.WithTimeout(context.Background(), flashbots.DefaultSubmissionTimeout)
455+
success, err := flashbots.SubmitBundleAndWait(timeoutCtx, nil, ec, relayUrl, []*gethtypes.Transaction{distributorTx, distBalTx}, flashbots.DefaultBundleBlockCount)
456+
cancel()
457+
if err != nil {
458+
return nil, fmt.Errorf("error sending bundle for distribute+distributeBalance: %w", err)
459+
}
460+
if !success {
461+
return nil, fmt.Errorf("bundle for minipool %s distribute+distributeBalance was not included. Bundles usually require a higher priority fee to get included", minipoolAddress.Hex())
462+
}
463+
464+
// Report the distributeBalance tx hash (the last tx in the bundle).
465+
response.TxHash = distBalTx.Hash()
382466
}
383467

384468
// Return response

rocketpool/api/minipool/routes.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@ func RegisterRoutes(mux *http.ServeMux, c *cli.Command) {
155155
apiutils.WriteErrorResponse(w, err)
156156
return
157157
}
158-
resp, err := closeMinipool(c, addr, opts)
158+
bundle := r.FormValue("bundle") == "true"
159+
resp, err := closeMinipool(c, addr, opts, bundle)
159160
apiutils.WriteResponse(w, resp, err)
160161
})
161162

shared/services/config/smartnode-config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@ type SmartnodeConfig struct {
220220

221221
// The FlashBots Protect RPC endpoint
222222
flashbotsProtectUrl map[config.Network]string `yaml:"-"`
223+
224+
// The Flashbots relay URL for eth_sendBundle / bundle operations (distinct from Protect RPC)
225+
flashbotsRelayUrl map[config.Network]string `yaml:"-"`
223226
}
224227

225228
// Generates a newSmart Node configuration
@@ -635,6 +638,10 @@ func NewSmartnodeConfig(cfg *RocketPoolConfig) *SmartnodeConfig {
635638
config.Network_Devnet: "https://rpc-hoodi.flashbots.net/",
636639
config.Network_Testnet: "https://rpc-hoodi.flashbots.net/",
637640
},
641+
642+
flashbotsRelayUrl: map[config.Network]string{
643+
config.Network_Mainnet: "https://relay.flashbots.net",
644+
},
638645
}
639646

640647
}
@@ -993,6 +1000,10 @@ func (cfg *SmartnodeConfig) GetFlashbotsProtectUrl() string {
9931000
return cfg.flashbotsProtectUrl[cfg.Network.Value.(config.Network)]
9941001
}
9951002

1003+
func (cfg *SmartnodeConfig) GetFlashbotsRelayUrl() string {
1004+
return cfg.flashbotsRelayUrl[cfg.Network.Value.(config.Network)]
1005+
}
1006+
9961007
func getNetworkOptions() []config.ParameterOption {
9971008
options := []config.ParameterOption{
9981009
{

0 commit comments

Comments
 (0)