diff --git a/bindings/minipool/minipool-contract-v3.go b/bindings/minipool/minipool-contract-v3.go index 157746450..456ff486b 100644 --- a/bindings/minipool/minipool-contract-v3.go +++ b/bindings/minipool/minipool-contract-v3.go @@ -33,6 +33,7 @@ type MinipoolV3 interface { GetUserDistributed(opts *bind.CallOpts) (bool, error) EstimateDistributeBalanceGas(rewardsOnly bool, opts *bind.TransactOpts) (rocketpool.GasInfo, error) DistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (common.Hash, error) + PrepareDistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (*types.Transaction, error) } // Minipool contract @@ -388,6 +389,20 @@ func (mp *minipool_v3) DistributeBalance(rewardsOnly bool, opts *bind.TransactOp return tx.Hash(), nil } +// PrepareDistributeBalance is like DistributeBalance but forces NoSend and returns the signed transaction +// (instead of sending it). Useful for assembling Flashbots bundles. +func (mp *minipool_v3) PrepareDistributeBalance(rewardsOnly bool, opts *bind.TransactOpts) (*types.Transaction, error) { + if opts == nil { + opts = &bind.TransactOpts{} + } + opts.NoSend = true + tx, err := mp.Contract.Transact(opts, "distributeBalance", rewardsOnly) + if err != nil { + return nil, fmt.Errorf("error preparing distribute tx for minipool %s: %w", mp.Address.Hex(), err) + } + return tx, nil +} + // Estimate the gas of Stake func (mp *minipool_v3) EstimateStakeGas(validatorSignature rptypes.ValidatorSignature, depositDataRoot common.Hash, opts *bind.TransactOpts) (rocketpool.GasInfo, error) { return mp.Contract.GetTransactionGasInfo(opts, "stake", validatorSignature[:], depositDataRoot) diff --git a/bindings/node/distributor.go b/bindings/node/distributor.go index 9563437ed..49f1319df 100644 --- a/bindings/node/distributor.go +++ b/bindings/node/distributor.go @@ -7,6 +7,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" "github.com/rocket-pool/smartnode/bindings/rocketpool" ) @@ -62,6 +63,20 @@ func (d *Distributor) Distribute(opts *bind.TransactOpts) (common.Hash, error) { return tx.Hash(), nil } +// PrepareDistribute is like Distribute but forces NoSend and returns the signed transaction +// (instead of sending it). Useful for assembling Flashbots bundles. +func (d *Distributor) PrepareDistribute(opts *bind.TransactOpts) (*types.Transaction, error) { + if opts == nil { + opts = &bind.TransactOpts{} + } + opts.NoSend = true + tx, err := d.Contract.Transact(opts, "distribute") + if err != nil { + return nil, fmt.Errorf("error preparing distribute tx for distributor %s: %w", d.Address.Hex(), err) + } + return tx, nil +} + // Gets the node share of the distributor's current balance func (d *Distributor) GetNodeShare(opts *bind.CallOpts) (*big.Int, error) { nodeShare := new(*big.Int) diff --git a/rocketpool-cli/minipool/close.go b/rocketpool-cli/minipool/close.go index a8a5e0ded..219eb8fad 100644 --- a/rocketpool-cli/minipool/close.go +++ b/rocketpool-cli/minipool/close.go @@ -21,7 +21,7 @@ import ( "github.com/rocket-pool/smartnode/shared/utils/math" ) -func closeMinipools(minipool string, confirmSlashing, yes bool) error { +func closeMinipools(minipool string, confirmSlashing, yes, bundle bool) error { // Get RP client rp, err := rocketpool.NewClient().WithReady() @@ -272,7 +272,7 @@ func closeMinipools(minipool string, confirmSlashing, yes bool) error { // Close minipools for _, minipool := range selectedMinipools { - response, err := rp.CloseMinipool(minipool.Address) + response, err := rp.CloseMinipool(minipool.Address, bundle) if err != nil { fmt.Printf("Could not close minipool %s: %s.\n", minipool.Address.Hex(), err.Error()) continue diff --git a/rocketpool-cli/minipool/commands.go b/rocketpool-cli/minipool/commands.go index 0ce9a2d83..40dd1bffc 100644 --- a/rocketpool-cli/minipool/commands.go +++ b/rocketpool-cli/minipool/commands.go @@ -265,6 +265,11 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { Name: "confirm-slashing", 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.", }, + &cli.BoolFlag{ + Name: "bundle", + Aliases: []string{"b"}, + 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.", + }, }, Action: func(ctx context.Context, c *cli.Command) error { @@ -281,7 +286,7 @@ func RegisterCommands(app *cli.Command, name string, aliases []string) { } // Run - return closeMinipools(c.String("minipool"), c.Bool("confirm-slashing"), c.Bool("yes")) + return closeMinipools(c.String("minipool"), c.Bool("confirm-slashing"), c.Bool("yes"), c.Bool("bundle")) }, }, diff --git a/rocketpool/api/minipool/close.go b/rocketpool/api/minipool/close.go index 670080c9e..929556e50 100644 --- a/rocketpool/api/minipool/close.go +++ b/rocketpool/api/minipool/close.go @@ -7,6 +7,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/urfave/cli/v3" "golang.org/x/sync/errgroup" @@ -18,9 +19,13 @@ import ( "github.com/rocket-pool/smartnode/shared/services" "github.com/rocket-pool/smartnode/shared/services/beacon" + "github.com/rocket-pool/smartnode/shared/services/flashbots" "github.com/rocket-pool/smartnode/shared/types/api" ) +// Gas can't be estimated against current chain state (the fee distributor's distribute() in the bundle hasn't executed yet) +const distributeBalanceBundleGasLimit uint64 = 600000 + func getMinipoolCloseDetailsForNode(c *cli.Command) (*api.GetMinipoolCloseDetailsForNodeResponse, error) { // Get services @@ -307,7 +312,7 @@ func getMinipoolCloseDetails(rp *rocketpool.RocketPool, minipoolAddress common.A } -func closeMinipool(c *cli.Command, minipoolAddress common.Address, opts *bind.TransactOpts) (*api.CloseMinipoolResponse, error) { +func closeMinipool(c *cli.Command, minipoolAddress common.Address, opts *bind.TransactOpts, bundle bool) (*api.CloseMinipoolResponse, error) { // Get services if err := services.RequireNodeRegistered(c); err != nil { @@ -373,12 +378,91 @@ func closeMinipool(c *cli.Command, minipoolAddress common.Address, opts *bind.Tr } response.TxHash = hash } else { - // Do a distribution, which will finalize it - hash, err := mpv3.DistributeBalance(false, opts) + cfg, err := services.GetConfig(c) if err != nil { - return nil, err + return nil, fmt.Errorf("error getting config: %w", err) } - response.TxHash = hash + + ec, err := services.GetEthClient(c) + if err != nil { + return nil, fmt.Errorf("error getting eth client: %w", err) + } + + relayUrl := cfg.Smartnode.GetFlashbotsRelayUrl() + useBundle := bundle + if useBundle && relayUrl == "" { + return nil, fmt.Errorf("a bundle was requested but Flashbots bundles are only supported on mainnet; there is no relay for this network") + } + + var distributorAddress common.Address + if relayUrl != "" { + w, err := services.GetWallet(c) + if err != nil { + return nil, err + } + nodeAccount, err := w.GetNodeAccount() + if err != nil { + return nil, err + } + distributorAddress, err = node.GetDistributorAddress(rp, nodeAccount.Address, nil) + if err != nil { + return nil, fmt.Errorf("error getting fee distributor address: %w", err) + } + + if !useBundle { + distributorBalance, err := ec.BalanceAt(context.Background(), distributorAddress, nil) + if err != nil { + return nil, fmt.Errorf("error getting fee distributor balance: %w", err) + } + useBundle = distributorBalance.Cmp(big.NewInt(1)) == 0 + } + } + + if !useBundle { + // Do a plain distribution, which will finalize it + hash, err := mpv3.DistributeBalance(false, opts) + if err != nil { + return nil, err + } + response.TxHash = hash + return &response, nil + } + + // Empty the fee distributor and distribute the minipool balance (which also finalizes + // it) atomically in the same block via a Flashbots bundle. + distributor, err := node.NewDistributor(rp, distributorAddress, nil) + if err != nil { + return nil, fmt.Errorf("error creating fee distributor binding: %w", err) + } + + // First tx: fee distributor distribute() + distributorTx, err := distributor.PrepareDistribute(opts) + if err != nil { + return nil, fmt.Errorf("error preparing fee distributor distribute tx for bundle: %w", err) + } + + // Second tx: minipool distributeBalance(), with bumped nonce and a fixed gas limit + opts.Nonce = new(big.Int).SetUint64(distributorTx.Nonce() + 1) + opts.GasLimit = distributeBalanceBundleGasLimit + + distBalTx, err := mpv3.PrepareDistributeBalance(false, opts) + if err != nil { + return nil, fmt.Errorf("error preparing distribute balance tx for bundle on minipool %s: %w", minipoolAddress.Hex(), err) + } + + // Send the 2-tx bundle (distribute then distributeBalance) + timeoutCtx, cancel := context.WithTimeout(context.Background(), flashbots.DefaultSubmissionTimeout) + success, err := flashbots.SubmitBundleAndWait(timeoutCtx, nil, ec, relayUrl, []*gethtypes.Transaction{distributorTx, distBalTx}, flashbots.DefaultBundleBlockCount) + cancel() + if err != nil { + return nil, fmt.Errorf("error sending bundle for distribute+distributeBalance: %w", err) + } + if !success { + 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()) + } + + // Report the distributeBalance tx hash (the last tx in the bundle). + response.TxHash = distBalTx.Hash() } // Return response diff --git a/rocketpool/api/minipool/routes.go b/rocketpool/api/minipool/routes.go index 32eb2c058..a29a9c73b 100644 --- a/rocketpool/api/minipool/routes.go +++ b/rocketpool/api/minipool/routes.go @@ -155,7 +155,8 @@ func RegisterRoutes(mux *http.ServeMux, c *cli.Command) { apiutils.WriteErrorResponse(w, err) return } - resp, err := closeMinipool(c, addr, opts) + bundle := r.FormValue("bundle") == "true" + resp, err := closeMinipool(c, addr, opts, bundle) apiutils.WriteResponse(w, resp, err) }) diff --git a/shared/services/config/smartnode-config.go b/shared/services/config/smartnode-config.go index 57266be5c..26b727363 100644 --- a/shared/services/config/smartnode-config.go +++ b/shared/services/config/smartnode-config.go @@ -220,6 +220,9 @@ type SmartnodeConfig struct { // The FlashBots Protect RPC endpoint flashbotsProtectUrl map[config.Network]string `yaml:"-"` + + // The Flashbots relay URL for eth_sendBundle / bundle operations (distinct from Protect RPC) + flashbotsRelayUrl map[config.Network]string `yaml:"-"` } // Generates a newSmart Node configuration @@ -635,6 +638,10 @@ func NewSmartnodeConfig(cfg *RocketPoolConfig) *SmartnodeConfig { config.Network_Devnet: "https://rpc-hoodi.flashbots.net/", config.Network_Testnet: "https://rpc-hoodi.flashbots.net/", }, + + flashbotsRelayUrl: map[config.Network]string{ + config.Network_Mainnet: "https://relay.flashbots.net", + }, } } @@ -993,6 +1000,10 @@ func (cfg *SmartnodeConfig) GetFlashbotsProtectUrl() string { return cfg.flashbotsProtectUrl[cfg.Network.Value.(config.Network)] } +func (cfg *SmartnodeConfig) GetFlashbotsRelayUrl() string { + return cfg.flashbotsRelayUrl[cfg.Network.Value.(config.Network)] +} + func getNetworkOptions() []config.ParameterOption { options := []config.ParameterOption{ { diff --git a/shared/services/flashbots/bundle.go b/shared/services/flashbots/bundle.go new file mode 100644 index 000000000..a01322cbd --- /dev/null +++ b/shared/services/flashbots/bundle.go @@ -0,0 +1,189 @@ +package flashbots + +import ( + "errors" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/google/uuid" +) + +type Bundle struct { + transactions []*types.Transaction + targetBlocknumber uint64 + minTimestamp int64 + maxTimestamp int64 + revertingTxHashes []string + replacementUuid string + uuidAlreadySend bool + builders []string + + bundleHash common.Hash + isSmart bool +} + +func NewBundle() *Bundle { + return &Bundle{ + replacementUuid: uuid.New().String(), + targetBlocknumber: 0, + } +} + +func NewBundleWithTransactions(transactions []*types.Transaction) *Bundle { + return &Bundle{ + replacementUuid: uuid.New().String(), + transactions: transactions, + targetBlocknumber: 0, + } +} + +func (b *Bundle) Transactions() []*types.Transaction { + return b.transactions +} + +func (b *Bundle) TargetBlockNumber() uint64 { + return b.targetBlocknumber +} + +func (b *Bundle) MinTimestamp() int64 { + return b.minTimestamp +} + +func (b *Bundle) MaxTimestamp() int64 { + return b.maxTimestamp +} + +func (b *Bundle) RevertingTxHashes() []string { + return b.revertingTxHashes +} + +func (b *Bundle) ReplacementUuid() string { + return b.replacementUuid +} + +func (b *Bundle) BundleHash() common.Hash { + return b.bundleHash +} + +func (b *Bundle) Builders() []string { + return b.builders +} + +func (b *Bundle) AddTransaction(tx *types.Transaction) { + b.transactions = append(b.transactions, tx) +} + +func (b *Bundle) AddTransactions(txs []*types.Transaction) { + b.transactions = append(b.transactions, txs...) +} + +// SetBlockNumber sets the block number for which this bundle is valid +// If set to 0, the bundle is valid for the next block +func (b *Bundle) SetTargetBlockNumber(blocknumber uint64) error { + if b.targetBlocknumber != 0 { + return errors.New("targetBlocknumber already set") + } + + b.targetBlocknumber = blocknumber + return nil +} + +// SetMinTimestamp sets the minimum timestamp for which this bundle is valid, in seconds since the unix epoch +func (b *Bundle) SetMinTimestamp(minTimestamp int64) error { + if b.maxTimestamp != 0 && minTimestamp > b.maxTimestamp { + return errors.New("minTimestamp must be less than maxTimestamp") + } + + b.minTimestamp = minTimestamp + return nil +} + +// SetMaxTimestamp sets the maximum timestamp for which this bundle is valid, in seconds since the unix epoch +func (b *Bundle) SetMaxTimestamp(maxTimestamp int64) error { + if b.minTimestamp != 0 && maxTimestamp < b.minTimestamp { + return errors.New("maxTimestamp must be greater than minTimestamp") + } + if maxTimestamp < time.Now().Unix() { + return errors.New("maxTimestamp must be in the future") + } + + b.maxTimestamp = maxTimestamp + return nil +} + +// SetRevertingTxHash sets one transaction hash that is allowed to revert +func (b *Bundle) SetRevertingTxHash(revertingTxHash string) { + b.revertingTxHashes = append(b.revertingTxHashes, revertingTxHash) +} + +// SetRevertingTxHashes sets the list of transaction hashes that are allowed to revert +func (b *Bundle) SetRevertingTxHashes(revertingTxHashes []string) { + b.revertingTxHashes = revertingTxHashes +} + +// SetReplacementUuid sets the replacement UUID for this bundle that can be used to cancel/replace this bundle +func (b *Bundle) SetReplacementUuid(replacementUuid string) error { + if b.uuidAlreadySend { + return errors.New("bundle already send to relay, cant change uuid") + } + + b.replacementUuid = replacementUuid + return nil +} + +func (b *Bundle) MaximumGasFeePaid() (feePaid *big.Int) { + feePaid = big.NewInt(0) + for _, tx := range b.transactions { + feePaid.Add(feePaid, tx.Cost()) + } + + return feePaid +} + +func (b *Bundle) UseAllBuilders(networkId uint64) { + if networkId == 1 { + b.builders = AllBuilders + } +} + +func (b *Bundle) Copy() *Bundle { + transactions := make([]*types.Transaction, len(b.transactions)) + copy(transactions, b.transactions) + + revertingTxHashes := make([]string, len(b.revertingTxHashes)) + copy(revertingTxHashes, b.revertingTxHashes) + + builders := make([]string, len(b.builders)) + copy(builders, b.builders) + + return &Bundle{ + transactions: transactions, + targetBlocknumber: b.targetBlocknumber, + minTimestamp: b.minTimestamp, + maxTimestamp: b.maxTimestamp, + revertingTxHashes: revertingTxHashes, + replacementUuid: b.replacementUuid, + builders: builders, + bundleHash: b.bundleHash, + } +} + +func (b *Bundle) GetBundelsForNextNBlocks(n uint64) ([]*Bundle, error) { + if b.targetBlocknumber == 0 { + return nil, errors.New("targetBlocknumber not set") + } + + bundles := make([]*Bundle, n) + for i := uint64(0); i < n; i++ { + bundles[i] = b.Copy() + bundles[i].targetBlocknumber += (i + 1) + // Each copy gets its own replacement UUID: relays treat a new eth_sendBundle with the + // same UUID as a replacement of the previous submission, which would defeat the + // per-block redundancy these copies provide. + bundles[i].replacementUuid = uuid.New().String() + } + + return bundles, nil +} diff --git a/shared/services/flashbots/config.go b/shared/services/flashbots/config.go new file mode 100644 index 000000000..5935a7a1e --- /dev/null +++ b/shared/services/flashbots/config.go @@ -0,0 +1,38 @@ +package flashbots + +// Flashbots relay URLs per chain ID, used as a fallback when no relay URL is +// passed to NewClient explicitly (the Smart Node passes cfg.GetFlashbotsRelayUrl()). +var FlashbotsUrlPerNetwork = map[uint64]string{ + 1: "https://relay.flashbots.net", +} + +const ( + JsonRpcParseError = -32700 + JsonRpcInvalidRequest = -32600 + JsonRpcMethodNotFound = -32601 + JsonRpcInvalidParams = -32602 + JsonRpcInternalError = -32603 +) + +// AllBuilders is the list used by UseAllBuilders for broader inclusion chances on supported networks. +var AllBuilders = []string{ + "flashbots", + "f1b.io", + "rsync", + "beaverbuild.org", + "builder0x69", + "Titan", + "EigenPhi", + "boba-builder", + "Gambit Labs", + "payload", + "Loki", + "BuildAI", + "JetBuilder", + "tbuilder", + "penguinbuild", + "bobthebuilder", + "BTCS", + "bloXroute", + "Blockbeelder", +} diff --git a/shared/services/flashbots/flashbots_client.go b/shared/services/flashbots/flashbots_client.go new file mode 100644 index 000000000..92acbd6ce --- /dev/null +++ b/shared/services/flashbots/flashbots_client.go @@ -0,0 +1,816 @@ +package flashbots + +import ( + "bytes" + "context" + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "math/big" + "net/http" + "sync" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" +) + +// EthRpc is the minimal execution client surface needed by the Flashbots client. +// Both *ethclient.Client and the Smart Node's ExecutionClientManager satisfy it. +type EthRpc interface { + ChainID(ctx context.Context) (*big.Int, error) + BlockNumber(ctx context.Context) (uint64, error) + TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) +} + +type ErrorType struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type Response struct { + ID int `json:"id"` + JSONRPC string `json:"jsonrpc"` + Result json.RawMessage `json:"result"` + Error ErrorType `json:"error"` +} + +type Request struct { + ID int `json:"id"` + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params []interface{} `json:"params"` +} + +type RawSimulationResultTransaction struct { + CoinbaseDiff string `json:"coinbaseDiff"` + EthSentToCoinbase string `json:"ethSentToCoinbase"` + FromAddress string `json:"fromAddress"` + GasFees string `json:"gasFees"` + GasPrice string `json:"gasPrice"` + GasUsed uint64 `json:"gasUsed"` + ToAddress string `json:"toAddress"` + TxHash string `json:"txHash"` + Value string `json:"value"` + + // revert related + Error string `json:"error"` + RevertReason string `json:"revert"` +} + +type RawSimulationResultBundle struct { + BundleGasPrice string + BundleHash string + CoinbaseDiff string + EthSentToCoinbase string + GasFees string + Results []RawSimulationResultTransaction + StateBlockNumber uint64 + TotalGasUsed uint64 + FirstRevert string +} + +type SimulationResultTransaction struct { + CoinbaseDiff *big.Int `json:"coinbaseDiff"` + EthSentToCoinbase *big.Int `json:"ethSentToCoinbase"` + FromAddress common.Address `json:"fromAddress"` + GasFees *big.Int `json:"gasFees"` + GasPrice *big.Int `json:"gasPrice"` + GasUsed uint64 `json:"gasUsed"` + ToAddress common.Address `json:"toAddress"` + TxHash common.Hash `json:"txHash"` + Value *big.Int `json:"value"` + + // revert related + Error string `json:"error"` + RevertReason string `json:"revert"` +} + +type SimulationResultBundle struct { + BundleGasPrice *big.Int + BundleHash common.Hash + CoinbaseDiff *big.Int + EthSentToCoinbase *big.Int + GasFees *big.Int + Results []SimulationResultTransaction + StateBlockNumber uint64 + TotalGasUsed uint64 + FirstRevert common.Hash +} + +type FlashbotsClient struct { + url string + logger *slog.Logger + client http.Client + ethRpc EthRpc + searcherSecret *ecdsa.PrivateKey + searcherAddress common.Address + nextRequestId int + nextRequestIdMutex sync.Mutex +} + +func NewClientRpcString(logger *slog.Logger, ethRpcStr string, relayUrl string, searcherSecret *ecdsa.PrivateKey) (*FlashbotsClient, error) { + if ethRpcStr == "" { + return nil, errors.New("ethRpc is required") + } + + rpcClient, err := ethclient.Dial(ethRpcStr) + if err != nil { + return nil, errors.Join(errors.New("error connecting to rpc client"), err) + } + + return NewClient(logger, rpcClient, relayUrl, searcherSecret) +} + +// NewClient creates a Flashbots client using the given execution client for on-chain queries. +// If relayUrl is empty, the relay is resolved from FlashbotsUrlPerNetwork using the chain ID. +func NewClient(logger *slog.Logger, ethRpc EthRpc, relayUrl string, searcherSecret *ecdsa.PrivateKey) (*FlashbotsClient, error) { + if logger != nil { + logger = logger.With(slog.String("module", "flashbots_client")) + } + + url := relayUrl + if url == "" { + ctx := context.Background() + timeoutContext, cancel := context.WithTimeout(ctx, 2*time.Second) + + chainId, err := ethRpc.ChainID(timeoutContext) + cancel() + + if err != nil { + return nil, errors.Join(errors.New("error calling rpc client"), err) + } + + var ok bool + url, ok = FlashbotsUrlPerNetwork[chainId.Uint64()] + if !ok { + return nil, fmt.Errorf("network %s not supported", chainId) + } + } + + client := http.Client{ + Timeout: 15 * time.Second, + } + + // Generate a random secret if searcherSecret is nil + if searcherSecret == nil { + privateKey, err := crypto.GenerateKey() + if err != nil { + return nil, fmt.Errorf("failed to generate random secret: %v", err) + } + searcherSecret = privateKey + } + + searcherPublicKey := searcherSecret.Public() + searcherPublicKeyECDSA, ok := searcherPublicKey.(*ecdsa.PublicKey) + if !ok { + return nil, errors.New("error casting public key to ECDSA") + } + searcherAddress := crypto.PubkeyToAddress(*searcherPublicKeyECDSA) + + fbClient := FlashbotsClient{ + url: url, + logger: logger, + client: client, + ethRpc: ethRpc, + searcherSecret: searcherSecret, + searcherAddress: searcherAddress, + nextRequestId: 0, + nextRequestIdMutex: sync.Mutex{}, + } + + return &fbClient, nil +} + +func (client *FlashbotsClient) Call(method string, params ...interface{}) (json.RawMessage, error) { + return client.CallWithAdditionalHeaders(method, map[string]string{}, params...) +} + +func (client *FlashbotsClient) CallWithAdditionalHeaders(method string, headers map[string]string, params ...interface{}) (json.RawMessage, error) { + request := Request{ + ID: client.getNextRequestId(), + JSONRPC: "2.0", + Method: method, + Params: params, + } + + body, err := json.Marshal(request) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", client.url, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + for k, v := range headers { + req.Header.Add(k, v) + } + + // add flashbots auth header + hashedBody := crypto.Keccak256Hash([]byte(body)).Hex() + signature, err := crypto.Sign( + accounts.TextHash([]byte(hashedBody)), + client.searcherSecret, + ) + if err != nil { + return nil, errors.Join(errors.New("error signing payload"), err) + } + req.Header.Add("X-Flashbots-Signature", client.searcherAddress.Hex()+":"+hexutil.Encode(signature)) + + response, err := client.client.Do(req) + if response != nil { + defer func() { + if cerr := response.Body.Close(); cerr != nil && client.logger != nil { + client.logger.Warn("error closing flashbots response body", slog.String("error", cerr.Error())) + } + }() + } + if err != nil { + return nil, errors.Join(errors.New("error calling flashbots API"), err) + } + + data, err := io.ReadAll(response.Body) + if err != nil { + return nil, errors.Join(errors.New("error reading response body"), err) + } + + resp := new(Response) + if err := json.Unmarshal(data, resp); err != nil { + return nil, errors.Join(errors.New("error parsing response"), err) + } + + if resp.Error.Code != 0 { + return nil, parseError(resp.Error) + } + + return resp.Result, nil +} + +func (client *FlashbotsClient) SendBundle(bundle *Bundle) (common.Hash, bool, error) { + hexEncodedBlocknumber, sendTargetBlockNumber, err := client.hexEncodeBlocknumbeInTheFuture(bundle.targetBlocknumber) + if err != nil { + return common.Hash{}, false, errors.Join(errors.New("error encoding block number"), err) + } + + bundle.targetBlocknumber = sendTargetBlockNumber + + rawTransactions, err := convertTransactionsToRawStrings(bundle.transactions) + if err != nil { + return common.Hash{}, false, errors.Join(errors.New("error converting transactions to raw strings"), err) + } + + params := map[string]interface{}{ + "txs": rawTransactions, + "blockNumber": hexEncodedBlocknumber, + } + + if bundle.minTimestamp != 0 { + params["minTimestamp"] = bundle.minTimestamp + } + + if bundle.maxTimestamp != 0 { + params["maxTimestamp"] = bundle.maxTimestamp + } + + if len(bundle.revertingTxHashes) > 0 { + params["revertingTxHashes"] = bundle.revertingTxHashes + } + + if bundle.replacementUuid != "" { + params["replacementUuid"] = bundle.replacementUuid + } + + if len(bundle.builders) > 0 { + params["builders"] = bundle.builders + } + + res, err := client.Call("eth_sendBundle", params) + if err != nil { + return common.Hash{}, false, errors.Join(errors.New("error calling flashbots"), err) + } + + var response struct { + BundleHash string `json:"bundleHash"` + Smart bool `json:"smart"` + } + err = json.Unmarshal(res, &response) + if err != nil { + return common.Hash{}, false, errors.Join(errors.New("error parsing response"), err) + } + + bundle.bundleHash = common.HexToHash(response.BundleHash) + bundle.isSmart = response.Smart + bundle.uuidAlreadySend = true + return common.HexToHash(response.BundleHash), bundle.isSmart, nil +} + +func (client *FlashbotsClient) SendBundleAndWait(ctx context.Context, bundle *Bundle) (bool, error) { + _, _, err := client.SendBundle(bundle) + if err != nil { + return false, errors.Join(errors.New("error sending bundle"), err) + } + + return client.WaitForBundleInclusion(ctx, bundle) +} + +func (client *FlashbotsClient) SendBundleNTimes(originalBundle *Bundle, n uint64) (bundlesToSend []*Bundle, hash common.Hash, smart bool, err error) { + bundlesToSend = []*Bundle{originalBundle} + + // create n-1 followup bundles + if n > 1 { + nextBundles, err := originalBundle.GetBundelsForNextNBlocks(n - 1) + if err != nil { + return bundlesToSend, common.Hash{}, false, errors.Join(errors.New("error getting bundles for next n blocks"), err) + } + bundlesToSend = append(bundlesToSend, nextBundles...) + } + + for _, bundle := range bundlesToSend { + if client.logger != nil { + client.logger.Debug("sending bundle", slog.Uint64("targetBlock", bundle.targetBlocknumber)) + } + + hash, smart, err = client.SendBundle(bundle) + if err != nil { + return bundlesToSend, common.Hash{}, false, errors.Join(errors.New("error sending bundle"), err) + } + } + + return bundlesToSend, hash, smart, nil +} + +func (client *FlashbotsClient) SendNBundleAndWait(ctx context.Context, bundle *Bundle, n uint64) (bool, error) { + bundles, _, _, err := client.SendBundleNTimes(bundle, n) + if err != nil { + return false, errors.Join(errors.New("error sending bundle"), err) + } + + var success bool + for _, nextBundle := range bundles { + if client.logger != nil { + client.logger.Debug("start waiting for bundle inclusion", slog.Uint64("targetBlock", nextBundle.targetBlocknumber)) + } + + success, err = client.WaitForBundleInclusion(ctx, nextBundle) + if err != nil { + if client.logger != nil { + client.logger.Warn("error waiting for bundle inclusion - this does not affect the remaining bundle", slog.String("error", err.Error())) + } + + // do not return, wait for other bundles + continue + } + + if success { + break + } + } + + // cancel all bundles if one of them succeeded (each has its own replacement UUID) + if success { + for _, nextBundle := range bundles { + err = client.CancelBundle(nextBundle.replacementUuid) + if err != nil && client.logger != nil { + client.logger.Warn("error canceling bundle - this does not affect the bundle", slog.String("error", err.Error())) + } + } + } + + return success, nil +} + +// SimulateBundle simulates the execution of a bundle +// The stateBlocknumber parameter is the block number at which the simulation should start, 0 for the current block +func (client *FlashbotsClient) SimulateBundle(bundle *Bundle, stateBlocknumber uint64) (*SimulationResultBundle, bool, error) { + hexEncodedBlocknumber, _, err := client.hexEncodeBlocknumber(bundle.targetBlocknumber) + if err != nil { + return nil, false, errors.Join(errors.New("error encoding block number"), err) + } + + var hexEncodedBlocknumberState string + if stateBlocknumber == bundle.targetBlocknumber { + hexEncodedBlocknumberState = hexEncodedBlocknumber + } else { + hexEncodedBlocknumberState, _, err = client.hexEncodeBlocknumber(stateBlocknumber) + if err != nil { + return nil, false, errors.Join(errors.New("error encoding state block number"), err) + } + } + + rawTransactions, err := convertTransactionsToRawStrings(bundle.transactions) + if err != nil { + return nil, false, errors.Join(errors.New("error converting transactions to raw strings"), err) + } + + params := map[string]interface{}{ + "txs": rawTransactions, + "blockNumber": hexEncodedBlocknumber, + "stateBlockNumber": hexEncodedBlocknumberState, + } + + if bundle.minTimestamp != 0 { + params["minTimestamp"] = bundle.minTimestamp + } + + result, err := client.Call("eth_callBundle", params) + if err != nil { + return nil, false, errors.Join(errors.New("error calling flashbots"), err) + } + + var rawSimulationResult RawSimulationResultBundle + err = json.Unmarshal(result, &rawSimulationResult) + if err != nil { + return nil, false, errors.Join(errors.New("error parsing simulation result"), err) + } + + simulationResult := parseSimulationResultBundle(rawSimulationResult) + + // eval first revert + for _, tx := range simulationResult.Results { + if tx.Error != "" { + simulationResult.FirstRevert = tx.TxHash + break + } + } + + return &simulationResult, simulationResult.FirstRevert == common.Hash{}, nil +} + +type BundleStats struct { + IsHighPriority bool `json:"isHighPriority"` + IsSimulated bool `json:"isSimulated"` + SimulatedAt string `json:"simulatedAt"` + ReceivedAt string `json:"receivedAt"` + ConsideredByBuilders []struct { + Pubkey string `json:"pubkey"` + Timestamp string `json:"timestamp"` + } `json:"consideredByBuildersAt"` + SealedByBuilders []struct { + Pubkey string `json:"pubkey"` + Timestamp string `json:"timestamp"` + } `json:"sealedByBuildersAt"` +} + +func (client *FlashbotsClient) GetBundleStats(bundle *Bundle) (*BundleStats, error) { + if bundle.isSmart { + return client.GetSbundleStats(bundle) + } + bundleV2, err := client.GetBundleStatsV2(bundle) + if err != nil { + return nil, err + } + + return &BundleStats{ + IsHighPriority: bundleV2.IsHighPriority, + IsSimulated: bundleV2.IsSimulated, + SimulatedAt: bundleV2.SimulatedAt, + ReceivedAt: bundleV2.ReceivedAt, + ConsideredByBuilders: bundleV2.ConsideredByBuilders, + SealedByBuilders: bundleV2.SealedByBuilders, + }, nil +} + +type BundleStatsV2 struct { + IsHighPriority bool `json:"isHighPriority"` + IsSimulated bool `json:"isSimulated"` + SimulatedAt string `json:"simulatedAt"` + ReceivedAt string `json:"receivedAt"` + ConsideredByBuilders []struct { + Pubkey string `json:"pubkey"` + Timestamp string `json:"timestamp"` + } `json:"consideredByBuildersAt"` + SealedByBuilders []struct { + Pubkey string `json:"pubkey"` + Timestamp string `json:"timestamp"` + } `json:"sealedByBuildersAt"` +} + +func (client *FlashbotsClient) GetBundleStatsV2(bundle *Bundle) (*BundleStatsV2, error) { + if bundle.bundleHash == (common.Hash{}) { + return nil, errors.New("bundle hash not set") + } + + if bundle.isSmart { + return nil, errors.New("smart bundles are not supported by 'GetBundleStatsV2', use 'GetSbundleStats'") + } + + params := map[string]interface{}{ + "bundleHash": bundle.bundleHash.Hex(), + "blockNumber": fmt.Sprintf("0x%x", bundle.targetBlocknumber), + } + + result, err := client.Call("flashbots_getBundleStatsV2", params) + if err != nil { + return nil, errors.Join(errors.New("error calling flashbots"), err) + } + + var response BundleStatsV2 + err = json.Unmarshal(result, &response) + if err != nil { + return nil, errors.Join(errors.New("error parsing response"), err) + } + + return &response, nil +} + +func (client *FlashbotsClient) GetSbundleStats(bundle *Bundle) (*BundleStats, error) { + if bundle.bundleHash == (common.Hash{}) { + return nil, errors.New("bundle hash not set") + } + + if !bundle.isSmart { + return nil, errors.New("non-smart bundles are not supported by 'GetSbundleStats', use 'GetBundleStatsV2'") + } + + params := map[string]interface{}{ + "bundleHash": bundle.bundleHash.Hex(), + "blockNumber": fmt.Sprintf("0x%x", bundle.targetBlocknumber), + } + + result, err := client.Call("flashbots_getSbundleStats", params) + if err != nil { + return nil, errors.Join(errors.New("error calling flashbots"), err) + } + + var response BundleStats + err = json.Unmarshal(result, &response) + if err != nil { + return nil, errors.Join(errors.New("error parsing response"), err) + } + + return &response, nil +} + +func (client *FlashbotsClient) CancelBundle(uuid string) error { + params := map[string]interface{}{ + "replacementUuid": uuid, + } + + _, err := client.Call("eth_cancelBundle", params) + if err != nil { + return errors.Join(errors.New("error calling flashbots"), err) + } + + return nil +} + +// WaitForBundleInclusion waits for a bundle to be included in a block +func (client *FlashbotsClient) WaitForBundleInclusion(ctx context.Context, bundle *Bundle) (bool, error) { + targetBlock := bundle.TargetBlockNumber() + + firstTime := true + var lastBlockChecked uint64 + txs := bundle.Transactions() + for { + // 1. Check if the context has been canceled or timed out + select { + case <-ctx.Done(): + return false, nil + default: + // continue + } + + // 2. Get current on-chain block + currentBlock, err := client.getBlocknumber(ctx) + if err != nil { + return false, errors.Join(errors.New("error getting current block number"), err) + } + + if lastBlockChecked != currentBlock { + wasIncluded, err := client.transactionsIncluded(ctx, txs) + if err != nil { + return false, errors.Join(errors.New("error checking transaction inclusion"), err) + } + + if wasIncluded { + return true, nil + } + + lastBlockChecked = currentBlock + } + + if currentBlock >= targetBlock { + return false, nil + } + + // 5. Not past the sealed block yet; sleep and poll again + time.Sleep(1 * time.Second) + + if client.logger != nil { + if client.logger.Enabled(ctx, slog.LevelDebug) { + stats, err := client.GetBundleStats(bundle) + if err != nil { + client.logger.Warn("failed to get bundle stats", slog.String("error", err.Error())) + continue + } + + if !stats.IsSimulated { + client.logger.Debug("Bundle not yet seen by relay", slog.Uint64("targetBlock", targetBlock)) + } else { + if firstTime { + client.logger.Debug("Bundle received and simulated", + slog.Uint64("targetBlock", targetBlock), + slog.String("receivedAt", stats.ReceivedAt), + slog.String("simulatedAt", stats.SimulatedAt), + ) + firstTime = false + } + + client.logger.Debug("Bundle considered or sealed by builders", + slog.Uint64("targetBlock", targetBlock), + slog.Int("consideredByBuilders", len(stats.ConsideredByBuilders)), + slog.Int("sealedByBuilders", len(stats.SealedByBuilders)), + ) + } + } + } + } +} + +func (client *FlashbotsClient) CheckBundleIncusion(ctx context.Context, bundle *Bundle) (bool, error) { + return client.transactionsIncluded(ctx, bundle.Transactions()) +} + +func (client *FlashbotsClient) hexEncodeBlocknumber(blocknumber uint64) (string, uint64, error) { + if blocknumber == 0 { + n, err := client.getBlocknumber(context.Background()) + if err != nil { + return "", 0, errors.Join(errors.New("error getting blocknumber"), err) + } + + blocknumber = n + } + + return fmt.Sprintf("0x%x", blocknumber), blocknumber, nil +} + +func (client *FlashbotsClient) hexEncodeBlocknumbeInTheFuture(blocknumber uint64) (string, uint64, error) { + if blocknumber == 0 { + n, err := client.getBlocknumber(context.Background()) + if err != nil { + return "", 0, errors.Join(errors.New("error getting blocknumber"), err) + } + + // use next block + blocknumber = n + 1 + } + + return fmt.Sprintf("0x%x", blocknumber), blocknumber, nil +} + +func (client *FlashbotsClient) getBlocknumber(ctx context.Context) (uint64, error) { + timeoutContext, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + return client.ethRpc.BlockNumber(timeoutContext) +} + +func (client *FlashbotsClient) getNextRequestId() int { + client.nextRequestIdMutex.Lock() + defer client.nextRequestIdMutex.Unlock() + + client.nextRequestId++ + return client.nextRequestId +} + +func (client *FlashbotsClient) transactionsIncluded(ctx context.Context, txs []*types.Transaction) (bool, error) { + for _, tx := range txs { + txHash := tx.Hash() + + receipt, err := client.ethRpc.TransactionReceipt(ctx, txHash) + if err != nil { + if errors.Is(err, ethereum.NotFound) { + return false, nil + } + return false, errors.Join(errors.New("error getting transaction receipt"), err) + } + + if receipt == nil { + return false, nil + } + } + + return true, nil +} + +func convertTransactionsToRawStrings(txs []*types.Transaction) ([]string, error) { + var txsString []string + for _, tx := range txs { + binary, err := tx.MarshalBinary() + if err != nil { + return nil, errors.Join(errors.New("error marshalling transaction"), err) + } + + txHex := "0x" + hex.EncodeToString(binary) + + txsString = append(txsString, txHex) + } + + return txsString, nil +} + +func parseSimulationResultBundle(raw RawSimulationResultBundle) SimulationResultBundle { + var results []SimulationResultTransaction + for _, rawTx := range raw.Results { + tx := SimulationResultTransaction{ + CoinbaseDiff: new(big.Int), + EthSentToCoinbase: new(big.Int), + FromAddress: common.HexToAddress(rawTx.FromAddress), + GasFees: new(big.Int), + GasPrice: new(big.Int), + GasUsed: rawTx.GasUsed, + ToAddress: common.HexToAddress(rawTx.ToAddress), + TxHash: common.HexToHash(rawTx.TxHash), + Value: new(big.Int), + + Error: rawTx.Error, + RevertReason: rawTx.RevertReason, + } + + tx.CoinbaseDiff.SetString(rawTx.CoinbaseDiff, 10) + tx.EthSentToCoinbase.SetString(rawTx.EthSentToCoinbase, 10) + tx.GasFees.SetString(rawTx.GasFees, 10) + tx.GasPrice.SetString(rawTx.GasPrice, 10) + tx.Value.SetString(rawTx.Value, 10) + + results = append(results, tx) + } + + bundle := SimulationResultBundle{ + BundleGasPrice: new(big.Int), + BundleHash: common.HexToHash(raw.BundleHash), + CoinbaseDiff: new(big.Int), + EthSentToCoinbase: new(big.Int), + GasFees: new(big.Int), + Results: results, + StateBlockNumber: raw.StateBlockNumber, + TotalGasUsed: raw.TotalGasUsed, + FirstRevert: common.HexToHash(raw.FirstRevert), + } + + bundle.BundleGasPrice.SetString(raw.BundleGasPrice, 10) + bundle.CoinbaseDiff.SetString(raw.CoinbaseDiff, 10) + bundle.EthSentToCoinbase.SetString(raw.EthSentToCoinbase, 10) + bundle.GasFees.SetString(raw.GasFees, 10) + + return bundle +} + +func (client *FlashbotsClient) UpdateFeeRefundRecipient(newFeeRefundRecipient common.Address) error { + res, err := client.Call("flashbots_setFeeRefundRecipient", client.searcherAddress.Hex(), newFeeRefundRecipient.Hex()) + if err != nil { + return errors.Join(errors.New("error calling flashbots"), err) + } + + var response struct { + From string `json:"from"` + To string `json:"to"` + } + err = json.Unmarshal(res, &response) + if err != nil { + return errors.Join(errors.New("error parsing response"), err) + } + + parseFromAddress := common.HexToAddress(response.From) + parseToAddress := common.HexToAddress(response.To) + + if client.searcherAddress.Cmp(parseFromAddress) != 0 { + return errors.New("from address not correctly updated") + } + + if newFeeRefundRecipient.Cmp(parseToAddress) != 0 { + return errors.New("to address not correctly updated") + } + + return nil +} + +func parseError(err ErrorType) error { + switch err.Code { + case JsonRpcParseError: + return fmt.Errorf("flashbots: failed to parse request. %d: %s", err.Code, err.Message) + case JsonRpcInvalidRequest: + return fmt.Errorf("flashbots: invalid request. %d: %s", err.Code, err.Message) + case JsonRpcMethodNotFound: + return fmt.Errorf("flashbots: method not found. %d: %s", err.Code, err.Message) + case JsonRpcInvalidParams: + return fmt.Errorf("flashbots: invalid params. %d: %s", err.Code, err.Message) + case JsonRpcInternalError: + return fmt.Errorf("flashbots: internal error. %d: %s", err.Code, err.Message) + default: + return fmt.Errorf("flashbots: error (%d): %s", err.Code, err.Message) + } +} diff --git a/shared/services/flashbots/submit.go b/shared/services/flashbots/submit.go new file mode 100644 index 000000000..901e66aad --- /dev/null +++ b/shared/services/flashbots/submit.go @@ -0,0 +1,39 @@ +package flashbots + +import ( + "context" + "errors" + "log/slog" + "time" + + "github.com/ethereum/go-ethereum/core/types" +) + +// Defaults for bundle submission: retry the bundle over the next 4 blocks (~48s on mainnet) +// and give inclusion polling a bit of margin on top of that. +const ( + DefaultBundleBlockCount uint64 = 5 + DefaultSubmissionTimeout = 70 * time.Second +) + +// SubmitBundleAndWait bundles the given signed transactions, targets the next block, submits +// the bundle with redundancy across the next nBlocks blocks and waits for inclusion +// (cancelling the remaining bundles once one of them lands). +// If relayUrl is empty, the relay is resolved from the chain ID. +// Returns true if the bundle was included before ctx expired. +func SubmitBundleAndWait(ctx context.Context, logger *slog.Logger, ethRpc EthRpc, relayUrl string, txs []*types.Transaction, nBlocks uint64) (bool, error) { + // A random searcher key is generated internally (standard for public bundles) + client, err := NewClient(logger, ethRpc, relayUrl, nil) + if err != nil { + return false, errors.Join(errors.New("error creating flashbots client"), err) + } + + bundle := NewBundleWithTransactions(txs) + + // Target the next block; on error leave the target at 0 and let the client resolve it. + if blockNum, err := ethRpc.BlockNumber(ctx); err == nil { + _ = bundle.SetTargetBlockNumber(blockNum + 1) + } + + return client.SendNBundleAndWait(ctx, bundle, nBlocks) +} diff --git a/shared/services/rocketpool/minipool.go b/shared/services/rocketpool/minipool.go index ac4329fd2..aa2d44a7b 100644 --- a/shared/services/rocketpool/minipool.go +++ b/shared/services/rocketpool/minipool.go @@ -203,8 +203,11 @@ func (c *Client) GetMinipoolCloseDetailsForNode() (api.GetMinipoolCloseDetailsFo } // Close a minipool -func (c *Client) CloseMinipool(address common.Address) (api.CloseMinipoolResponse, error) { - responseBytes, err := c.callHTTPAPI("POST", "/api/minipool/close", url.Values{"address": {address.Hex()}}) +func (c *Client) CloseMinipool(address common.Address, bundle bool) (api.CloseMinipoolResponse, error) { + responseBytes, err := c.callHTTPAPI("POST", "/api/minipool/close", url.Values{ + "address": {address.Hex()}, + "bundle": {strconv.FormatBool(bundle)}, + }) if err != nil { return api.CloseMinipoolResponse{}, fmt.Errorf("Could not close minipool: %w", err) }