diff --git a/arbnode/batch_poster.go b/arbnode/batch_poster.go index d96ed57238..cc9d2f4287 100644 --- a/arbnode/batch_poster.go +++ b/arbnode/batch_poster.go @@ -270,16 +270,6 @@ func NewBatchPoster(ctx context.Context, opts *BatchPosterOpts) (*BatchPoster, e bridgeAddr: opts.DeployInfo.Bridge, daWriter: opts.DAWriter, redisLock: redisLock, - accessList: func(SequencerInboxAccs, AfterDelayedMessagesRead int) types.AccessList { - return AccessList(&AccessListOpts{ - SequencerInboxAddr: opts.DeployInfo.SequencerInbox, - DataPosterAddr: opts.TransactOpts.From, - BridgeAddr: opts.DeployInfo.Bridge, - GasRefunderAddr: opts.Config().gasRefunder, - SequencerInboxAccs: SequencerInboxAccs, - AfterDelayedMessagesRead: AfterDelayedMessagesRead, - }) - }, } dataPosterConfigFetcher := func() *dataposter.DataPosterConfig { return &(opts.Config().DataPoster) @@ -298,6 +288,18 @@ func NewBatchPoster(ctx context.Context, opts *BatchPosterOpts) (*BatchPoster, e if err != nil { return nil, err } + // Dataposter sender may be external signer address, so we should initialize + // access list after initializing dataposter. + b.accessList = func(SequencerInboxAccs, AfterDelayedMessagesRead int) types.AccessList { + return AccessList(&AccessListOpts{ + SequencerInboxAddr: opts.DeployInfo.SequencerInbox, + DataPosterAddr: b.dataPoster.Sender(), + BridgeAddr: opts.DeployInfo.Bridge, + GasRefunderAddr: opts.Config().gasRefunder, + SequencerInboxAccs: SequencerInboxAccs, + AfterDelayedMessagesRead: AfterDelayedMessagesRead, + }) + } return b, nil } diff --git a/cmd/nitro/nitro.go b/cmd/nitro/nitro.go index 711848123c..f23fbc90de 100644 --- a/cmd/nitro/nitro.go +++ b/cmd/nitro/nitro.go @@ -234,7 +234,10 @@ func mainImpl() int { var dataSigner signature.DataSignerFunc var l1TransactionOptsValidator *bind.TransactOpts var l1TransactionOptsBatchPoster *bind.TransactOpts - sequencerNeedsKey := (nodeConfig.Node.Sequencer && !nodeConfig.Node.Feed.Output.DisableSigning) || nodeConfig.Node.BatchPoster.Enable + // If sequencer and signing is enabled or batchposter is enabled without + // external signing sequencer will need a key. + sequencerNeedsKey := (nodeConfig.Node.Sequencer && !nodeConfig.Node.Feed.Output.DisableSigning) || + (nodeConfig.Node.BatchPoster.Enable && nodeConfig.Node.BatchPoster.DataPoster.ExternalSigner.URL == "") validatorNeedsKey := nodeConfig.Node.Staker.OnlyCreateWalletContract || nodeConfig.Node.Staker.Enable && !strings.EqualFold(nodeConfig.Node.Staker.Strategy, "watchtower") l1Wallet.ResolveDirectoryNames(nodeConfig.Persistent.Chain) diff --git a/system_tests/batch_poster_test.go b/system_tests/batch_poster_test.go index 8561e3ffc7..f0c115e2f8 100644 --- a/system_tests/batch_poster_test.go +++ b/system_tests/batch_poster_test.go @@ -8,14 +8,20 @@ import ( "crypto/rand" "fmt" "math/big" + "net/http" + "strings" "testing" "time" "github.com/andybalholm/brotli" + "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" "github.com/offchainlabs/nitro/arbnode" + "github.com/offchainlabs/nitro/solgen/go/bridgegen" + "github.com/offchainlabs/nitro/solgen/go/upgrade_executorgen" "github.com/offchainlabs/nitro/util/redisutil" ) @@ -27,10 +33,50 @@ func TestRedisBatchPosterParallel(t *testing.T) { testBatchPosterParallel(t, true) } +func addNewBatchPoster(ctx context.Context, t *testing.T, builder *NodeBuilder, address common.Address) { + t.Helper() + upgradeExecutor, err := upgrade_executorgen.NewUpgradeExecutor(builder.L2.ConsensusNode.DeployInfo.UpgradeExecutor, builder.L1.Client) + if err != nil { + t.Fatal("Failed to get new upgrade executor", err) + } + sequencerInboxABI, err := abi.JSON(strings.NewReader(bridgegen.SequencerInboxABI)) + if err != nil { + t.Fatal("Failed to parse sequencer inbox abi", err) + } + setIsBatchPoster, err := sequencerInboxABI.Pack("setIsBatchPoster", address, true) + if err != nil { + t.Fatal("Failed to pack setIsBatchPoster", err) + } + ownerOpts := builder.L1Info.GetDefaultTransactOpts("RollupOwner", ctx) + tx, err := upgradeExecutor.ExecuteCall( + &ownerOpts, + builder.L1Info.GetAddress("SequencerInbox"), + setIsBatchPoster) + if err != nil { + t.Fatalf("Error creating transaction to set batch poster: %v", err) + } + if _, err := builder.L1.EnsureTxSucceeded(tx); err != nil { + t.Fatalf("Error setting batch poster: %v", err) + } +} + func testBatchPosterParallel(t *testing.T, useRedis bool) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) defer cancel() + httpSrv, srv := newServer(ctx, t) + t.Cleanup(func() { + if err := httpSrv.Shutdown(ctx); err != nil { + t.Fatalf("Error shutting down http server: %v", err) + } + }) + go func() { + log.Debug("Server is listening on port 1234...") + if err := httpSrv.ListenAndServeTLS(signerServerCert, signerServerKey); err != nil && err != http.ErrServerClosed { + log.Debug("ListenAndServeTLS() failed", "error", err) + return + } + }() var redisUrl string if useRedis { @@ -48,14 +94,19 @@ func testBatchPosterParallel(t *testing.T, useRedis bool) { builder := NewNodeBuilder(ctx).DefaultConfig(t, true) builder.nodeConfig.BatchPoster.Enable = false builder.nodeConfig.BatchPoster.RedisUrl = redisUrl + builder.nodeConfig.BatchPoster.DataPoster.ExternalSigner = *externalSignerTestCfg(srv.address) + cleanup := builder.Build(t) defer cleanup() - testClientB, cleanupB := builder.Build2ndNode(t, &SecondNodeParams{}) defer cleanupB() - builder.L2Info.GenerateAccount("User2") + addNewBatchPoster(ctx, t, builder, srv.address) + + builder.L1.SendWaitTestTransactions(t, []*types.Transaction{ + builder.L1Info.PrepareTxTo("Faucet", &srv.address, 30000, big.NewInt(1e18), nil)}) + var txs []*types.Transaction for i := 0; i < 100; i++ { diff --git a/system_tests/external_signer.go b/system_tests/external_signer.go new file mode 100644 index 0000000000..1ee9c85581 --- /dev/null +++ b/system_tests/external_signer.go @@ -0,0 +1,188 @@ +package arbtest + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "os" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/accounts/keystore" + "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/rlp" + "github.com/ethereum/go-ethereum/signer/core/apitypes" + "github.com/offchainlabs/nitro/arbnode/dataposter" +) + +var ( + signerPort = 1234 + signerURL = fmt.Sprintf("https://localhost:%v", signerPort) + signerMethod = "test_signTransaction" + signerServerCert = "../arbnode/dataposter/testdata/localhost.crt" + signerServerKey = "../arbnode/dataposter/testdata/localhost.key" + signerClientCert = "../arbnode/dataposter/testdata/client.crt" + signerClientPrivateKey = "../arbnode/dataposter/testdata/client.key" +) + +func externalSignerTestCfg(addr common.Address) *dataposter.ExternalSignerCfg { + return &dataposter.ExternalSignerCfg{ + Address: common.Bytes2Hex(addr.Bytes()), + URL: signerURL, + Method: signerMethod, + RootCA: signerServerCert, + ClientCert: signerClientCert, + ClientPrivateKey: signerClientPrivateKey, + } +} + +type server struct { + handlers map[string]func(*json.RawMessage) (string, error) + signerFn bind.SignerFn + address common.Address +} + +type request struct { + ID *json.RawMessage `json:"id"` + Method string `json:"method"` + Params *json.RawMessage `json:"params"` +} + +type response struct { + ID *json.RawMessage `json:"id"` + Result string `json:"result,omitempty"` +} + +// newServer returns http server and server struct that implements RPC methods. +// It sets up an account in temporary directory and cleans up after test is +// done. +func newServer(ctx context.Context, t *testing.T) (*http.Server, *server) { + t.Helper() + signer, address, err := setupAccount("/tmp/keystore") + if err != nil { + t.Fatalf("Error setting up account: %v", err) + } + t.Cleanup(func() { os.RemoveAll("/tmp/keystore") }) + + s := &server{signerFn: signer, address: address} + s.handlers = map[string]func(*json.RawMessage) (string, error){ + signerMethod: s.signTransaction, + } + m := http.NewServeMux() + + clientCert, err := os.ReadFile(signerClientCert) + if err != nil { + t.Fatalf("Error reading client certificate: %v", err) + } + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(clientCert) + + httpSrv := &http.Server{ + Addr: fmt.Sprintf(":%v", signerPort), + Handler: m, + ReadTimeout: 5 * time.Second, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: pool, + }, + } + m.HandleFunc("/", s.mux) + return httpSrv, s +} + +// setupAccount creates a new account in a given directory, unlocks it, creates +// signer with that account and returns it along with account address. +func setupAccount(dir string) (bind.SignerFn, common.Address, error) { + ks := keystore.NewKeyStore( + dir, + keystore.StandardScryptN, + keystore.StandardScryptP, + ) + a, err := ks.NewAccount("password") + if err != nil { + return nil, common.Address{}, fmt.Errorf("creating account account: %w", err) + } + if err := ks.Unlock(a, "password"); err != nil { + return nil, common.Address{}, fmt.Errorf("unlocking account: %w", err) + } + txOpts, err := bind.NewKeyStoreTransactorWithChainID(ks, a, big.NewInt(1337)) + if err != nil { + return nil, common.Address{}, fmt.Errorf("creating transactor: %w", err) + } + return txOpts.Signer, a.Address, nil +} + +// UnmarshallFirst unmarshalls slice of params and returns the first one. +// Parameters in Go ethereum RPC calls are marashalled as slices. E.g. +// eth_sendRawTransaction or eth_signTransaction, marshall transaction as a +// slice of transactions in a message: +// https://github.com/ethereum/go-ethereum/blob/0004c6b229b787281760b14fb9460ffd9c2496f1/rpc/client.go#L548 +func unmarshallFirst(params []byte) (*types.Transaction, error) { + var arr []apitypes.SendTxArgs + if err := json.Unmarshal(params, &arr); err != nil { + return nil, fmt.Errorf("unmarshaling first param: %w", err) + } + if len(arr) != 1 { + return nil, fmt.Errorf("argument should be a single transaction, but got: %d", len(arr)) + } + return arr[0].ToTransaction(), nil +} + +func (s *server) signTransaction(params *json.RawMessage) (string, error) { + tx, err := unmarshallFirst(*params) + if err != nil { + return "", err + } + signedTx, err := s.signerFn(s.address, tx) + if err != nil { + return "", fmt.Errorf("signing transaction: %w", err) + } + data, err := rlp.EncodeToBytes(signedTx) + if err != nil { + return "", fmt.Errorf("rlp encoding transaction: %w", err) + } + return hexutil.Encode(data), nil +} + +func (s *server) mux(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "can't read body", http.StatusBadRequest) + return + } + var req request + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "can't unmarshal JSON request", http.StatusBadRequest) + return + } + method, ok := s.handlers[req.Method] + if !ok { + http.Error(w, "method not found", http.StatusNotFound) + return + } + result, err := method(req.Params) + if err != nil { + fmt.Printf("error calling method: %v\n", err) + http.Error(w, "error calling method", http.StatusInternalServerError) + return + } + resp := response{ID: req.ID, Result: result} + respBytes, err := json.Marshal(resp) + if err != nil { + http.Error(w, fmt.Sprintf("error encoding response: %v", err), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if _, err := w.Write(respBytes); err != nil { + fmt.Printf("error writing response: %v\n", err) + } +}