From b949de6f9de34c3bd76444f3274cdd1d2ad5478a Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:00 -0300 Subject: [PATCH 1/2] Submit challengeExit as a bundle, avoiding the mempool --- rocketpool/watchtower/challenge-exit.go | 74 ++++++- rocketpool/watchtower/challenge-exit_test.go | 207 ++++++++++++++++++ shared/services/flashbots/flashbots_client.go | 12 +- shared/services/flashbots/submit.go | 6 +- .../assets/install/networks-default.yml | 1 + 5 files changed, 292 insertions(+), 8 deletions(-) create mode 100644 rocketpool/watchtower/challenge-exit_test.go 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..f9246c47a --- /dev/null +++ b/rocketpool/watchtower/challenge-exit_test.go @@ -0,0 +1,207 @@ +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/rocket-pool/smartnode/bindings/contracts" + "github.com/rocket-pool/smartnode/bindings/megapool" + "github.com/rocket-pool/smartnode/bindings/rocketpool" + "github.com/rocket-pool/smartnode/shared/services/config" +) + +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 +} + +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, *types.Transaction) error { + c.sent++ + return errors.New("transaction leaked to local execution client") +} + +type exitChallengeProtectedRPC struct { + chainID int64 + chainErr error + sendErr error + received chan *types.Transaction +} + +func (r *exitChallengeProtectedRPC) ChainId() (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(r.chainID)), r.chainErr +} + +func (r *exitChallengeProtectedRPC) SendRawTransaction(raw hexutil.Bytes) (common.Hash, error) { + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + r.received <- tx + return tx.Hash(), r.sendErr +} + +func TestSubmitExitChallengesProtectedRPC(t *testing.T) { + for _, test := range []struct { + name string + chainID int64 + chainErr error + sendErr error + missingURL bool + invalidURL bool + noSend bool + noSigner bool + wantError string + wantSent bool + }{ + {name: "protected only", chainID: 1, wantSent: true}, + {name: "rejected without public fallback", chainID: 1, sendErr: errors.New("private submission rejected"), wantError: "private submission rejected", wantSent: true}, + {name: "wrong chain", chainID: 2, wantError: "does not match"}, + {name: "chain lookup failed", chainErr: errors.New("chain lookup failed"), wantError: "chain lookup failed"}, + {name: "missing endpoint", missingURL: true, wantError: "flashbotsProtectUrl is not configured"}, + {name: "invalid endpoint", invalidURL: true, wantError: "error connecting to protected RPC"}, + {name: "submission disabled", noSend: true, wantError: "transaction submission disabled"}, + {name: "signing failed", chainID: 1, noSigner: true, wantError: "no signer"}, + } { + t.Run(test.name, func(t *testing.T) { + protected := &exitChallengeProtectedRPC{ + chainID: test.chainID, chainErr: test.chainErr, sendErr: test.sendErr, + received: make(chan *types.Transaction, 1), + } + 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().FlashbotsProtectUrl = server.URL + if test.missingURL { + cfg.GetNetworkInfo().FlashbotsProtectUrl = "" + } + if test.invalidURL { + cfg.GetNetworkInfo().FlashbotsProtectUrl = "://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}} + 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{cfg: cfg, rp: rp, ec: local} + tx, 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 local.sent != 0 { + t.Fatalf("broadcast %d transactions to local execution client", local.sent) + } + if opts.NoSend != test.noSend || opts.Context != originalContext { + t.Fatal("modified caller's transaction options") + } + if (len(protected.received) != 0) != test.wantSent { + t.Fatalf("protected RPC received %d transactions, wantSent=%t", len(protected.received), test.wantSent) + } + if !test.wantSent { + return + } + got := <-protected.received + if err == nil && got.Hash() != tx.Hash() { + t.Fatal("protected RPC 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("protected RPC 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 From 1c212f11f5c16f660dbedfaadbc4feb3ce0f5ebd Mon Sep 17 00:00:00 2001 From: Fornax <23104993+0xfornax@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:44:13 -0300 Subject: [PATCH 2/2] Fix test --- rocketpool/watchtower/challenge-exit_test.go | 120 +++++++++++-------- 1 file changed, 73 insertions(+), 47 deletions(-) diff --git a/rocketpool/watchtower/challenge-exit_test.go b/rocketpool/watchtower/challenge-exit_test.go index f9246c47a..6b4a205f0 100644 --- a/rocketpool/watchtower/challenge-exit_test.go +++ b/rocketpool/watchtower/challenge-exit_test.go @@ -18,11 +18,14 @@ import ( "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"}]` @@ -33,6 +36,8 @@ type exitChallengeLocalClient struct { 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) { @@ -54,57 +59,67 @@ func (c *exitChallengeLocalClient) PendingNonceAt(context.Context, common.Addres return 7, nil } -func (c *exitChallengeLocalClient) SendTransaction(context.Context, *types.Transaction) error { +func (c *exitChallengeLocalClient) SendTransaction(_ context.Context, tx *types.Transaction) error { c.sent++ - return errors.New("transaction leaked to local execution client") + if !c.allowSend { + return errors.New("transaction leaked to local execution client") + } + c.received = tx + return nil } -type exitChallengeProtectedRPC struct { - chainID int64 - chainErr error - sendErr error - received chan *types.Transaction +func (c *exitChallengeLocalClient) BlockNumber(context.Context) (uint64, error) { + return 100, nil } -func (r *exitChallengeProtectedRPC) ChainId() (*hexutil.Big, error) { - return (*hexutil.Big)(big.NewInt(r.chainID)), r.chainErr +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 *exitChallengeProtectedRPC) SendRawTransaction(raw hexutil.Bytes) (common.Hash, error) { +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(raw); err != nil { - return common.Hash{}, err + if err := tx.UnmarshalBinary(args.Txs[0]); err != nil { + return nil, err } r.received <- tx - return tx.Hash(), r.sendErr + return map[string]string{"bundleHash": tx.Hash().Hex()}, r.sendErr +} + +func (r *exitChallengeRelay) CancelBundle(_ struct{ ReplacementUUID string }) (bool, error) { + return true, nil } -func TestSubmitExitChallengesProtectedRPC(t *testing.T) { +func TestSubmitExitChallengesBundle(t *testing.T) { for _, test := range []struct { - name string - chainID int64 - chainErr error - sendErr error - missingURL bool - invalidURL bool - noSend bool - noSigner bool - wantError string - wantSent bool + name string + sendErr error + missingURL bool + invalidURL bool + noSend bool + noSigner bool + wantError string + wantBundles int + wantLocal bool }{ - {name: "protected only", chainID: 1, wantSent: true}, - {name: "rejected without public fallback", chainID: 1, sendErr: errors.New("private submission rejected"), wantError: "private submission rejected", wantSent: true}, - {name: "wrong chain", chainID: 2, wantError: "does not match"}, - {name: "chain lookup failed", chainErr: errors.New("chain lookup failed"), wantError: "chain lookup failed"}, - {name: "missing endpoint", missingURL: true, wantError: "flashbotsProtectUrl is not configured"}, - {name: "invalid endpoint", invalidURL: true, wantError: "error connecting to protected RPC"}, + {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", chainID: 1, noSigner: true, wantError: "no signer"}, + {name: "signing failed", noSigner: true, wantError: "no signer"}, } { t.Run(test.name, func(t *testing.T) { - protected := &exitChallengeProtectedRPC{ - chainID: test.chainID, chainErr: test.chainErr, sendErr: test.sendErr, - received: make(chan *types.Transaction, 1), + protected := &exitChallengeRelay{ + sendErr: test.sendErr, + received: make(chan *types.Transaction, flashbots.DefaultBundleBlockCount), } rpcServer := rpc.NewServer() if err := rpcServer.RegisterName("eth", protected); err != nil { @@ -118,12 +133,12 @@ func TestSubmitExitChallengesProtectedRPC(t *testing.T) { if err != nil { t.Fatal(err) } - cfg.GetNetworkInfo().FlashbotsProtectUrl = server.URL + cfg.GetNetworkInfo().FlashbotsRelayUrl = server.URL if test.missingURL { - cfg.GetNetworkInfo().FlashbotsProtectUrl = "" + cfg.GetNetworkInfo().FlashbotsRelayUrl = "" } if test.invalidURL { - cfg.GetNetworkInfo().FlashbotsProtectUrl = "://invalid" + cfg.GetNetworkInfo().FlashbotsRelayUrl = "://invalid" } storageABI, err := abi.JSON(strings.NewReader(contracts.RocketStorageABI)) @@ -134,7 +149,7 @@ func TestSubmitExitChallengesProtectedRPC(t *testing.T) { if err != nil { t.Fatal(err) } - local := &exitChallengeLocalClient{storageABI: storageABI, encodedABI: encodedABI, manager: common.Address{1}} + 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) @@ -159,8 +174,8 @@ func TestSubmitExitChallengesProtectedRPC(t *testing.T) { {Megapool: common.Address{3}, ValidatorIds: []uint32{0, 10}}, {Megapool: common.Address{4}, ValidatorIds: []uint32{20}}, } - task := &challengeValidatorsExiting{cfg: cfg, rp: rp, ec: local} - tx, err := task.submitExitChallenges(batch, opts) + 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) @@ -168,21 +183,32 @@ func TestSubmitExitChallengesProtectedRPC(t *testing.T) { } else if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Fatalf("got error %v, want %q", err, test.wantError) } - if local.sent != 0 { + 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) != 0) != test.wantSent { - t.Fatalf("protected RPC received %d transactions, wantSent=%t", len(protected.received), test.wantSent) + if len(protected.received) != test.wantBundles { + t.Fatalf("relay received %d bundles, want %d", len(protected.received), test.wantBundles) } - if !test.wantSent { + var got *types.Transaction + switch { + case test.wantBundles > 0: + got = <-protected.received + case test.wantLocal: + got = local.received + default: return } - got := <-protected.received if err == nil && got.Hash() != tx.Hash() { - t.Fatal("protected RPC received a different transaction") + t.Fatal("submission received a different transaction") } sender, err := types.Sender(types.LatestSignerForChainID(big.NewInt(1)), got) if err != nil || sender != opts.From { @@ -200,7 +226,7 @@ func TestSubmitExitChallengesProtectedRPC(t *testing.T) { t.Fatal(err) } if !bytes.Equal(got.Data(), wantData) { - t.Fatal("protected RPC received different exit challenge batch calldata") + t.Fatal("submission received different exit challenge batch calldata") } }) }