Commit e87f2b3b authored by Mark Tyneway's avatar Mark Tyneway Committed by GitHub

Merge pull request #1576 from cfromknecht/bss-resources

feat: batch-submitter peripheral resources
parents 4ae92056 c6ccc9ef
......@@ -8,7 +8,7 @@ LDFLAGSSTRING +=-X main.GitVersion=$(GITVERSION)
LDFLAGS := -ldflags "$(LDFLAGSSTRING)"
batch-submitter:
env GO111MODULE=on go build $(LDFLAGS) ./cmd/batch-submitter
env GO111MODULE=on go build -v $(LDFLAGS) ./cmd/batch-submitter
clean:
rm batch-submitter
......
package batchsubmitter
import (
"context"
"crypto/ecdsa"
"fmt"
"net/http"
"os"
"strconv"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/log"
"github.com/getsentry/sentry-go"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/urfave/cli"
)
const (
// defaultDialTimeout is default duration the service will wait on
// startup to make a connection to either the L1 or L2 backends.
defaultDialTimeout = 5 * time.Second
)
// Main is the entrypoint into the batch submitter service. This method returns
// a closure that executes the service and blocks until the service exits. The
// use of a closure allows the parameters bound to the top-level main package,
// e.g. GitVersion, to be captured and used once the function is executed.
func Main(gitVersion string) func(ctx *cli.Context) error {
return func(ctx *cli.Context) error {
cfg, err := NewConfig(ctx)
if err != nil {
return err
}
// The call to defer is done here so that any errors logged from
// this point on are posted to Sentry before exiting.
if cfg.SentryEnable {
defer sentry.Flush(2 * time.Second)
}
_, err = NewBatchSubmitter(cfg, gitVersion)
if err != nil {
log.Error("Unable to create batch submitter", "error", err)
return err
}
return nil
}
}
// BatchSubmitter is a service that configures the necessary resources for
// running the TxBatchSubmitter and StateBatchSubmitter sub-services.
type BatchSubmitter struct {
ctx context.Context
cfg Config
l1Client *ethclient.Client
l2Client *ethclient.Client
sequencerPrivKey *ecdsa.PrivateKey
proposerPrivKey *ecdsa.PrivateKey
ctcAddress common.Address
sccAddress common.Address
}
// NewBatchSubmitter initializes the BatchSubmitter, gathering any resources
// that will be needed by the TxBatchSubmitter and StateBatchSubmitter
// sub-services.
func NewBatchSubmitter(cfg Config, gitVersion string) (*BatchSubmitter, error) {
ctx := context.Background()
// Set up our logging. If Sentry is enabled, we will use our custom
// log handler that logs to stdout and forwards any error messages to
// Sentry for collection. Otherwise, logs will only be posted to stdout.
var logHandler log.Handler
if cfg.SentryEnable {
err := sentry.Init(sentry.ClientOptions{
Dsn: cfg.SentryDsn,
Environment: cfg.EthNetworkName,
Release: "batch-submitter@" + gitVersion,
TracesSampleRate: traceRateToFloat64(cfg.SentryTraceRate),
Debug: false,
})
if err != nil {
return nil, err
}
logHandler = SentryStreamHandler(os.Stdout, log.TerminalFormat(true))
} else {
logHandler = log.StreamHandler(os.Stdout, log.TerminalFormat(true))
}
logLevel, err := log.LvlFromString(cfg.LogLevel)
if err != nil {
return nil, err
}
log.Root().SetHandler(log.LvlFilterHandler(logLevel, logHandler))
log.Info("Config", "config", fmt.Sprintf("%#v", cfg))
// Parse sequencer private key and CTC contract address.
sequencerPrivKey, ctcAddress, err := parseWalletPrivKeyAndContractAddr(
"Sequencer", cfg.Mnemonic, cfg.SequencerHDPath,
cfg.SequencerPrivateKey, cfg.CTCAddress,
)
if err != nil {
return nil, err
}
// Parse proposer private key and SCC contract address.
proposerPrivKey, sccAddress, err := parseWalletPrivKeyAndContractAddr(
"Proposer", cfg.Mnemonic, cfg.ProposerHDPath,
cfg.ProposerPrivateKey, cfg.SCCAddress,
)
if err != nil {
return nil, err
}
// Connect to L1 and L2 providers. Perform these lastsince they are the
// most expensive.
l1Client, err := dialEthClientWithTimeout(ctx, cfg.L1EthRpc)
if err != nil {
return nil, err
}
l2Client, err := dialEthClientWithTimeout(ctx, cfg.L2EthRpc)
if err != nil {
return nil, err
}
if cfg.MetricsServerEnable {
go runMetricsServer(cfg.MetricsHostname, cfg.MetricsPort)
}
return &BatchSubmitter{
ctx: ctx,
cfg: cfg,
l1Client: l1Client,
l2Client: l2Client,
sequencerPrivKey: sequencerPrivKey,
proposerPrivKey: proposerPrivKey,
ctcAddress: ctcAddress,
sccAddress: sccAddress,
}, nil
}
// parseWalletPrivKeyAndContractAddr returns the wallet private key to use for
// sending transactions as well as the contract address to send to for a
// particular sub-service.
func parseWalletPrivKeyAndContractAddr(
name string,
mnemonic string,
hdPath string,
privKeyStr string,
contractAddrStr string,
) (*ecdsa.PrivateKey, common.Address, error) {
// Parse wallet private key from either privkey string or BIP39 mnemonic
// and BIP32 HD derivation path.
privKey, err := GetConfiguredPrivateKey(mnemonic, hdPath, privKeyStr)
if err != nil {
return nil, common.Address{}, err
}
// Parse the target contract address the wallet will send to.
contractAddress, err := ParseAddress(contractAddrStr)
if err != nil {
return nil, common.Address{}, err
}
// Log wallet address rather than private key...
walletAddress := crypto.PubkeyToAddress(privKey.PublicKey)
log.Info(name+" wallet params parsed successfully", "wallet_address",
walletAddress, "contract_address", contractAddress)
return privKey, contractAddress, nil
}
// runMetricsServer spins up a prometheus metrics server at the provided
// hostname and port.
//
// NOTE: This method MUST be run as a goroutine.
func runMetricsServer(hostname string, port uint64) {
metricsPortStr := strconv.FormatUint(port, 10)
metricsAddr := fmt.Sprintf("%s: %s", hostname, metricsPortStr)
http.Handle("/metrics", promhttp.Handler())
_ = http.ListenAndServe(metricsAddr, nil)
}
// dialEthClientWithTimeout attempts to dial the L1 or L2 provider using the
// provided URL. If the dial doesn't complete within defaultDialTimeout seconds,
// this method will return an error.
func dialEthClientWithTimeout(ctx context.Context, url string) (
*ethclient.Client, error) {
ctxt, cancel := context.WithTimeout(ctx, defaultDialTimeout)
defer cancel()
return ethclient.DialContext(ctxt, url)
}
// traceRateToFloat64 converts a time.Duration into a valid float64 for the
// Sentry client. The client only accepts values between 0.0 and 1.0, so this
// method clamps anything greater than 1 second to 1.0.
func traceRateToFloat64(rate time.Duration) float64 {
rate64 := float64(rate) / float64(time.Second)
if rate64 > 1.0 {
rate64 = 1.0
}
return rate64
}
......@@ -36,27 +36,7 @@ func main() {
app.Description = "Service for generating and submitting batched transactions " +
"that synchronize L2 state to L1 contracts"
app.Action = func(ctx *cli.Context) error {
cfg, err := batchsubmitter.NewConfig(ctx)
if err != nil {
return err
}
logLevel, err := log.LvlFromString(cfg.LogLevel)
if err != nil {
return err
}
log.Root().SetHandler(
log.LvlFilterHandler(
logLevel,
log.StreamHandler(os.Stdout, log.TerminalFormat(true)),
),
)
log.Info("Config", "message", fmt.Sprintf("%#v", cfg))
return nil
}
app.Action = batchsubmitter.Main(GitVersion)
err := app.Run(os.Args)
if err != nil {
log.Crit("Application failed", "message", err)
......
package batchsubmitter
import (
"crypto/ecdsa"
"errors"
"fmt"
"strings"
"github.com/decred/dcrd/hdkeychain/v3"
"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/tyler-smith/go-bip39"
)
var (
// ErrCannotGetPrivateKey signals that an both or neither combination of
// mnemonic+hdpath or private key string was used in the configuration.
ErrCannotGetPrivateKey = errors.New("invalid combination of privkey " +
"or mnemonic+hdpath")
)
// ParseAddress parses an ETH addres from a hex string. This method will fail if
// the address is not a valid hexidecimal address.
func ParseAddress(address string) (common.Address, error) {
if common.IsHexAddress(address) {
return common.HexToAddress(address), nil
}
return common.Address{}, fmt.Errorf("invalid address: %v", address)
}
// GetConfiguredPrivateKey computes the private key for our configured services.
// The two supported methods are:
// - Derived from BIP39 mnemonic and BIP32 HD derivation path.
// - Directly from a serialized private key.
func GetConfiguredPrivateKey(mnemonic, hdPath, privKeyStr string) (
*ecdsa.PrivateKey, error) {
useMnemonic := mnemonic != "" && hdPath != ""
usePrivKeyStr := privKeyStr != ""
switch {
case useMnemonic && !usePrivKeyStr:
return DerivePrivateKey(mnemonic, hdPath)
case usePrivKeyStr && !useMnemonic:
return ParsePrivateKeyStr(privKeyStr)
default:
return nil, ErrCannotGetPrivateKey
}
}
// fakeNetworkParams implements the hdkeychain.NetworkParams interface. These
// methods are unused in the child derivation, and only needed for serializing
// xpubs/xprivs which we don't rely on.
type fakeNetworkParams struct{}
func (f fakeNetworkParams) HDPrivKeyVersion() [4]byte {
return [4]byte{}
}
func (f fakeNetworkParams) HDPubKeyVersion() [4]byte {
return [4]byte{}
}
// DerivePrivateKey derives the private key from a given mnemonic and BIP32
// deriviation path.
func DerivePrivateKey(mnemonic, hdPath string) (*ecdsa.PrivateKey, error) {
// Parse the seed string into the master BIP32 key.
seed, err := bip39.NewSeedWithErrorChecking(mnemonic, "")
if err != nil {
return nil, err
}
privKey, err := hdkeychain.NewMaster(seed, fakeNetworkParams{})
if err != nil {
return nil, err
}
// Parse the derivation path and derive a child for each level of the
// BIP32 derivation path.
derivationPath, err := accounts.ParseDerivationPath(hdPath)
if err != nil {
return nil, err
}
for _, child := range derivationPath {
privKey, err = privKey.Child(child)
if err != nil {
return nil, err
}
}
rawPrivKey, err := privKey.SerializedPrivKey()
if err != nil {
return nil, err
}
return crypto.ToECDSA(rawPrivKey)
}
// ParsePrivateKeyStr parses a hexidecimal encoded private key, the encoding may
// optionally have an "0x" prefix.
func ParsePrivateKeyStr(privKeyStr string) (*ecdsa.PrivateKey, error) {
hex := strings.TrimPrefix(privKeyStr, "0x")
return crypto.HexToECDSA(hex)
}
package batchsubmitter_test
import (
"bytes"
"errors"
"strings"
"testing"
batchsubmitter "github.com/ethereum-optimism/go/batch-submitter"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/stretchr/testify/require"
)
var (
validMnemonic = strings.Join([]string{
"abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "about",
}, " ")
validHDPath = "m/44'/60'/0'/128"
// validPrivKeyStr is the private key string for the child derived at
// validHDPath from validMnemonic.
validPrivKeyStr = "69d3a0e79bf039ca788924cb98b6b60c5f5aaa5e770aef09b4b15fdb59944d02"
// validPrivKeyBytes is the raw private key bytes for the child derived
// at validHDPath from validMnemonic.
validPrivKeyBytes = []byte{
0x69, 0xd3, 0xa0, 0xe7, 0x9b, 0xf0, 0x39, 0xca,
0x78, 0x89, 0x24, 0xcb, 0x98, 0xb6, 0xb6, 0x0c,
0x5f, 0x5a, 0xaa, 0x5e, 0x77, 0x0a, 0xef, 0x09,
0xb4, 0xb1, 0x5f, 0xdb, 0x59, 0x94, 0x4d, 0x02,
}
// invalidMnemonic has an invalid checksum.
invalidMnemonic = strings.Join([]string{
"abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon",
"abandon", "abandon", "abandon", "abandon",
}, " ")
)
// TestParseAddress asserts that ParseAddress correctly parses 40-characater
// hexidecimal strings with optional 0x prefix into valid 20-byte addresses.
func TestParseAddress(t *testing.T) {
tests := []struct {
name string
addr string
expErr error
expAddr common.Address
}{
{
name: "empty address",
addr: "",
expErr: errors.New("invalid address: "),
},
{
name: "only 0x",
addr: "0x",
expErr: errors.New("invalid address: 0x"),
},
{
name: "non hex character",
addr: "0xaaaaaazaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
expErr: errors.New("invalid address: 0xaaaaaazaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
},
{
name: "valid address",
addr: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
expErr: nil,
expAddr: common.BytesToAddress(bytes.Repeat([]byte{170}, 20)),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
addr, err := batchsubmitter.ParseAddress(test.addr)
require.Equal(t, err, test.expErr)
if test.expErr != nil {
return
}
require.Equal(t, addr, test.expAddr)
})
}
}
// TestDerivePrivateKey asserts that DerivePrivateKey properly parses a BIP39
// mnemonic and BIP32 HD path, and derives the corresponding private key.
func TestDerivePrivateKey(t *testing.T) {
tests := []struct {
name string
mnemonic string
hdPath string
expErr error
expPrivKey []byte
}{
{
name: "invalid mnemonic",
mnemonic: invalidMnemonic,
hdPath: validHDPath,
expErr: errors.New("Checksum incorrect"),
},
{
name: "valid mnemonic invalid hdpath",
mnemonic: validMnemonic,
hdPath: "",
expErr: errors.New("ambiguous path: use 'm/' prefix for absolute " +
"paths, or no leading '/' for relative ones"),
},
{
name: "valid mnemonic invalid hdpath",
mnemonic: validMnemonic,
hdPath: "m/",
expErr: errors.New("invalid component: "),
},
{
name: "valid mnemonic valid hdpath no components",
mnemonic: validMnemonic,
hdPath: "m/0",
expPrivKey: []byte{
0xba, 0xa8, 0x9a, 0x8b, 0xdd, 0x61, 0xc5, 0xe2,
0x2b, 0x9f, 0x10, 0x60, 0x1d, 0x87, 0x91, 0xc9,
0xf8, 0xfc, 0x4b, 0x2f, 0xa6, 0xdf, 0x9d, 0x68,
0xd3, 0x36, 0xf0, 0xeb, 0x03, 0xb0, 0x6e, 0xb6,
},
},
{
name: "valid mnemonic valid hdpath full path",
mnemonic: validMnemonic,
hdPath: validHDPath,
expPrivKey: []byte{
0x69, 0xd3, 0xa0, 0xe7, 0x9b, 0xf0, 0x39, 0xca,
0x78, 0x89, 0x24, 0xcb, 0x98, 0xb6, 0xb6, 0x0c,
0x5f, 0x5a, 0xaa, 0x5e, 0x77, 0x0a, 0xef, 0x09,
0xb4, 0xb1, 0x5f, 0xdb, 0x59, 0x94, 0x4d, 0x02,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
privKey, err := batchsubmitter.DerivePrivateKey(test.mnemonic, test.hdPath)
require.Equal(t, err, test.expErr)
if test.expErr != nil {
return
}
expPrivKey, err := crypto.ToECDSA(test.expPrivKey)
require.Nil(t, err)
require.Equal(t, privKey, expPrivKey)
})
}
}
// TestParsePrivateKeyStr asserts that ParsePrivateKey properly parses
// 64-character hexidecimal strings with optional 0x prefix into valid ECDSA
// private keys.
func TestParsePrivateKeyStr(t *testing.T) {
tests := []struct {
name string
privKeyStr string
expErr error
expPrivKey []byte
}{
{
name: "empty privkey string",
privKeyStr: "",
expErr: errors.New("invalid length, need 256 bits"),
},
{
name: "privkey string only 0x",
privKeyStr: "0x",
expErr: errors.New("invalid length, need 256 bits"),
},
{
name: "non hex privkey string",
privKeyStr: "0xaaaazaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
expErr: errors.New("invalid hex character 'z' in private key"),
},
{
name: "valid privkey string",
privKeyStr: validPrivKeyStr,
expPrivKey: validPrivKeyBytes,
},
{
name: "valid privkey string with 0x",
privKeyStr: "0x" + validPrivKeyStr,
expPrivKey: validPrivKeyBytes,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
privKey, err := batchsubmitter.ParsePrivateKeyStr(test.privKeyStr)
require.Equal(t, err, test.expErr)
if test.expErr != nil {
return
}
expPrivKey, err := crypto.ToECDSA(test.expPrivKey)
require.Nil(t, err)
require.Equal(t, privKey, expPrivKey)
})
}
}
// TestGetConfiguredPrivateKey asserts that GetConfiguredPrivateKey either:
// 1) Derives the correct private key assuming the BIP39 mnemonic and BIP32
// derivation path are both present and the private key string is ommitted.
// 2) Parses the correct private key assuming only the private key string is
// present, but the BIP39 mnemonic and BIP32 derivation path are ommitted.
func TestGetConfiguredPrivateKey(t *testing.T) {
tests := []struct {
name string
mnemonic string
hdPath string
privKeyStr string
expErr error
expPrivKey []byte
}{
{
name: "valid mnemonic+hdpath",
mnemonic: validMnemonic,
hdPath: validHDPath,
privKeyStr: "",
expPrivKey: validPrivKeyBytes,
},
{
name: "valid privkey",
mnemonic: "",
hdPath: "",
privKeyStr: validPrivKeyStr,
expPrivKey: validPrivKeyBytes,
},
{
name: "valid privkey with 0x",
mnemonic: "",
hdPath: "",
privKeyStr: "0x" + validPrivKeyStr,
expPrivKey: validPrivKeyBytes,
},
{
name: "valid menmonic+hdpath and privkey",
mnemonic: validMnemonic,
hdPath: validHDPath,
privKeyStr: validPrivKeyStr,
expErr: batchsubmitter.ErrCannotGetPrivateKey,
},
{
name: "neither menmonic+hdpath or privkey",
mnemonic: "",
hdPath: "",
privKeyStr: "",
expErr: batchsubmitter.ErrCannotGetPrivateKey,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
privKey, err := batchsubmitter.GetConfiguredPrivateKey(
test.mnemonic, test.hdPath, test.privKeyStr,
)
require.Equal(t, err, test.expErr)
if test.expErr != nil {
return
}
expPrivKey, err := crypto.ToECDSA(test.expPrivKey)
require.Nil(t, err)
require.Equal(t, privKey, expPrivKey)
})
}
}
......@@ -3,7 +3,11 @@ module github.com/ethereum-optimism/go/batch-submitter
go 1.16
require (
github.com/decred/dcrd/hdkeychain/v3 v3.0.0
github.com/ethereum/go-ethereum v1.10.8
github.com/getsentry/sentry-go v0.11.0
github.com/prometheus/client_golang v1.0.0
github.com/stretchr/testify v1.7.0
github.com/tyler-smith/go-bip39 v1.0.1-0.20181017060643-dbb3b84ba2ef
github.com/urfave/cli v1.22.5
)
This diff is collapsed.
package batchsubmitter
import (
"errors"
"io"
"github.com/ethereum/go-ethereum/log"
"github.com/getsentry/sentry-go"
)
var jsonFmt = log.JSONFormat()
// SentryStreamHandler creates a log.Handler that behaves similarly to
// log.StreamHandler, however it writes any log levels greater than or equal to
// LvlError to Sentry. In that case, the passed log.Record is encoded using JSON
// rather than the default terminal output, so that it can be captured for
// debugging in the Sentry dashboard.
func SentryStreamHandler(wr io.Writer, fmtr log.Format) log.Handler {
h := log.FuncHandler(func(r *log.Record) error {
_, err := wr.Write(fmtr.Format(r))
// If this record is LvlError or higher, serialize the record
// using JSON and write it to Sentry. We also capture the error
// message separately so that it's easy to parse what the error
// is in the dashboard.
if r.Lvl >= log.LvlError {
sentry.WithScope(func(scope *sentry.Scope) {
scope.SetExtra("context", jsonFmt.Format(r))
sentry.CaptureException(errors.New(r.Msg))
})
}
return err
})
return log.LazyHandler(log.SyncHandler(h))
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment