Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 68 additions & 6 deletions rocketpool/watchtower/challenge-exit.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
)
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}
233 changes: 233 additions & 0 deletions rocketpool/watchtower/challenge-exit_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
12 changes: 11 additions & 1 deletion shared/services/flashbots/flashbots_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"io"
"log/slog"
"math/big"
"net"
"net/http"
"sync"
"time"
Expand Down Expand Up @@ -108,6 +109,7 @@ type SimulationResultBundle struct {
}

type FlashbotsClient struct {
requestContext context.Context
url string
logger *slog.Logger
client http.Client
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand Down
Loading
Loading