Commit 705c7ef8 authored by Joshua Gutow's avatar Joshua Gutow

op-proposer: Cleanup

This removes the driver / service design from the proposer & puts it all
in the L2 Output Submitter. This significantly reduces some of the
boilerplate. This also removes some extra config options.
parent 98b6a400
......@@ -4,6 +4,7 @@ import (
"context"
"crypto/ecdsa"
"math/big"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
......@@ -14,6 +15,7 @@ import (
"github.com/ethereum-optimism/optimism/op-node/sources"
"github.com/ethereum-optimism/optimism/op-proposer/proposer"
"github.com/ethereum-optimism/optimism/op-proposer/txmgr"
opcrypto "github.com/ethereum-optimism/optimism/op-service/crypto"
)
......@@ -26,28 +28,41 @@ type ProposerCfg struct {
type L2Proposer struct {
log log.Logger
l1 *ethclient.Client
driver *proposer.Driver
driver *proposer.L2OutputSubmitter
address common.Address
lastTx common.Hash
}
func NewL2Proposer(t Testing, log log.Logger, cfg *ProposerCfg, l1 *ethclient.Client, rollupCl *sources.RollupClient) *L2Proposer {
chainID, err := l1.ChainID(t.Ctx())
require.NoError(t, err)
signer := opcrypto.PrivateKeySignerFn(cfg.ProposerKey, chainID)
dr, err := proposer.NewDriver(proposer.DriverConfig{
Log: log,
Name: "proposer",
signer := func(chainID *big.Int) proposer.SignerFn {
s := opcrypto.PrivateKeySignerFn(cfg.ProposerKey, chainID)
return func(_ context.Context, addr common.Address, tx *types.Transaction) (*types.Transaction, error) {
return s(addr, tx)
}
}
from := crypto.PubkeyToAddress(cfg.ProposerKey.PublicKey)
proposerCfg := proposer.Config{
L2OutputOracleAddr: cfg.OutputOracleAddr,
PollInterval: time.Second,
TxManagerConfig: txmgr.Config{
Log: log,
Name: "action-proposer",
ResubmissionTimeout: 5 * time.Second,
ReceiptQueryInterval: time.Second,
NumConfirmations: 1,
SafeAbortNonceTooLowCount: 4,
},
L1Client: l1,
RollupClient: rollupCl,
AllowNonFinalized: cfg.AllowNonFinalized,
L2OOAddr: cfg.OutputOracleAddr,
From: crypto.PubkeyToAddress(cfg.ProposerKey.PublicKey),
SignerFn: func(_ context.Context, addr common.Address, tx *types.Transaction) (*types.Transaction, error) {
return signer(addr, tx)
},
})
From: from,
SignerFnFactory: signer,
}
dr, err := proposer.NewL2OutputSubmitterWithSigner(proposerCfg, log)
require.NoError(t, err)
return &L2Proposer{
log: log,
l1: l1,
......@@ -57,25 +72,24 @@ func NewL2Proposer(t Testing, log log.Logger, cfg *ProposerCfg, l1 *ethclient.Cl
}
func (p *L2Proposer) CanPropose(t Testing) bool {
start, end, err := p.driver.GetBlockRange(t.Ctx())
_, shouldPropose, err := p.driver.FetchNextOutputInfo(t.Ctx())
require.NoError(t, err)
return start.Cmp(end) < 0
return shouldPropose
}
func (p *L2Proposer) ActMakeProposalTx(t Testing) {
start, end, err := p.driver.GetBlockRange(t.Ctx())
require.NoError(t, err)
if start.Cmp(end) == 0 {
t.InvalidAction("nothing to propose, block range starts and ends at %s", start.String())
output, shouldPropose, err := p.driver.FetchNextOutputInfo(t.Ctx())
if !shouldPropose {
return
}
nonce, err := p.l1.PendingNonceAt(t.Ctx(), p.address)
require.NoError(t, err)
tx, err := p.driver.CraftTx(t.Ctx(), start, end, new(big.Int).SetUint64(nonce))
tx, err := p.driver.CreateProposalTx(t.Ctx(), output)
require.NoError(t, err)
err = p.driver.SendTransaction(t.Ctx(), tx)
require.NoError(t, err)
p.lastTx = tx.Hash()
}
......
......@@ -342,7 +342,7 @@ func TestMigration(t *testing.T) {
batcher.Stop()
})
proposer, err := l2os.NewL2OutputSubmitter(l2os.Config{
proposer, err := l2os.NewL2OutputSubmitter(l2os.CLIConfig{
L1EthRpc: forkedL1URL,
RollupRpc: rollupNode.HTTPEndpoint(),
L2OOAddress: l2OS.Address.String(),
......@@ -356,7 +356,7 @@ func TestMigration(t *testing.T) {
Format: "text",
},
PrivateKey: hexPriv(secrets.Proposer),
}, "", lgr.New("module", "proposer"))
}, lgr.New("module", "proposer"))
require.NoError(t, err)
t.Cleanup(func() {
proposer.Stop()
......
......@@ -498,7 +498,7 @@ func (cfg SystemConfig) Start() (*System, error) {
}
// L2Output Submitter
sys.L2OutputSubmitter, err = l2os.NewL2OutputSubmitter(l2os.Config{
sys.L2OutputSubmitter, err = l2os.NewL2OutputSubmitter(l2os.CLIConfig{
L1EthRpc: sys.Nodes["l1"].WSEndpoint(),
RollupRpc: sys.RollupNodes["sequencer"].HTTPEndpoint(),
L2OOAddress: predeploys.DevL2OutputOracleAddr.String(),
......@@ -512,7 +512,7 @@ func (cfg SystemConfig) Start() (*System, error) {
Format: "text",
},
PrivateKey: hexPriv(cfg.Secrets.Proposer),
}, "", sys.cfg.Loggers["proposer"])
}, sys.cfg.Loggers["proposer"])
if err != nil {
return nil, fmt.Errorf("unable to setup l2 output submitter: %w", err)
}
......
package mock
import (
"context"
"math/big"
"sync"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)
// L1ClientConfig houses the internal methods that are executed by the mock
// L1Client. Any members left as nil will panic on execution.
type L1ClientConfig struct {
// BlockNumber returns the most recent block number.
BlockNumber func(context.Context) (uint64, error)
// HeaderByNumber returns a block header from the current canonical chain.
// If number is nil, the latest known header is returned.
HeaderByNumber func(context.Context, *big.Int) (*types.Header, error)
// NonceAt returns the account nonce of the given account. The block number
// can be nil, in which case the nonce is taken from the latest known block.
NonceAt func(context.Context, common.Address, *big.Int) (uint64, error)
// SendTransaction injects a signed transaction into the pending pool for
// execution.
//
// If the transaction was a contract creation use the TransactionReceipt
// method to get the contract address after the transaction has been mined.
SendTransaction func(context.Context, *types.Transaction) error
// SuggestGasTipCap retrieves the currently suggested gas tip cap after 1559
// to allow a timely execution of a transaction.
SuggestGasTipCap func(context.Context) (*big.Int, error)
// TransactionReceipt returns the receipt of a transaction by transaction
// hash. Note that the receipt is not available for pending transactions.
TransactionReceipt func(context.Context, common.Hash) (*types.Receipt, error)
}
// L1Client represents a mock L1Client.
type L1Client struct {
cfg L1ClientConfig
mu sync.RWMutex
}
// NewL1Client returns a new L1Client using the mocked methods in the
// L1ClientConfig.
func NewL1Client(cfg L1ClientConfig) *L1Client {
return &L1Client{
cfg: cfg,
}
}
// BlockNumber returns the most recent block number.
func (c *L1Client) BlockNumber(ctx context.Context) (uint64, error) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.cfg.BlockNumber(ctx)
}
// HeaderByNumber returns a block header from the current canonical chain. If
// number is nil, the latest known header is returned.
func (c *L1Client) HeaderByNumber(ctx context.Context, blockNumber *big.Int) (*types.Header, error) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.cfg.HeaderByNumber(ctx, blockNumber)
}
// NonceAt executes the mock NonceAt method.
func (c *L1Client) NonceAt(ctx context.Context, addr common.Address, blockNumber *big.Int) (uint64, error) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.cfg.NonceAt(ctx, addr, blockNumber)
}
// SendTransaction executes the mock SendTransaction method.
func (c *L1Client) SendTransaction(ctx context.Context, tx *types.Transaction) error {
c.mu.RLock()
defer c.mu.RUnlock()
return c.cfg.SendTransaction(ctx, tx)
}
// SuggestGasTipCap retrieves the currently suggested gas tip cap after 1559 to
// allow a timely execution of a transaction.
func (c *L1Client) SuggestGasTipCap(ctx context.Context) (*big.Int, error) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.cfg.SuggestGasTipCap(ctx)
}
// TransactionReceipt executes the mock TransactionReceipt method.
func (c *L1Client) TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.cfg.TransactionReceipt(ctx, txHash)
}
// SetBlockNumberFunc overwrites the mock BlockNumber method.
func (c *L1Client) SetBlockNumberFunc(
f func(context.Context) (uint64, error)) {
c.mu.Lock()
defer c.mu.Unlock()
c.cfg.BlockNumber = f
}
// SetHeaderByNumberFunc overwrites the mock HeaderByNumber method.
func (c *L1Client) SetHeaderByNumberFunc(
f func(ctx context.Context, blockNumber *big.Int) (*types.Header, error)) {
c.mu.Lock()
defer c.mu.Unlock()
c.cfg.HeaderByNumber = f
}
// SetNonceAtFunc overwrites the mock NonceAt method.
func (c *L1Client) SetNonceAtFunc(
f func(context.Context, common.Address, *big.Int) (uint64, error)) {
c.mu.Lock()
defer c.mu.Unlock()
c.cfg.NonceAt = f
}
// SetSendTransactionFunc overwrites the mock SendTransaction method.
func (c *L1Client) SetSendTransactionFunc(
f func(context.Context, *types.Transaction) error) {
c.mu.Lock()
defer c.mu.Unlock()
c.cfg.SendTransaction = f
}
// SetSuggestGasTipCapFunc overwrites themock SuggestGasTipCap method.
func (c *L1Client) SetSuggestGasTipCapFunc(
f func(context.Context) (*big.Int, error)) {
c.mu.Lock()
defer c.mu.Unlock()
c.cfg.SuggestGasTipCap = f
}
// SetTransactionReceiptFunc overwrites the mock TransactionReceipt method.
func (c *L1Client) SetTransactionReceiptFunc(
f func(context.Context, common.Hash) (*types.Receipt, error)) {
c.mu.Lock()
defer c.mu.Unlock()
c.cfg.TransactionReceipt = f
}
......@@ -3,16 +3,36 @@ package proposer
import (
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/urfave/cli"
"github.com/ethereum-optimism/optimism/op-node/sources"
"github.com/ethereum-optimism/optimism/op-proposer/flags"
"github.com/ethereum-optimism/optimism/op-proposer/txmgr"
oplog "github.com/ethereum-optimism/optimism/op-service/log"
opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics"
oppprof "github.com/ethereum-optimism/optimism/op-service/pprof"
oprpc "github.com/ethereum-optimism/optimism/op-service/rpc"
)
// Config contains the well typed fields that are used to initialize the output submitter.
// It is intended for programmatic use.
type Config struct {
L2OutputOracleAddr common.Address
PollInterval time.Duration
TxManagerConfig txmgr.Config
L1Client *ethclient.Client
RollupClient *sources.RollupClient
AllowNonFinalized bool
From common.Address
SignerFnFactory SignerFactory
}
// CLIConfig is a well typed config that is parsed from the CLI params.
// This also contains config options for auxiliary services.
// It is transformed into a `Config` before the L2 output submitter is started.
type CLIConfig struct {
/* Required Params */
// L1EthRpc is the HTTP provider URL for L1.
......@@ -68,7 +88,7 @@ type Config struct {
PprofConfig oppprof.CLIConfig
}
func (c Config) Check() error {
func (c CLIConfig) Check() error {
if err := c.RPCConfig.Check(); err != nil {
return err
}
......@@ -85,9 +105,9 @@ func (c Config) Check() error {
}
// NewConfig parses the Config from the provided flags or environment variables.
func NewConfig(ctx *cli.Context) Config {
return Config{
/* Required Flags */
func NewConfig(ctx *cli.Context) CLIConfig {
return CLIConfig{
// Required Flags
L1EthRpc: ctx.GlobalString(flags.L1EthRpcFlag.Name),
RollupRpc: ctx.GlobalString(flags.RollupRpcFlag.Name),
L2OOAddress: ctx.GlobalString(flags.L2OOAddressFlag.Name),
......@@ -98,10 +118,11 @@ func NewConfig(ctx *cli.Context) Config {
Mnemonic: ctx.GlobalString(flags.MnemonicFlag.Name),
L2OutputHDPath: ctx.GlobalString(flags.L2OutputHDPathFlag.Name),
PrivateKey: ctx.GlobalString(flags.PrivateKeyFlag.Name),
AllowNonFinalized: ctx.GlobalBool(flags.AllowNonFinalizedFlag.Name),
RPCConfig: oprpc.ReadCLIConfig(ctx),
LogConfig: oplog.ReadCLIConfig(ctx),
MetricsConfig: opmetrics.ReadCLIConfig(ctx),
PprofConfig: oppprof.ReadCLIConfig(ctx),
// Optional Flags
AllowNonFinalized: ctx.GlobalBool(flags.AllowNonFinalizedFlag.Name),
RPCConfig: oprpc.ReadCLIConfig(ctx),
LogConfig: oplog.ReadCLIConfig(ctx),
MetricsConfig: opmetrics.ReadCLIConfig(ctx),
PprofConfig: oppprof.ReadCLIConfig(ctx),
}
}
package proposer
import (
"context"
"fmt"
"math/big"
"strings"
"github.com/ethereum-optimism/optimism/op-node/sources"
"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/core/types"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
"github.com/ethereum-optimism/optimism/op-node/eth"
)
var bigOne = big.NewInt(1)
var supportedL2OutputVersion = eth.Bytes32{}
type SignerFn func(context.Context, common.Address, *types.Transaction) (*types.Transaction, error)
type DriverConfig struct {
Log log.Logger
Name string
// L1Client is used to submit transactions to
L1Client *ethclient.Client
// RollupClient is used to retrieve output roots from
RollupClient *sources.RollupClient
// AllowNonFinalized enables the proposal of safe, but non-finalized L2 blocks.
// The L1 block-hash embedded in the proposal TX is checked and should ensure the proposal
// is never valid on an alternative L1 chain that would produce different L2 data.
// This option is not necessary when higher proposal latency is acceptable and L1 is healthy.
AllowNonFinalized bool
// L2OOAddr is the L1 contract address of the L2 Output Oracle.
L2OOAddr common.Address
// From is the address to send transactions from
From common.Address
// SignerFn is the function used to sign transactions
SignerFn SignerFn
}
type Driver struct {
cfg DriverConfig
l2ooContract *bindings.L2OutputOracle
rawL2ooContract *bind.BoundContract
walletAddr common.Address
l log.Logger
}
func NewDriver(cfg DriverConfig) (*Driver, error) {
l2ooContract, err := bindings.NewL2OutputOracle(cfg.L2OOAddr, cfg.L1Client)
if err != nil {
return nil, err
}
parsed, err := abi.JSON(strings.NewReader(
bindings.L2OutputOracleMetaData.ABI,
))
if err != nil {
return nil, err
}
rawL2ooContract := bind.NewBoundContract(
cfg.L2OOAddr, parsed, cfg.L1Client, cfg.L1Client, cfg.L1Client,
)
cfg.Log.Info("Configured driver", "wallet", cfg.From, "l2-output-contract", cfg.L2OOAddr)
return &Driver{
cfg: cfg,
l2ooContract: l2ooContract,
rawL2ooContract: rawL2ooContract,
walletAddr: cfg.From,
l: cfg.Log,
}, nil
}
// Name is an identifier used to prefix logs for a particular service.
func (d *Driver) Name() string {
return d.cfg.Name
}
// WalletAddr is the wallet address used to pay for transaction fees.
func (d *Driver) WalletAddr() common.Address {
return d.walletAddr
}
// GetBlockRange returns the start and end L2 block heights that need to be
// processed. Note that the end value is *exclusive*, therefore if the returned
// values are identical nothing needs to be processed.
func (d *Driver) GetBlockRange(ctx context.Context) (*big.Int, *big.Int, error) {
name := d.cfg.Name
callOpts := &bind.CallOpts{
Pending: false,
Context: ctx,
}
// Determine the last committed L2 Block Number
start, err := d.l2ooContract.LatestBlockNumber(callOpts)
if err != nil {
d.l.Error(name+" unable to get latest block number", "err", err)
return nil, nil, err
}
start.Add(start, bigOne)
// Next determine the L2 block that we need to commit
nextBlockNumber, err := d.l2ooContract.NextBlockNumber(callOpts)
if err != nil {
d.l.Error(name+" unable to get next block number", "err", err)
return nil, nil, err
}
status, err := d.cfg.RollupClient.SyncStatus(ctx)
if err != nil {
d.l.Error(name+" unable to get sync status", "err", err)
return nil, nil, err
}
var currentBlockNumber *big.Int
if d.cfg.AllowNonFinalized {
currentBlockNumber = new(big.Int).SetUint64(status.SafeL2.Number)
} else {
currentBlockNumber = new(big.Int).SetUint64(status.FinalizedL2.Number)
}
// If we do not have the new L2 Block number
if currentBlockNumber.Cmp(nextBlockNumber) < 0 {
d.l.Info(name+" submission interval has not elapsed",
"currentBlockNumber", currentBlockNumber, "nextBlockNumber", nextBlockNumber)
return start, start, nil
}
d.l.Info(name+" submission interval has elapsed",
"currentBlockNumber", currentBlockNumber, "nextBlockNumber", nextBlockNumber)
// Otherwise the submission interval has elapsed. Transform the next
// expected timestamp into its L2 block number, and add one since end is
// exclusive.
end := new(big.Int).Add(nextBlockNumber, bigOne)
return start, end, nil
}
// CraftTx transforms the L2 blocks between start and end into a transaction
// using the given nonce.
//
// NOTE: This method SHOULD NOT publish the resulting transaction.
func (d *Driver) CraftTx(ctx context.Context, start, end, nonce *big.Int) (*types.Transaction, error) {
name := d.cfg.Name
d.l.Info(name+" crafting checkpoint tx", "start", start, "end", end, "nonce", nonce)
// Fetch the final block in the range, as this is the only L2 output we need to submit.
nextCheckpointBlock := new(big.Int).Sub(end, bigOne).Uint64()
output, err := d.cfg.RollupClient.OutputAtBlock(ctx, nextCheckpointBlock)
if err != nil {
return nil, fmt.Errorf("failed to fetch output at block %d: %w", nextCheckpointBlock, err)
}
if output.Version != supportedL2OutputVersion {
return nil, fmt.Errorf("unsupported l2 output version: %s", output.Version)
}
if output.BlockRef.Number != nextCheckpointBlock { // sanity check, e.g. in case of bad RPC caching
return nil, fmt.Errorf("invalid blockNumber: next blockNumber is %v, blockNumber of block is %v", nextCheckpointBlock, output.BlockRef.Number)
}
// Always propose if it's part of the Finalized L2 chain. Or if allowed, if it's part of the safe L2 chain.
if !(output.BlockRef.Number <= output.Status.FinalizedL2.Number || (d.cfg.AllowNonFinalized && output.BlockRef.Number <= output.Status.SafeL2.Number)) {
d.l.Debug("not proposing yet, L2 block is not ready for proposal",
"l2_proposal", output.BlockRef,
"l2_safe", output.Status.SafeL2,
"l2_finalized", output.Status.FinalizedL2,
"allow_non_finalized", d.cfg.AllowNonFinalized)
return nil, fmt.Errorf("output for L2 block %s is still unsafe", output.BlockRef)
}
opts := &bind.TransactOpts{
From: d.cfg.From,
Signer: func(addr common.Address, tx *types.Transaction) (*types.Transaction, error) {
return d.cfg.SignerFn(ctx, addr, tx)
},
Context: ctx,
Nonce: nonce,
NoSend: true,
}
// Note: the CurrentL1 is up to (and incl.) what the safe chain and finalized chain have been derived from,
// and should be a quite recent L1 block (depends on L1 conf distance applied to rollup node).
tx, err := d.l2ooContract.ProposeL2Output(
opts,
output.OutputRoot,
new(big.Int).SetUint64(output.BlockRef.Number),
output.Status.CurrentL1.Hash,
new(big.Int).SetUint64(output.Status.CurrentL1.Number))
if err != nil {
return nil, err
}
numElements := new(big.Int).Sub(start, end).Uint64()
d.l.Info(name+" proposal constructed",
"start", start, "end", end,
"nonce", nonce, "blocks_committed", numElements,
"tx_hash", tx.Hash(),
"output_version", output.Version,
"output_root", output.OutputRoot,
"output_block", output.BlockRef,
"output_withdrawals_root", output.WithdrawalStorageRoot,
"output_state_root", output.StateRoot,
"current_l1", output.Status.CurrentL1,
"safe_l2", output.Status.SafeL2,
"finalized_l2", output.Status.FinalizedL2,
)
return tx, nil
}
// UpdateGasPrice signs an otherwise identical txn to the one provided but with
// updated gas prices sampled from the existing network conditions.
//
// NOTE: This method SHOULD NOT publish the resulting transaction.
func (d *Driver) UpdateGasPrice(ctx context.Context, tx *types.Transaction) (*types.Transaction, error) {
opts := &bind.TransactOpts{
From: d.cfg.From,
Signer: func(addr common.Address, tx *types.Transaction) (*types.Transaction, error) {
return d.cfg.SignerFn(ctx, addr, tx)
},
Context: ctx,
Nonce: new(big.Int).SetUint64(tx.Nonce()),
NoSend: true,
}
return d.rawL2ooContract.RawTransact(opts, tx.Data())
}
// SendTransaction injects a signed transaction into the pending pool for execution.
func (d *Driver) SendTransaction(ctx context.Context, tx *types.Transaction) error {
d.l.Info(d.cfg.Name+" sending transaction", "tx", tx.Hash())
return d.cfg.L1Client.SendTransaction(ctx, tx)
}
package proposer
import (
"context"
"math/big"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)
// L1Client is an abstraction over an L1 Ethereum client functionality required
// by the batch submitter.
type L1Client interface {
// HeaderByNumber returns a block header from the current canonical chain.
// If number is nil, the latest known header is returned.
HeaderByNumber(context.Context, *big.Int) (*types.Header, error)
// NonceAt returns the account nonce of the given account. The block number
// can be nil, in which case the nonce is taken from the latest known block.
NonceAt(context.Context, common.Address, *big.Int) (uint64, error)
// SendTransaction injects a signed transaction into the pending pool for
// execution.
//
// If the transaction was a contract creation use the TransactionReceipt
// method to get the contract address after the transaction has been mined.
SendTransaction(context.Context, *types.Transaction) error
// SuggestGasTipCap retrieves the currently suggested gas tip cap after 1559
// to allow a timely execution of a transaction.
SuggestGasTipCap(context.Context) (*big.Int, error)
// TransactionReceipt returns the receipt of a transaction by transaction
// hash. Note that the receipt is not available for pending transactions.
TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error)
}
......@@ -10,20 +10,23 @@ import (
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
hdwallet "github.com/ethereum-optimism/go-ethereum-hdwallet"
"github.com/ethereum/go-ethereum/accounts"
"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/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/rpc"
"github.com/urfave/cli"
"github.com/ethereum-optimism/optimism/op-node/client"
hdwallet "github.com/ethereum-optimism/go-ethereum-hdwallet"
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
"github.com/ethereum-optimism/optimism/op-node/eth"
"github.com/ethereum-optimism/optimism/op-node/sources"
"github.com/ethereum-optimism/optimism/op-proposer/txmgr"
opcrypto "github.com/ethereum-optimism/optimism/op-service/crypto"
......@@ -39,6 +42,12 @@ const (
defaultDialTimeout = 5 * time.Second
)
var supportedL2OutputVersion = eth.Bytes32{}
type SignerFn func(context.Context, common.Address, *types.Transaction) (*types.Transaction, error)
type SignerFactory func(chainID *big.Int) SignerFn
// Main is the entrypoint into the L2 Output Submitter. 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.
......@@ -53,22 +62,22 @@ func Main(version string) func(ctx *cli.Context) error {
l := oplog.NewLogger(cfg.LogConfig)
l.Info("Initializing L2 Output Submitter")
l2OutputSubmitter, err := NewL2OutputSubmitter(cfg, version, l)
l2OutputSubmitter, err := NewL2OutputSubmitter(cfg, l)
if err != nil {
l.Error("Unable to create L2 Output Submitter", "error", err)
return err
}
l.Info("Starting L2 Output Submitter")
ctx, cancel := context.WithCancel(context.Background())
if err := l2OutputSubmitter.Start(); err != nil {
cancel()
l.Error("Unable to start L2 Output Submitter", "error", err)
return err
}
defer l2OutputSubmitter.Stop()
ctx, cancel := context.WithCancel(context.Background())
l.Info("L2 Output Submitter started")
pprofConfig := cfg.PprofConfig
if pprofConfig.Enabled {
......@@ -89,16 +98,12 @@ func Main(version string) func(ctx *cli.Context) error {
l.Error("error starting metrics server", err)
}
}()
addr := l2OutputSubmitter.l2OutputService.cfg.Driver.WalletAddr()
opmetrics.LaunchBalanceMetrics(ctx, l, registry, "", l2OutputSubmitter.l2OutputService.cfg.L1Client, addr)
addr := l2OutputSubmitter.from
opmetrics.LaunchBalanceMetrics(ctx, l, registry, "", l2OutputSubmitter.l1Client, addr)
}
rpcCfg := cfg.RPCConfig
server := oprpc.NewServer(
rpcCfg.ListenAddr,
rpcCfg.ListenPort,
version,
)
server := oprpc.NewServer(rpcCfg.ListenAddr, rpcCfg.ListenPort, version)
if err := server.Start(); err != nil {
cancel()
return fmt.Errorf("error starting RPC server: %w", err)
......@@ -118,20 +123,40 @@ func Main(version string) func(ctx *cli.Context) error {
}
}
// L2OutputSubmitter encapsulates a service responsible for submitting
// L2Outputs to the L2OutputOracle contract.
// L2OutputSubmitter is responsible for proposing outputs
type L2OutputSubmitter struct {
ctx context.Context
l2OutputService *Service
txMgr txmgr.TxManager
wg sync.WaitGroup
done chan struct{}
log log.Logger
ctx context.Context
cancel context.CancelFunc
// L1Client is used to submit transactions to
l1Client *ethclient.Client
// RollupClient is used to retrieve output roots from
rollupClient *sources.RollupClient
l2ooContract *bindings.L2OutputOracle
rawL2ooContract *bind.BoundContract
// AllowNonFinalized enables the proposal of safe, but non-finalized L2 blocks.
// The L1 block-hash embedded in the proposal TX is checked and should ensure the proposal
// is never valid on an alternative L1 chain that would produce different L2 data.
// This option is not necessary when higher proposal latency is acceptable and L1 is healthy.
allowNonFinalized bool
// From is the address to send transactions from
from common.Address
// SignerFn is the function used to sign transactions
signerFn SignerFn
// How frequently to poll L2 for new finalized outputs
pollInterval time.Duration
}
// NewL2OutputSubmitter initializes the L2OutputSubmitter, gathering any resources
// that will be needed during operation.
func NewL2OutputSubmitter(
cfg Config,
gitVersion string,
l log.Logger,
) (*L2OutputSubmitter, error) {
func NewL2OutputSubmitter(cfg CLIConfig, l log.Logger) (*L2OutputSubmitter, error) {
var l2OutputPrivKey *ecdsa.PrivateKey
var err error
......@@ -161,26 +186,6 @@ func NewL2OutputSubmitter(
}
}
signer := func(chainID *big.Int) SignerFn {
s := opcrypto.PrivateKeySignerFn(l2OutputPrivKey, chainID)
return func(_ context.Context, addr common.Address, tx *types.Transaction) (*types.Transaction, error) {
return s(addr, tx)
}
}
return NewL2OutputSubmitterWithSigner(cfg, crypto.PubkeyToAddress(l2OutputPrivKey.PublicKey), signer, gitVersion, l)
}
type SignerFactory func(chainID *big.Int) SignerFn
func NewL2OutputSubmitterWithSigner(
cfg Config,
from common.Address,
signer SignerFactory,
gitVersion string,
l log.Logger,
) (*L2OutputSubmitter, error) {
ctx := context.Background()
l2ooAddress, err := parseAddress(cfg.L2OOAddress)
if err != nil {
return nil, err
......@@ -188,6 +193,7 @@ func NewL2OutputSubmitterWithSigner(
// Connect to L1 and L2 providers. Perform these last since they are the
// most expensive.
ctx := context.Background()
l1Client, err := dialEthClientWithTimeout(ctx, cfg.L1EthRpc)
if err != nil {
return nil, err
......@@ -198,12 +204,14 @@ func NewL2OutputSubmitterWithSigner(
return nil, err
}
chainID, err := l1Client.ChainID(ctx)
if err != nil {
return nil, err
signer := func(chainID *big.Int) SignerFn {
s := opcrypto.PrivateKeySignerFn(l2OutputPrivKey, chainID)
return func(_ context.Context, addr common.Address, tx *types.Transaction) (*types.Transaction, error) {
return s(addr, tx)
}
}
txManagerConfig := txmgr.Config{
txMgrConfg := txmgr.Config{
Log: l,
Name: "L2Output Submitter",
ResubmissionTimeout: cfg.ResubmissionTimeout,
......@@ -212,75 +220,253 @@ func NewL2OutputSubmitterWithSigner(
SafeAbortNonceTooLowCount: cfg.SafeAbortNonceTooLowCount,
}
l2OutputDriver, err := NewDriver(DriverConfig{
Log: l,
Name: "L2Output Submitter",
L1Client: l1Client,
RollupClient: rollupClient,
AllowNonFinalized: cfg.AllowNonFinalized,
L2OOAddr: l2ooAddress,
From: from,
SignerFn: signer(chainID),
})
proposerCfg := Config{
L2OutputOracleAddr: l2ooAddress,
PollInterval: cfg.PollInterval,
TxManagerConfig: txMgrConfg,
L1Client: l1Client,
RollupClient: rollupClient,
AllowNonFinalized: cfg.AllowNonFinalized,
From: crypto.PubkeyToAddress(l2OutputPrivKey.PublicKey),
SignerFnFactory: signer,
}
return NewL2OutputSubmitterWithSigner(proposerCfg, l)
}
// NewL2OutputSubmitterWithSigner creates a new L2 Output Submitter
func NewL2OutputSubmitterWithSigner(cfg Config, l log.Logger) (*L2OutputSubmitter, error) {
ctx, cancel := context.WithCancel(context.Background())
cCtx, cCancel := context.WithTimeout(ctx, defaultDialTimeout)
chainID, err := cfg.L1Client.ChainID(cCtx)
cCancel()
if err != nil {
cancel()
return nil, err
}
l2ooContract, err := bindings.NewL2OutputOracle(cfg.L2OutputOracleAddr, cfg.L1Client)
if err != nil {
cancel()
return nil, err
}
l2OutputService := NewService(ServiceConfig{
Log: l,
Context: ctx,
Driver: l2OutputDriver,
PollInterval: cfg.PollInterval,
L1Client: l1Client,
TxManagerConfig: txManagerConfig,
})
parsed, err := abi.JSON(strings.NewReader(bindings.L2OutputOracleMetaData.ABI))
if err != nil {
cancel()
return nil, err
}
rawL2ooContract := bind.NewBoundContract(cfg.L2OutputOracleAddr, parsed, cfg.L1Client, cfg.L1Client, cfg.L1Client)
return &L2OutputSubmitter{
ctx: ctx,
l2OutputService: l2OutputService,
txMgr: txmgr.NewSimpleTxManager("proposer", cfg.TxManagerConfig, cfg.L1Client),
done: make(chan struct{}),
log: l,
ctx: ctx,
cancel: cancel,
l1Client: cfg.L1Client,
rollupClient: cfg.RollupClient,
l2ooContract: l2ooContract,
rawL2ooContract: rawL2ooContract,
allowNonFinalized: cfg.AllowNonFinalized,
from: cfg.From,
signerFn: cfg.SignerFnFactory(chainID),
pollInterval: cfg.PollInterval,
}, nil
}
func (l *L2OutputSubmitter) Start() error {
return l.l2OutputService.Start()
l.wg.Add(1)
go l.loop()
return nil
}
func (l *L2OutputSubmitter) Stop() {
_ = l.l2OutputService.Stop()
l.cancel()
close(l.done)
l.wg.Wait()
}
// dialEthClientWithTimeout attempts to dial the L1 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) {
// UpdateGasPrice signs an otherwise identical txn to the one provided but with
// updated gas prices sampled from the existing network conditions.
//
// NOTE: This method SHOULD NOT publish the resulting transaction.
func (l *L2OutputSubmitter) UpdateGasPrice(ctx context.Context, tx *types.Transaction) (*types.Transaction, error) {
opts := &bind.TransactOpts{
From: l.from,
Signer: func(addr common.Address, tx *types.Transaction) (*types.Transaction, error) {
return l.signerFn(ctx, addr, tx)
},
Context: ctx,
Nonce: new(big.Int).SetUint64(tx.Nonce()),
NoSend: true,
}
return l.rawL2ooContract.RawTransact(opts, tx.Data())
}
ctxt, cancel := context.WithTimeout(ctx, defaultDialTimeout)
defer cancel()
// SendTransaction injects a signed transaction into the pending pool for execution.
func (l *L2OutputSubmitter) SendTransaction(ctx context.Context, tx *types.Transaction) error {
l.log.Info("proposer sending transaction", "tx", tx.Hash())
return l.l1Client.SendTransaction(ctx, tx)
}
// FetchNextOutputInfo gets the block number of the next proposal.
// It returns: the next block number, if the proposal should be made, error
func (l *L2OutputSubmitter) FetchNextOutputInfo(ctx context.Context) (*eth.OutputResponse, bool, error) {
callOpts := &bind.CallOpts{
From: l.from,
Context: ctx,
}
nextCheckpointBlock, err := l.l2ooContract.NextBlockNumber(callOpts)
if err != nil {
l.log.Error("proposer unable to get next block number", "err", err)
return nil, false, err
}
// Fetch the current L2 heads
status, err := l.rollupClient.SyncStatus(ctx)
if err != nil {
l.log.Error("proposer unable to get sync status", "err", err)
return nil, false, err
}
// Use either the finalized or safe head depending on the config. Finalized head is default & safer.
var currentBlockNumber *big.Int
if l.allowNonFinalized {
currentBlockNumber = new(big.Int).SetUint64(status.SafeL2.Number)
} else {
currentBlockNumber = new(big.Int).SetUint64(status.FinalizedL2.Number)
}
// Ensure that we do not submit a block in the future
if currentBlockNumber.Cmp(nextCheckpointBlock) < 0 {
l.log.Info("proposer submission interval has not elapsed", "currentBlockNumber", currentBlockNumber, "nextBlockNumber", nextCheckpointBlock)
return nil, false, nil
}
output, err := l.rollupClient.OutputAtBlock(ctx, nextCheckpointBlock.Uint64())
if err != nil {
l.log.Error("failed to fetch output at block %d: %w", nextCheckpointBlock, err)
return nil, false, err
}
if output.Version != supportedL2OutputVersion {
l.log.Error("unsupported l2 output version: %s", output.Version)
return nil, false, errors.New("unsupported l2 output version")
}
if output.BlockRef.Number != nextCheckpointBlock.Uint64() { // sanity check, e.g. in case of bad RPC caching
l.log.Error("invalid blockNumber: next blockNumber is %v, blockNumber of block is %v", nextCheckpointBlock, output.BlockRef.Number)
return nil, false, errors.New("invalid blockNumber")
}
return ethclient.DialContext(ctxt, url)
// Always propose if it's part of the Finalized L2 chain. Or if allowed, if it's part of the safe L2 chain.
if !(output.BlockRef.Number <= output.Status.FinalizedL2.Number || (l.allowNonFinalized && output.BlockRef.Number <= output.Status.SafeL2.Number)) {
l.log.Debug("not proposing yet, L2 block is not ready for proposal",
"l2_proposal", output.BlockRef,
"l2_safe", output.Status.SafeL2,
"l2_finalized", output.Status.FinalizedL2,
"allow_non_finalized", l.allowNonFinalized)
return nil, false, nil
}
return output, true, nil
}
// dialRollupClientWithTimeout attempts to dial the RPC provider using the provided
// URL. If the dial doesn't complete within defaultDialTimeout seconds, this
// method will return an error.
func dialRollupClientWithTimeout(ctx context.Context, url string) (*sources.RollupClient, error) {
ctxt, cancel := context.WithTimeout(ctx, defaultDialTimeout)
defer cancel()
// CreateProposalTx transforms an output response into a signed output transaction.
// It does not send the transaction to the transaction pool.
func (l *L2OutputSubmitter) CreateProposalTx(ctx context.Context, output *eth.OutputResponse) (*types.Transaction, error) {
nonce, err := l.l1Client.NonceAt(ctx, l.from, nil)
if err != nil {
l.log.Error("Failed to get nonce", "err", err, "from", l.from)
return nil, err
}
opts := &bind.TransactOpts{
From: l.from,
Signer: func(addr common.Address, tx *types.Transaction) (*types.Transaction, error) {
return l.signerFn(ctx, addr, tx)
},
Context: ctx,
Nonce: new(big.Int).SetUint64(nonce),
NoSend: true,
}
rpcCl, err := rpc.DialContext(ctxt, url)
tx, err := l.l2ooContract.ProposeL2Output(
opts,
output.OutputRoot,
new(big.Int).SetUint64(output.BlockRef.Number),
output.Status.CurrentL1.Hash,
new(big.Int).SetUint64(output.Status.CurrentL1.Number))
if err != nil {
l.log.Error("failed to create the ProposeL2Output transaction", "err", err)
return nil, err
}
return tx, nil
}
// SendTransactionExt sends a transaction through the transaction manager which handles automatic
// price bumping.
// It also hardcodes a timeout of 100s.
func (l *L2OutputSubmitter) SendTransactionExt(ctx context.Context, tx *types.Transaction) error {
// Construct the closure that will update the txn with the current gas prices.
nonce := tx.Nonce()
updateGasPrice := func(ctx context.Context) (*types.Transaction, error) {
l.log.Info("proposer updating batch tx gas price", "nonce", nonce)
return l.UpdateGasPrice(ctx, tx)
}
// Wait until one of our submitted transactions confirms. If no
// receipt is received it's likely our gas price was too low.
cCtx, cancel := context.WithTimeout(ctx, 100*time.Second)
defer cancel()
receipt, err := l.txMgr.Send(cCtx, updateGasPrice, l.SendTransaction)
if err != nil {
l.log.Error("proposer unable to publish tx", "err", err)
return err
}
return sources.NewRollupClient(client.NewBaseRPCClient(rpcCl)), nil
// The transaction was successfully submitted
l.log.Info("proposer tx successfully published", "tx_hash", receipt.TxHash)
return nil
}
// parseAddress parses an ETH address from a hex string. This method will fail if
// the address is not a valid hexadecimal address.
func parseAddress(address string) (common.Address, error) {
if common.IsHexAddress(address) {
return common.HexToAddress(address), nil
// loop is responsible for creating & submitting the next outputs
func (l *L2OutputSubmitter) loop() {
defer l.wg.Done()
ctx := l.ctx
ticker := time.NewTicker(l.pollInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
cCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
output, shouldPropose, err := l.FetchNextOutputInfo(cCtx)
if err != nil {
l.log.Error("Failed to fetch next output", "err", err)
cancel()
break
}
if !shouldPropose {
cancel()
break
}
tx, err := l.CreateProposalTx(cCtx, output)
if err != nil {
l.log.Error("Failed to create proposal transaction", "err", err)
cancel()
break
}
if err := l.SendTransactionExt(cCtx, tx); err != nil {
l.log.Error("Failed to send proposal transaction", "err", err)
cancel()
break
}
cancel()
case <-l.done:
return
}
}
return common.Address{}, fmt.Errorf("invalid address: %v", address)
}
package proposer
import (
"context"
"math/big"
"sync"
"time"
"github.com/ethereum-optimism/optimism/op-proposer/txmgr"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/log"
)
// DriverInterface is an interface for creating and submitting transactions for a
// specific contract.
type DriverInterface interface {
// Name is an identifier used to prefix logs for a particular service.
Name() string
// WalletAddr is the wallet address used to pay for transaction fees.
WalletAddr() common.Address
// GetBlockRange returns the start and end L2 block heights that need to be
// processed. Note that the end value is *exclusive*, therefore if the
// returned values are identical nothing needs to be processed.
GetBlockRange(ctx context.Context) (*big.Int, *big.Int, error)
// CraftTx transforms the L2 blocks between start and end into a transaction
// using the given nonce.
//
// NOTE: This method SHOULD NOT publish the resulting transaction.
CraftTx(
ctx context.Context,
start, end, nonce *big.Int,
) (*types.Transaction, error)
// UpdateGasPrice signs an otherwise identical txn to the one provided but
// with updated gas prices sampled from the existing network conditions.
//
// NOTE: Thie method SHOULD NOT publish the resulting transaction.
UpdateGasPrice(
ctx context.Context,
tx *types.Transaction,
) (*types.Transaction, error)
// SendTransaction injects a signed transaction into the pending pool for
// execution.
SendTransaction(ctx context.Context, tx *types.Transaction) error
}
type ServiceConfig struct {
Log log.Logger
Context context.Context
Driver DriverInterface
PollInterval time.Duration
L1Client *ethclient.Client
TxManagerConfig txmgr.Config
}
type Service struct {
cfg ServiceConfig
txMgr txmgr.TxManager
l log.Logger
ctx context.Context
cancel func()
wg sync.WaitGroup
}
func NewService(cfg ServiceConfig) *Service {
txMgr := txmgr.NewSimpleTxManager(
cfg.Driver.Name(), cfg.TxManagerConfig, cfg.L1Client,
)
ctx, cancel := context.WithCancel(cfg.Context)
return &Service{
cfg: cfg,
txMgr: txMgr,
l: cfg.Log,
ctx: ctx,
cancel: cancel,
}
}
func (s *Service) Start() error {
s.wg.Add(1)
go s.eventLoop()
return nil
}
func (s *Service) Stop() error {
s.cancel()
s.wg.Wait()
return nil
}
func (s *Service) eventLoop() {
defer s.wg.Done()
name := s.cfg.Driver.Name()
ticker := time.NewTicker(s.cfg.PollInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// Determine the range of L2 blocks that the submitter has not
// processed, and needs to take action on.
s.l.Info(name + " fetching current block range")
start, end, err := s.cfg.Driver.GetBlockRange(s.ctx)
if err != nil {
s.l.Error(name+" unable to get block range", "err", err)
continue
}
// No new updates.
if start.Cmp(end) == 0 {
s.l.Info(name+" no updates", "start", start, "end", end)
continue
}
s.l.Info(name+" block range", "start", start, "end", end)
// Query for the submitter's current nonce.
nonce64, err := s.cfg.L1Client.NonceAt(
s.ctx, s.cfg.Driver.WalletAddr(), nil,
)
if err != nil {
s.l.Error(name+" unable to get current nonce",
"err", err)
continue
}
nonce := new(big.Int).SetUint64(nonce64)
tx, err := s.cfg.Driver.CraftTx(
s.ctx, start, end, nonce,
)
if err != nil {
s.l.Error(name+" unable to craft tx",
"err", err)
continue
}
// Construct the a closure that will update the txn with the current
// gas prices.
updateGasPrice := func(ctx context.Context) (*types.Transaction, error) {
s.l.Info(name+" updating batch tx gas price", "start", start,
"end", end, "nonce", nonce)
return s.cfg.Driver.UpdateGasPrice(ctx, tx)
}
// Wait until one of our submitted transactions confirms. If no
// receipt is received it's likely our gas price was too low.
receipt, err := s.txMgr.Send(
s.ctx, updateGasPrice, s.cfg.Driver.SendTransaction,
)
if err != nil {
s.l.Error(name+" unable to publish tx", "err", err)
continue
}
// The transaction was successfully submitted.
s.l.Info(name+" tx successfully published",
"tx_hash", receipt.TxHash)
case <-s.ctx.Done():
s.l.Info(name + " service shutting down")
return
}
}
}
package proposer
import (
"context"
"fmt"
"github.com/ethereum-optimism/optimism/op-node/client"
"github.com/ethereum-optimism/optimism/op-node/sources"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/rpc"
)
// dialEthClientWithTimeout attempts to dial the L1 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)
}
// dialRollupClientWithTimeout attempts to dial the RPC provider using the provided
// URL. If the dial doesn't complete within defaultDialTimeout seconds, this
// method will return an error.
func dialRollupClientWithTimeout(ctx context.Context, url string) (*sources.RollupClient, error) {
ctxt, cancel := context.WithTimeout(ctx, defaultDialTimeout)
defer cancel()
rpcCl, err := rpc.DialContext(ctxt, url)
if err != nil {
return nil, err
}
return sources.NewRollupClient(client.NewBaseRPCClient(rpcCl)), nil
}
// parseAddress parses an ETH address from a hex string. This method will fail if
// the address is not a valid hexadecimal 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)
}
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