diff --git a/arbnode/dataposter/data_poster.go b/arbnode/dataposter/data_poster.go index 01827881d9..cf4aab9e37 100644 --- a/arbnode/dataposter/data_poster.go +++ b/arbnode/dataposter/data_poster.go @@ -15,10 +15,12 @@ import ( "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/ethdb" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rlp" "github.com/ethereum/go-ethereum/rpc" "github.com/go-redis/redis/v8" "github.com/offchainlabs/nitro/arbnode/dataposter/dbstorage" @@ -47,7 +49,7 @@ type DataPoster struct { headerReader *headerreader.HeaderReader client arbutil.L1Interface sender common.Address - signer bind.SignerFn + signer signerFn redisLock AttemptLocker config ConfigFetcher replacementTimes []time.Duration @@ -66,6 +68,11 @@ type DataPoster struct { errorCount map[uint64]int // number of consecutive intermittent errors rbf-ing or sending, per nonce } +// signerFn is a signer function callback when a contract requires a method to +// sign the transaction before submission. +// This can be local or external, hence the context parameter. +type signerFn func(context.Context, common.Address, *types.Transaction) (*types.Transaction, error) + type AttemptLocker interface { AttemptLock(context.Context) bool } @@ -85,7 +92,7 @@ func parseReplacementTimes(val string) ([]time.Duration, error) { lastReplacementTime = t } if len(res) == 0 { - log.Warn("disabling replace-by-fee for data poster") + log.Warn("Disabling replace-by-fee for data poster") } // To avoid special casing "don't replace again", replace in 10 years. return append(res, time.Hour*24*365*10), nil @@ -103,13 +110,13 @@ type DataPosterOpts struct { } func NewDataPoster(ctx context.Context, opts *DataPosterOpts) (*DataPoster, error) { - initConfig := opts.Config() - replacementTimes, err := parseReplacementTimes(initConfig.ReplacementTimes) + cfg := opts.Config() + replacementTimes, err := parseReplacementTimes(cfg.ReplacementTimes) if err != nil { return nil, err } - if opts.HeaderReader.IsParentChainArbitrum() && !initConfig.UseNoOpStorage { - initConfig.UseNoOpStorage = true + if opts.HeaderReader.IsParentChainArbitrum() && !cfg.UseNoOpStorage { + cfg.UseNoOpStorage = true log.Info("Disabling data poster storage, as parent chain appears to be an Arbitrum chain without a mempool") } encF := func() storage.EncoderDecoderInterface { @@ -120,17 +127,17 @@ func NewDataPoster(ctx context.Context, opts *DataPosterOpts) (*DataPoster, erro } var queue QueueStorage switch { - case initConfig.UseNoOpStorage: + case cfg.UseNoOpStorage: queue = &noop.Storage{} case opts.RedisClient != nil: var err error - queue, err = redisstorage.NewStorage(opts.RedisClient, opts.RedisKey, &initConfig.RedisSigner, encF) + queue, err = redisstorage.NewStorage(opts.RedisClient, opts.RedisKey, &cfg.RedisSigner, encF) if err != nil { return nil, err } - case initConfig.UseDBStorage: + case cfg.UseDBStorage: storage := dbstorage.New(opts.Database, func() storage.EncoderDecoderInterface { return &storage.EncoderDecoder{} }) - if initConfig.Dangerous.ClearDBStorage { + if cfg.Dangerous.ClearDBStorage { if err := storage.PruneAll(ctx); err != nil { return nil, err } @@ -139,18 +146,64 @@ func NewDataPoster(ctx context.Context, opts *DataPosterOpts) (*DataPoster, erro default: queue = slice.NewStorage(func() storage.EncoderDecoderInterface { return &storage.EncoderDecoder{} }) } - return &DataPoster{ - headerReader: opts.HeaderReader, - client: opts.HeaderReader.Client(), - sender: opts.Auth.From, - signer: opts.Auth.Signer, + dp := &DataPoster{ + headerReader: opts.HeaderReader, + client: opts.HeaderReader.Client(), + sender: opts.Auth.From, + signer: func(_ context.Context, addr common.Address, tx *types.Transaction) (*types.Transaction, error) { + return opts.Auth.Signer(addr, tx) + }, config: opts.Config, replacementTimes: replacementTimes, metadataRetriever: opts.MetadataRetriever, queue: queue, redisLock: opts.RedisLock, errorCount: make(map[uint64]int), - }, nil + } + if cfg.ExternalSigner.URL != "" { + signer, sender, err := externalSigner(ctx, &cfg.ExternalSigner) + if err != nil { + return nil, err + } + dp.signer, dp.sender = signer, sender + } + return dp, nil +} + +// externalSigner returns signer function and ethereum address of the signer. +// Returns an error if address isn't specified or if it can't connect to the +// signer RPC server. +func externalSigner(ctx context.Context, opts *ExternalSignerCfg) (signerFn, common.Address, error) { + if opts.Address == "" { + return nil, common.Address{}, errors.New("external signer (From) address specified") + } + sender := common.HexToAddress(opts.Address) + client, err := rpc.DialContext(ctx, opts.URL) + if err != nil { + return nil, common.Address{}, fmt.Errorf("error connecting external signer: %w", err) + } + + var hasher types.Signer + return func(ctx context.Context, addr common.Address, tx *types.Transaction) (*types.Transaction, error) { + // According to the "eth_signTransaction" API definition, this should be + // RLP encoded transaction object. + // https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_signtransaction + var data hexutil.Bytes + if err := client.CallContext(ctx, &data, opts.Method, tx); err != nil { + return nil, fmt.Errorf("signing transaction: %w", err) + } + var signedTx types.Transaction + if err := rlp.DecodeBytes(data, &signedTx); err != nil { + return nil, fmt.Errorf("error decoding signed transaction: %w", err) + } + if hasher == nil { + hasher = types.LatestSignerForChainID(tx.ChainId()) + } + if hasher.Hash(tx) != hasher.Hash(&signedTx) { + return nil, fmt.Errorf("transaction: %x from external signer differs from request: %x", hasher.Hash(&signedTx), hasher.Hash(tx)) + } + return &signedTx, nil + }, sender, nil } func (p *DataPoster) Sender() common.Address { @@ -371,7 +424,7 @@ func (p *DataPoster) PostTransaction(ctx context.Context, dataCreatedAt time.Tim Data: calldata, AccessList: accessList, } - fullTx, err := p.signer(p.sender, types.NewTx(&inner)) + fullTx, err := p.signer(ctx, p.sender, types.NewTx(&inner)) if err != nil { return nil, fmt.Errorf("signing transaction: %w", err) } @@ -450,7 +503,7 @@ func (p *DataPoster) replaceTx(ctx context.Context, prevTx *storage.QueuedTransa newTx.Sent = false newTx.Data.GasFeeCap = newFeeCap newTx.Data.GasTipCap = newTipCap - newTx.FullTx, err = p.signer(p.sender, types.NewTx(&newTx.Data)) + newTx.FullTx, err = p.signer(ctx, p.sender, types.NewTx(&newTx.Data)) if err != nil { return err } @@ -636,20 +689,32 @@ type DataPosterConfig struct { ReplacementTimes string `koanf:"replacement-times"` // This is forcibly disabled if the parent chain is an Arbitrum chain, // so you should probably use DataPoster's waitForL1Finality method instead of reading this field directly. - WaitForL1Finality bool `koanf:"wait-for-l1-finality" reload:"hot"` - MaxMempoolTransactions uint64 `koanf:"max-mempool-transactions" reload:"hot"` - MaxQueuedTransactions int `koanf:"max-queued-transactions" reload:"hot"` - TargetPriceGwei float64 `koanf:"target-price-gwei" reload:"hot"` - UrgencyGwei float64 `koanf:"urgency-gwei" reload:"hot"` - MinFeeCapGwei float64 `koanf:"min-fee-cap-gwei" reload:"hot"` - MinTipCapGwei float64 `koanf:"min-tip-cap-gwei" reload:"hot"` - MaxTipCapGwei float64 `koanf:"max-tip-cap-gwei" reload:"hot"` - NonceRbfSoftConfs uint64 `koanf:"nonce-rbf-soft-confs" reload:"hot"` - AllocateMempoolBalance bool `koanf:"allocate-mempool-balance" reload:"hot"` - UseDBStorage bool `koanf:"use-db-storage"` - UseNoOpStorage bool `koanf:"use-noop-storage"` - LegacyStorageEncoding bool `koanf:"legacy-storage-encoding" reload:"hot"` - Dangerous DangerousConfig `koanf:"dangerous"` + WaitForL1Finality bool `koanf:"wait-for-l1-finality" reload:"hot"` + MaxMempoolTransactions uint64 `koanf:"max-mempool-transactions" reload:"hot"` + MaxQueuedTransactions int `koanf:"max-queued-transactions" reload:"hot"` + TargetPriceGwei float64 `koanf:"target-price-gwei" reload:"hot"` + UrgencyGwei float64 `koanf:"urgency-gwei" reload:"hot"` + MinFeeCapGwei float64 `koanf:"min-fee-cap-gwei" reload:"hot"` + MinTipCapGwei float64 `koanf:"min-tip-cap-gwei" reload:"hot"` + MaxTipCapGwei float64 `koanf:"max-tip-cap-gwei" reload:"hot"` + NonceRbfSoftConfs uint64 `koanf:"nonce-rbf-soft-confs" reload:"hot"` + AllocateMempoolBalance bool `koanf:"allocate-mempool-balance" reload:"hot"` + UseDBStorage bool `koanf:"use-db-storage"` + UseNoOpStorage bool `koanf:"use-noop-storage"` + LegacyStorageEncoding bool `koanf:"legacy-storage-encoding" reload:"hot"` + Dangerous DangerousConfig `koanf:"dangerous"` + ExternalSigner ExternalSignerCfg `koanf:"external-signer"` +} + +type ExternalSignerCfg struct { + // URL of the external signer rpc server, if set this overrides transaction + // options and uses external signer + // for signing transactions. + URL string `koanf:"url"` + // Hex encoded ethereum address of the external signer. + Address string `koanf:"address"` + // API method name (e.g. eth_signTransaction). + Method string `koanf:"method"` } type DangerousConfig struct { @@ -680,12 +745,19 @@ func DataPosterConfigAddOptions(prefix string, f *pflag.FlagSet, defaultDataPost signature.SimpleHmacConfigAddOptions(prefix+".redis-signer", f) addDangerousOptions(prefix+".dangerous", f) + addExternalSignerOptions(prefix+".external-signer", f) } func addDangerousOptions(prefix string, f *pflag.FlagSet) { f.Bool(prefix+".clear-dbstorage", DefaultDataPosterConfig.Dangerous.ClearDBStorage, "clear database storage") } +func addExternalSignerOptions(prefix string, f *pflag.FlagSet) { + f.String(prefix+".url", DefaultDataPosterConfig.ExternalSigner.URL, "external signer url") + f.String(prefix+".address", DefaultDataPosterConfig.ExternalSigner.Address, "external signer address") + f.String(prefix+".method", DefaultDataPosterConfig.ExternalSigner.Method, "external signer method") +} + var DefaultDataPosterConfig = DataPosterConfig{ ReplacementTimes: "5m,10m,20m,30m,1h,2h,4h,6h,8h,12h,16h,18h,20h,22h", WaitForL1Finality: true, @@ -700,6 +772,7 @@ var DefaultDataPosterConfig = DataPosterConfig{ UseNoOpStorage: false, LegacyStorageEncoding: true, Dangerous: DangerousConfig{ClearDBStorage: false}, + ExternalSigner: ExternalSignerCfg{Method: "eth_signTransaction"}, } var DefaultDataPosterConfigForValidator = func() DataPosterConfig { @@ -721,6 +794,7 @@ var TestDataPosterConfig = DataPosterConfig{ AllocateMempoolBalance: true, UseDBStorage: false, UseNoOpStorage: false, + ExternalSigner: ExternalSignerCfg{Method: "eth_signTransaction"}, } var TestDataPosterConfigForValidator = func() DataPosterConfig { diff --git a/arbnode/dataposter/dataposter_test.go b/arbnode/dataposter/dataposter_test.go index b8a9c3e499..4b4768e0b4 100644 --- a/arbnode/dataposter/dataposter_test.go +++ b/arbnode/dataposter/dataposter_test.go @@ -1,9 +1,23 @@ package dataposter import ( + "context" + "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/google/go-cmp/cmp" ) @@ -41,3 +55,167 @@ func TestParseReplacementTimes(t *testing.T) { }) } } + +func TestExternalSigner(t *testing.T) { + ctx := context.Background() + 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() { + fmt.Println("Server is listening on port 1234...") + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Errorf("ListenAndServe() unexpected error: %v", err) + return + } + }() + signer, addr, err := externalSigner(ctx, + &ExternalSignerCfg{ + Address: srv.address.Hex(), + URL: "http://127.0.0.1:1234", + Method: "test_signTransaction", + }) + if err != nil { + t.Fatalf("Error getting external signer: %v", err) + } + tx := types.NewTransaction(13, common.HexToAddress("0x01"), big.NewInt(1), 2, big.NewInt(3), []byte{0x01, 0x02, 0x03}) + got, err := signer(ctx, addr, tx) + if err != nil { + t.Fatalf("Error signing transaction with external signer: %v", err) + } + want, err := srv.signerFn(addr, tx) + if err != nil { + t.Fatalf("Error signing transaction: %v", err) + } + if diff := cmp.Diff(want.Hash(), got.Hash()); diff != "" { + t.Errorf("Signing transaction: unexpected diff: %v\n", diff) + } +} + +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){ + "test_signTransaction": s.signTransaction, + } + m := http.NewServeMux() + httpSrv := &http.Server{Addr: ":1234", Handler: m, ReadTimeout: 5 * time.Second} + 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(1)) + 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) + } +}