diff --git a/rocketpool/watchtower/challenge-exit.go b/rocketpool/watchtower/challenge-exit.go index f050a31b6..dbae8f226 100644 --- a/rocketpool/watchtower/challenge-exit.go +++ b/rocketpool/watchtower/challenge-exit.go @@ -1,9 +1,13 @@ package watchtower import ( + "context" + "errors" "fmt" + "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" "github.com/rocket-pool/smartnode/bindings/megapool" @@ -16,6 +20,7 @@ import ( "github.com/rocket-pool/smartnode/shared/math" "github.com/rocket-pool/smartnode/shared/services" "github.com/rocket-pool/smartnode/shared/services/config" + "github.com/rocket-pool/smartnode/shared/services/flashbots" "github.com/rocket-pool/smartnode/shared/services/state" "github.com/rocket-pool/smartnode/shared/services/wallet" ) @@ -155,15 +160,14 @@ func (t *challengeValidatorsExiting) challengeValidatorsExiting(state *state.Net opts.GasTipCap = math.GweiToWei(utils.GetWatchtowerPrioFee(t.cfg)) opts.GasLimit = gasLimits.Safe - // Challenge - tx, err := megapool.ChallengeExit(t.rp, exitChallenges, opts) + // Prefer a private bundle, with public fallback only when no relay is available. + tx, bundled, err := t.submitExitChallenges(exitChallenges, opts) if err != nil { return err } - - // Print TX info and wait for it to be included in a block - err = transactions.PrintAndWaitForTransaction(t.cfg, tx.Hash(), t.rp.Client, &t.log) - if err != nil { + if bundled { + t.log.Printlnf("Exit challenge bundle included: %s", tx.Hash()) + } else if err := transactions.PrintAndWaitForTransaction(t.cfg, tx.Hash(), t.ec, &t.log); err != nil { return err } @@ -173,3 +177,61 @@ func (t *challengeValidatorsExiting) challengeValidatorsExiting(state *state.Net return nil } + +// submitExitChallenges signs locally and submits a one-transaction bundle. +// The second result is true once the bundle has been included. A missing relay +// or DNS failure during submission permits a local public fallback. +func (t *challengeValidatorsExiting) submitExitChallenges(exitChallenges []megapool.ExitChallenge, opts *bind.TransactOpts) (*gethtypes.Transaction, bool, error) { + if opts.NoSend { + return nil, false, fmt.Errorf("cannot submit exit challenges with transaction submission disabled") + } + parent := opts.Context + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, flashbots.DefaultSubmissionTimeout) + defer cancel() + + // Build and sign without broadcasting through the contract client. + privateOpts := *opts + privateOpts.NoSend = true + privateOpts.Context = ctx + tx, err := megapool.ChallengeExit(t.rp, exitChallenges, &privateOpts) + if err != nil { + return nil, false, err + } + submitLocally := func(reason string) (*gethtypes.Transaction, bool, error) { + t.log.Printlnf("%s; submitting the exit challenge through the local execution client", reason) + if err := t.ec.SendTransaction(parent, tx); err != nil { + return nil, false, err + } + return tx, false, nil + } + + relayURL := t.cfg.Smartnode.GetFlashbotsRelayUrl() + if relayURL == "" { + return submitLocally(fmt.Sprintf("flashbotsRelayUrl is not configured for network %s", t.cfg.GetNetwork())) + } + + t.log.Printlnf("Submitting exit challenge transaction %s as a private bundle...", tx.Hash()) + included, err := flashbots.SubmitBundleAndWait(ctx, nil, t.ec, relayURL, []*gethtypes.Transaction{tx}, flashbots.DefaultBundleBlockCount) + if err != nil { + if errors.Is(err, flashbots.ErrRelayUnavailable) { + return submitLocally(flashbots.ErrRelayUnavailable.Error()) + } + return nil, false, fmt.Errorf("error submitting exit challenge bundle: %w", err) + } + if !included { + return nil, false, fmt.Errorf("exit challenge bundle for transaction %s was not included", tx.Hash()) + } + + // Bundle inclusion checks receipt availability; also require successful execution. + receipt, err := t.ec.TransactionReceipt(ctx, tx.Hash()) + if err != nil { + return nil, false, fmt.Errorf("error checking exit challenge receipt: %w", err) + } + if receipt == nil || receipt.Status != gethtypes.ReceiptStatusSuccessful { + return nil, false, fmt.Errorf("exit challenge transaction %s failed", tx.Hash()) + } + return tx, true, nil +} diff --git a/rocketpool/watchtower/challenge-exit_test.go b/rocketpool/watchtower/challenge-exit_test.go new file mode 100644 index 000000000..6b4a205f0 --- /dev/null +++ b/rocketpool/watchtower/challenge-exit_test.go @@ -0,0 +1,233 @@ +package watchtower + +import ( + "bytes" + "context" + "errors" + "fmt" + "math/big" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "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/rpc" + "github.com/fatih/color" + + "github.com/rocket-pool/smartnode/bindings/contracts" + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + log "github.com/rocket-pool/smartnode/shared/logger" + "github.com/rocket-pool/smartnode/shared/services/config" + "github.com/rocket-pool/smartnode/shared/services/flashbots" +) + +const exitChallengeTestABI = `[{"type":"function","name":"challengeExit","inputs":[{"name":"challenges","type":"tuple[]","components":[{"name":"megapool","type":"address"},{"name":"validatorIds","type":"uint32[]"}]}],"outputs":[],"stateMutability":"nonpayable"}]` + +type exitChallengeLocalClient struct { + rocketpool.ExecutionClient + storageABI abi.ABI + encodedABI string + manager common.Address + sent int + allowSend bool + received *types.Transaction +} + +func (c *exitChallengeLocalClient) CallContract(_ context.Context, msg ethereum.CallMsg, _ *big.Int) ([]byte, error) { + method, err := c.storageABI.MethodById(msg.Data) + if err != nil { + return nil, err + } + switch method.Name { + case "getAddress": + return method.Outputs.Pack(c.manager) + case "getString": + return method.Outputs.Pack(c.encodedABI) + default: + return nil, fmt.Errorf("unexpected storage call: %s", method.Name) + } +} + +func (c *exitChallengeLocalClient) PendingNonceAt(context.Context, common.Address) (uint64, error) { + return 7, nil +} + +func (c *exitChallengeLocalClient) SendTransaction(_ context.Context, tx *types.Transaction) error { + c.sent++ + if !c.allowSend { + return errors.New("transaction leaked to local execution client") + } + c.received = tx + return nil +} + +func (c *exitChallengeLocalClient) BlockNumber(context.Context) (uint64, error) { + return 100, nil +} + +func (c *exitChallengeLocalClient) TransactionReceipt(_ context.Context, hash common.Hash) (*types.Receipt, error) { + return &types.Receipt{TxHash: hash, Status: types.ReceiptStatusSuccessful}, nil +} + +type exitChallengeRelay struct { + sendErr error + received chan *types.Transaction +} + +func (r *exitChallengeRelay) SendBundle(args struct{ Txs []hexutil.Bytes }) (map[string]string, error) { + if len(args.Txs) != 1 { + return nil, fmt.Errorf("expected one transaction, got %d", len(args.Txs)) + } + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(args.Txs[0]); err != nil { + return nil, err + } + r.received <- tx + return map[string]string{"bundleHash": tx.Hash().Hex()}, r.sendErr +} + +func (r *exitChallengeRelay) CancelBundle(_ struct{ ReplacementUUID string }) (bool, error) { + return true, nil +} + +func TestSubmitExitChallengesBundle(t *testing.T) { + for _, test := range []struct { + name string + sendErr error + missingURL bool + invalidURL bool + noSend bool + noSigner bool + wantError string + wantBundles int + wantLocal bool + }{ + {name: "protected only", wantBundles: int(flashbots.DefaultBundleBlockCount)}, + {name: "rejected without public fallback", sendErr: errors.New("private submission rejected"), wantError: "private submission rejected", wantBundles: 1}, + {name: "missing endpoint", missingURL: true, wantLocal: true}, + {name: "invalid endpoint", invalidURL: true, wantError: "error submitting exit challenge bundle"}, + {name: "submission disabled", noSend: true, wantError: "transaction submission disabled"}, + {name: "signing failed", noSigner: true, wantError: "no signer"}, + } { + t.Run(test.name, func(t *testing.T) { + protected := &exitChallengeRelay{ + sendErr: test.sendErr, + received: make(chan *types.Transaction, flashbots.DefaultBundleBlockCount), + } + rpcServer := rpc.NewServer() + if err := rpcServer.RegisterName("eth", protected); err != nil { + t.Fatal(err) + } + defer rpcServer.Stop() + server := httptest.NewServer(rpcServer) + defer server.Close() + + cfg, err := config.NewRocketPoolConfig(t.TempDir(), true) + if err != nil { + t.Fatal(err) + } + cfg.GetNetworkInfo().FlashbotsRelayUrl = server.URL + if test.missingURL { + cfg.GetNetworkInfo().FlashbotsRelayUrl = "" + } + if test.invalidURL { + cfg.GetNetworkInfo().FlashbotsRelayUrl = "://invalid" + } + + storageABI, err := abi.JSON(strings.NewReader(contracts.RocketStorageABI)) + if err != nil { + t.Fatal(err) + } + encodedABI, err := rocketpool.EncodeAbiStr(exitChallengeTestABI) + if err != nil { + t.Fatal(err) + } + local := &exitChallengeLocalClient{storageABI: storageABI, encodedABI: encodedABI, manager: common.Address{1}, allowSend: test.wantLocal} + rp, err := rocketpool.NewRocketPool(local, common.Address{2}) + if err != nil { + t.Fatal(err) + } + key, err := crypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + opts, err := bind.NewKeyedTransactorWithChainID(key, big.NewInt(1)) + if err != nil { + t.Fatal(err) + } + opts.GasLimit = 500000 + opts.GasFeeCap = big.NewInt(20000000000) + opts.GasTipCap = big.NewInt(2000000000) + opts.NoSend = test.noSend + originalContext := opts.Context + if test.noSigner { + opts.Signer = nil + } + batch := []megapool.ExitChallenge{ + {Megapool: common.Address{3}, ValidatorIds: []uint32{0, 10}}, + {Megapool: common.Address{4}, ValidatorIds: []uint32{20}}, + } + task := &challengeValidatorsExiting{log: log.NewColorLogger(color.FgYellow), cfg: cfg, rp: rp, ec: local} + tx, bundled, err := task.submitExitChallenges(batch, opts) + if test.wantError == "" { + if err != nil { + t.Fatal(err) + } + } else if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("got error %v, want %q", err, test.wantError) + } + if !test.wantLocal && local.sent != 0 { + t.Fatalf("broadcast %d transactions to local execution client", local.sent) + } + if test.wantLocal && local.sent != 1 { + t.Fatalf("local execution client received %d transactions, want 1", local.sent) + } + if err == nil && bundled == test.wantLocal { + t.Fatalf("bundled = %t, want %t", bundled, !test.wantLocal) + } + if opts.NoSend != test.noSend || opts.Context != originalContext { + t.Fatal("modified caller's transaction options") + } + if len(protected.received) != test.wantBundles { + t.Fatalf("relay received %d bundles, want %d", len(protected.received), test.wantBundles) + } + var got *types.Transaction + switch { + case test.wantBundles > 0: + got = <-protected.received + case test.wantLocal: + got = local.received + default: + return + } + if err == nil && got.Hash() != tx.Hash() { + t.Fatal("submission received a different transaction") + } + sender, err := types.Sender(types.LatestSignerForChainID(big.NewInt(1)), got) + if err != nil || sender != opts.From { + t.Fatalf("incorrect transaction signer: %s, %v", sender, err) + } + if got.To() == nil || *got.To() != local.manager || got.Nonce() != 7 || got.Gas() != opts.GasLimit || got.GasFeeCap().Cmp(opts.GasFeeCap) != 0 || got.GasTipCap().Cmp(opts.GasTipCap) != 0 { + t.Fatal("incorrect transaction destination, nonce, or gas settings") + } + contractABI, err := abi.JSON(strings.NewReader(exitChallengeTestABI)) + if err != nil { + t.Fatal(err) + } + wantData, err := contractABI.Pack("challengeExit", batch) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got.Data(), wantData) { + t.Fatal("submission received different exit challenge batch calldata") + } + }) + } +} diff --git a/shared/services/flashbots/flashbots_client.go b/shared/services/flashbots/flashbots_client.go index 92acbd6ce..621d5eb8a 100644 --- a/shared/services/flashbots/flashbots_client.go +++ b/shared/services/flashbots/flashbots_client.go @@ -11,6 +11,7 @@ import ( "io" "log/slog" "math/big" + "net" "net/http" "sync" "time" @@ -108,6 +109,7 @@ type SimulationResultBundle struct { } type FlashbotsClient struct { + requestContext context.Context url string logger *slog.Logger client http.Client @@ -178,6 +180,7 @@ func NewClient(logger *slog.Logger, ethRpc EthRpc, relayUrl string, searcherSecr searcherAddress := crypto.PubkeyToAddress(*searcherPublicKeyECDSA) fbClient := FlashbotsClient{ + requestContext: context.Background(), url: url, logger: logger, client: client, @@ -208,7 +211,7 @@ func (client *FlashbotsClient) CallWithAdditionalHeaders(method string, headers return nil, err } - req, err := http.NewRequest("POST", client.url, bytes.NewBuffer(body)) + req, err := http.NewRequestWithContext(client.requestContext, "POST", client.url, bytes.NewBuffer(body)) if err != nil { return nil, err } @@ -246,6 +249,9 @@ func (client *FlashbotsClient) CallWithAdditionalHeaders(method string, headers if err != nil { return nil, errors.Join(errors.New("error reading response body"), err) } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("flashbots API returned HTTP %d: %s", response.StatusCode, bytes.TrimSpace(data)) + } resp := new(Response) if err := json.Unmarshal(data, resp); err != nil { @@ -299,6 +305,10 @@ func (client *FlashbotsClient) SendBundle(bundle *Bundle) (common.Hash, bool, er res, err := client.Call("eth_sendBundle", params) if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + err = errors.Join(ErrRelayUnavailable, err) + } return common.Hash{}, false, errors.Join(errors.New("error calling flashbots"), err) } diff --git a/shared/services/flashbots/submit.go b/shared/services/flashbots/submit.go index 901e66aad..af76ee57a 100644 --- a/shared/services/flashbots/submit.go +++ b/shared/services/flashbots/submit.go @@ -9,13 +9,16 @@ import ( "github.com/ethereum/go-ethereum/core/types" ) -// Defaults for bundle submission: retry the bundle over the next 4 blocks (~48s on mainnet) +// Defaults for bundle submission: retry the bundle over the next 5 blocks (~60s on mainnet) // and give inclusion polling a bit of margin on top of that. const ( DefaultBundleBlockCount uint64 = 5 DefaultSubmissionTimeout = 70 * time.Second ) +// ErrRelayUnavailable identifies a DNS error during bundle submission. +var ErrRelayUnavailable = errors.New("bundle relay DNS lookup failed") + // 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). @@ -27,6 +30,7 @@ func SubmitBundleAndWait(ctx context.Context, logger *slog.Logger, ethRpc EthRpc if err != nil { return false, errors.Join(errors.New("error creating flashbots client"), err) } + client.requestContext = ctx bundle := NewBundleWithTransactions(txs) diff --git a/shared/services/rocketpool/assets/install/networks-default.yml b/shared/services/rocketpool/assets/install/networks-default.yml index 8ff199a53..4d9285771 100644 --- a/shared/services/rocketpool/assets/install/networks-default.yml +++ b/shared/services/rocketpool/assets/install/networks-default.yml @@ -93,6 +93,7 @@ networks: beaconExplorerUrl: https://hoodi.beaconcha.in rocketDashUrl: https://rocketdash.net flashbotsProtectUrl: https://rpc-hoodi.flashbots.net/ + flashbotsRelayUrl: https://builder-rpc-us-hoodi.ultrasound.money beaconNetwork: hoodi commitBoostChainName: Hoodi clientTagSet: test