Commit 071ee384 authored by mergify[bot]'s avatar mergify[bot] Committed by GitHub

Merge branch 'develop' into deps/ctb-remove-core-utils

parents 0e969ffb 9dd47648
package challenger
import (
"context"
_ "net/http/pprof"
"sync"
"time"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
ethclient "github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-challenger/config"
"github.com/ethereum-optimism/optimism/op-challenger/metrics"
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
"github.com/ethereum-optimism/optimism/op-node/eth"
opclient "github.com/ethereum-optimism/optimism/op-service/client"
"github.com/ethereum-optimism/optimism/op-service/txmgr"
)
type OutputAPI interface {
OutputAtBlock(ctx context.Context, blockNum uint64) (*eth.OutputResponse, error)
}
// Challenger contests invalid L2OutputOracle outputs
type Challenger struct {
txMgr txmgr.TxManager
wg sync.WaitGroup
done chan struct{}
log log.Logger
metr metrics.Metricer
ctx context.Context
cancel context.CancelFunc
l1Client *ethclient.Client
rollupClient OutputAPI
// l2 Output Oracle contract
l2ooContract *bindings.L2OutputOracleCaller
l2ooContractAddr common.Address
l2ooABI *abi.ABI
// dispute game factory contract
dgfContract *bindings.DisputeGameFactoryCaller
dgfContractAddr common.Address
dgfABI *abi.ABI
networkTimeout time.Duration
}
// From returns the address of the account used to send transactions.
func (c *Challenger) From() common.Address {
return c.txMgr.From()
}
// Client returns the client for the settlement layer.
func (c *Challenger) Client() *ethclient.Client {
return c.l1Client
}
func (c *Challenger) NewOracleSubscription() (*Subscription, error) {
query, err := BuildOutputLogFilter(c.l2ooABI)
if err != nil {
return nil, err
}
return NewSubscription(query, c.Client(), c.log), nil
}
// NewFactorySubscription creates a new [Subscription] listening to the DisputeGameFactory contract.
func (c *Challenger) NewFactorySubscription() (*Subscription, error) {
query, err := BuildDisputeGameLogFilter(c.dgfABI)
if err != nil {
return nil, err
}
return NewSubscription(query, c.Client(), c.log), nil
}
// NewChallenger creates a new Challenger
func NewChallenger(cfg config.Config, l log.Logger, m metrics.Metricer) (*Challenger, error) {
ctx, cancel := context.WithCancel(context.Background())
txManager, err := txmgr.NewSimpleTxManager("challenger", l, m, *cfg.TxMgrConfig)
if err != nil {
cancel()
return nil, err
}
// Connect to L1 and L2 providers. Perform these last since they are the most expensive.
l1Client, err := opclient.DialEthClientWithTimeout(ctx, cfg.L1EthRpc, opclient.DefaultDialTimeout)
if err != nil {
cancel()
return nil, err
}
rollupClient, err := opclient.DialRollupClientWithTimeout(ctx, cfg.RollupRpc, opclient.DefaultDialTimeout)
if err != nil {
cancel()
return nil, err
}
l2ooContract, err := bindings.NewL2OutputOracleCaller(cfg.L2OOAddress, l1Client)
if err != nil {
cancel()
return nil, err
}
dgfContract, err := bindings.NewDisputeGameFactoryCaller(cfg.DGFAddress, l1Client)
if err != nil {
cancel()
return nil, err
}
cCtx, cCancel := context.WithTimeout(ctx, cfg.NetworkTimeout)
defer cCancel()
version, err := l2ooContract.Version(&bind.CallOpts{Context: cCtx})
if err != nil {
cancel()
return nil, err
}
l.Info("Connected to L2OutputOracle", "address", cfg.L2OOAddress, "version", version)
parsedL2oo, err := bindings.L2OutputOracleMetaData.GetAbi()
if err != nil {
cancel()
return nil, err
}
parsedDgf, err := bindings.DisputeGameFactoryMetaData.GetAbi()
if err != nil {
cancel()
return nil, err
}
return &Challenger{
txMgr: txManager,
done: make(chan struct{}),
log: l,
metr: m,
ctx: ctx,
cancel: cancel,
rollupClient: rollupClient,
l1Client: l1Client,
l2ooContract: l2ooContract,
l2ooContractAddr: cfg.L2OOAddress,
l2ooABI: parsedL2oo,
dgfContract: dgfContract,
dgfContractAddr: cfg.DGFAddress,
dgfABI: parsedDgf,
networkTimeout: cfg.NetworkTimeout,
}, nil
}
// Start runs the challenger in a goroutine.
func (c *Challenger) Start() error {
c.log.Error("challenger not implemented.")
return nil
}
// Stop closes the challenger and waits for spawned goroutines to exit.
func (c *Challenger) Stop() {
c.cancel()
close(c.done)
c.wg.Wait()
}
package challenger
import (
"errors"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
)
var ErrMissingFactoryEvent = errors.New("missing factory event")
// BuildDisputeGameLogFilter creates a filter query for the DisputeGameFactory contract.
//
// The `DisputeGameCreated` event is encoded as:
// 0: address indexed disputeProxy,
// 1: GameType indexed gameType,
// 2: Claim indexed rootClaim,
func BuildDisputeGameLogFilter(contract *abi.ABI) (ethereum.FilterQuery, error) {
event := contract.Events["DisputeGameCreated"]
if event.ID == (common.Hash{}) {
return ethereum.FilterQuery{}, ErrMissingFactoryEvent
}
query := ethereum.FilterQuery{
Topics: [][]common.Hash{
{event.ID},
},
}
return query, nil
}
package challenger
import (
"testing"
"github.com/stretchr/testify/require"
eth "github.com/ethereum/go-ethereum"
abi "github.com/ethereum/go-ethereum/accounts/abi"
common "github.com/ethereum/go-ethereum/common"
)
// TestBuildDisputeGameLogFilter_Succeeds tests that the DisputeGame
// Log Filter is built correctly.
func TestBuildDisputeGameLogFilter_Succeeds(t *testing.T) {
event := abi.Event{
ID: [32]byte{0x01},
}
filterQuery := eth.FilterQuery{
Topics: [][]common.Hash{
{event.ID},
},
}
dgfABI := abi.ABI{
Events: map[string]abi.Event{
"DisputeGameCreated": event,
},
}
query, err := BuildDisputeGameLogFilter(&dgfABI)
require.Equal(t, filterQuery, query)
require.NoError(t, err)
}
// TestBuildDisputeGameLogFilter_Fails tests that the DisputeGame
// Log Filter fails when the event definition is missing.
func TestBuildDisputeGameLogFilter_Fails(t *testing.T) {
dgfABI := abi.ABI{
Events: map[string]abi.Event{},
}
_, err := BuildDisputeGameLogFilter(&dgfABI)
require.ErrorIs(t, ErrMissingFactoryEvent, err)
}
package challenger
import (
"context"
"sync"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-service/backoff"
)
// logStore manages log subscriptions.
type logStore struct {
// The log filter query
query ethereum.FilterQuery
// core sync mutex for log store
// this locks the entire log store
mu sync.Mutex
logList []types.Log
logMap map[common.Hash][]types.Log
// Log subscription
subscription *Subscription
// Client to query for logs
client ethereum.LogFilterer
// Logger
log log.Logger
}
// NewLogStore creates a new log store.
func NewLogStore(query ethereum.FilterQuery, client ethereum.LogFilterer, log log.Logger) *logStore {
return &logStore{
query: query,
mu: sync.Mutex{},
logList: make([]types.Log, 0),
logMap: make(map[common.Hash][]types.Log),
subscription: NewSubscription(query, client, log),
client: client,
log: log,
}
}
// Subscribed returns true if the subscription has started.
func (l *logStore) Subscribed() bool {
return l.subscription.Started()
}
// Query returns the log filter query.
func (l *logStore) Query() ethereum.FilterQuery {
return l.query
}
// Client returns the log filter client.
func (l *logStore) Client() ethereum.LogFilterer {
return l.client
}
// GetLogs returns all logs in the log store.
func (l *logStore) GetLogs() []types.Log {
l.mu.Lock()
defer l.mu.Unlock()
logs := make([]types.Log, len(l.logList))
copy(logs, l.logList)
return logs
}
// GetLogByBlockHash returns all logs in the log store for a given block hash.
func (l *logStore) GetLogByBlockHash(blockHash common.Hash) []types.Log {
l.mu.Lock()
defer l.mu.Unlock()
logs := make([]types.Log, len(l.logMap[blockHash]))
copy(logs, l.logMap[blockHash])
return logs
}
// Subscribe starts the subscription.
// This function spawns a new goroutine.
func (l *logStore) Subscribe(ctx context.Context) error {
err := l.subscription.Subscribe()
if err != nil {
l.log.Error("failed to subscribe", "err", err)
return err
}
go l.dispatchLogs(ctx)
return nil
}
// Quit stops all log store asynchronous tasks.
func (l *logStore) Quit() {
l.subscription.Quit()
}
// buildBackoffStrategy builds a [backoff.Strategy].
func (l *logStore) buildBackoffStrategy() backoff.Strategy {
return &backoff.ExponentialStrategy{
Min: 1000,
Max: 20_000,
MaxJitter: 250,
}
}
// resubscribe attempts to re-establish the log store internal
// subscription with a backoff strategy.
func (l *logStore) resubscribe(ctx context.Context) error {
l.log.Info("log store resubscribing with backoff")
backoffStrategy := l.buildBackoffStrategy()
return backoff.DoCtx(ctx, 10, backoffStrategy, func() error {
if l.subscription == nil {
l.log.Error("subscription zeroed out")
return nil
}
err := l.subscription.Subscribe()
if err == nil {
l.log.Info("subscription reconnected", "id", l.subscription.ID())
}
return err
})
}
// insertLog inserts a log into the log store.
func (l *logStore) insertLog(log types.Log) {
l.mu.Lock()
l.logList = append(l.logList, log)
l.logMap[log.BlockHash] = append(l.logMap[log.BlockHash], log)
l.mu.Unlock()
}
// dispatchLogs dispatches logs to the log store.
// This function is intended to be run as a goroutine.
func (l *logStore) dispatchLogs(ctx context.Context) {
for {
select {
case err := <-l.subscription.sub.Err():
l.log.Error("log subscription error", "err", err)
for {
err = l.resubscribe(ctx)
if err == nil {
break
}
}
case log := <-l.subscription.logs:
l.insertLog(log)
case <-l.subscription.quit:
l.log.Info("received quit signal from subscription", "id", l.subscription.ID())
return
}
}
}
package challenger
import (
"context"
"errors"
"testing"
"time"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-e2e/e2eutils"
"github.com/ethereum-optimism/optimism/op-node/testlog"
"github.com/stretchr/testify/require"
)
type mockLogStoreClient struct {
sub mockSubscription
logs chan<- types.Log
subcount int
}
func newMockLogStoreClient() *mockLogStoreClient {
return &mockLogStoreClient{
sub: mockSubscription{
errorChan: make(chan error),
},
}
}
func (m *mockLogStoreClient) FilterLogs(context.Context, ethereum.FilterQuery) ([]types.Log, error) {
panic("this should not be called by the Subscription.Subscribe method")
}
func (m *mockLogStoreClient) SubscribeFilterLogs(ctx context.Context, query ethereum.FilterQuery, logs chan<- types.Log) (ethereum.Subscription, error) {
m.subcount = m.subcount + 1
m.logs = logs
return m.sub, nil
}
var (
ErrTestError = errors.New("test error")
)
// errLogStoreClient implements the [ethereum.LogFilter] interface for testing.
type errLogStoreClient struct{}
func (m errLogStoreClient) FilterLogs(context.Context, ethereum.FilterQuery) ([]types.Log, error) {
panic("this should not be called by the Subscription.Subscribe method")
}
func (m errLogStoreClient) SubscribeFilterLogs(context.Context, ethereum.FilterQuery, chan<- types.Log) (ethereum.Subscription, error) {
return nil, ErrTestError
}
type mockSubscription struct {
errorChan chan error
}
func (m mockSubscription) Err() <-chan error {
return m.errorChan
}
func (m mockSubscription) Unsubscribe() {}
func newLogStore(t *testing.T) (*logStore, *mockLogStoreClient) {
query := ethereum.FilterQuery{}
client := newMockLogStoreClient()
log := testlog.Logger(t, log.LvlError)
return NewLogStore(query, client, log), client
}
func newErrorLogStore(t *testing.T, client *errLogStoreClient) (*logStore, *errLogStoreClient) {
query := ethereum.FilterQuery{}
log := testlog.Logger(t, log.LvlError)
return NewLogStore(query, client, log), client
}
func TestLogStore_NewLogStore_NotSubscribed(t *testing.T) {
logStore, _ := newLogStore(t)
require.False(t, logStore.Subscribed())
}
func TestLogStore_NewLogStore_EmptyLogs(t *testing.T) {
logStore, _ := newLogStore(t)
require.Empty(t, logStore.GetLogs())
require.Empty(t, logStore.GetLogByBlockHash(common.Hash{}))
}
func TestLogStore_Subscribe_EstablishesSubscription(t *testing.T) {
logStore, client := newLogStore(t)
defer logStore.Quit()
require.Equal(t, 0, client.subcount)
require.False(t, logStore.Subscribed())
require.NoError(t, logStore.Subscribe(context.Background()))
require.True(t, logStore.Subscribed())
require.Equal(t, 1, client.subcount)
}
func TestLogStore_Subscribe_ReceivesLogs(t *testing.T) {
logStore, client := newLogStore(t)
defer logStore.Quit()
require.NoError(t, logStore.Subscribe(context.Background()))
mockLog := types.Log{
BlockHash: common.HexToHash("0x1"),
}
client.logs <- mockLog
timeout, tCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer tCancel()
err := e2eutils.WaitFor(timeout, 500*time.Millisecond, func() (bool, error) {
result := logStore.GetLogByBlockHash(mockLog.BlockHash)
return result[0].BlockHash == mockLog.BlockHash, nil
})
require.NoError(t, err)
}
func TestLogStore_Subscribe_SubscriptionErrors(t *testing.T) {
logStore, client := newLogStore(t)
defer logStore.Quit()
require.NoError(t, logStore.Subscribe(context.Background()))
client.sub.errorChan <- ErrTestError
timeout, tCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer tCancel()
err := e2eutils.WaitFor(timeout, 500*time.Millisecond, func() (bool, error) {
subcount := client.subcount == 2
started := logStore.subscription.Started()
return subcount && started, nil
})
require.NoError(t, err)
}
func TestLogStore_Subscribe_NoClient_Panics(t *testing.T) {
require.Panics(t, func() {
logStore, _ := newErrorLogStore(t, nil)
_ = logStore.Subscribe(context.Background())
})
}
func TestLogStore_Subscribe_ErrorSubscribing(t *testing.T) {
logStore, _ := newErrorLogStore(t, &errLogStoreClient{})
require.False(t, logStore.Subscribed())
require.EqualError(t, logStore.Subscribe(context.Background()), ErrTestError.Error())
}
func TestLogStore_Quit_ResetsSubscription(t *testing.T) {
logStore, _ := newLogStore(t)
require.False(t, logStore.Subscribed())
require.NoError(t, logStore.Subscribe(context.Background()))
require.True(t, logStore.Subscribed())
logStore.Quit()
require.False(t, logStore.Subscribed())
}
func TestLogStore_Quit_NoSubscription_Panics(t *testing.T) {
require.Panics(t, func() {
logStore, _ := newErrorLogStore(t, nil)
logStore.Quit()
})
}
package challenger
import (
"errors"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
)
var ErrMissingEvent = errors.New("missing event")
// BuildOutputLogFilter creates a filter query for the L2OutputOracle contract.
//
// The `OutputProposed` event is encoded as:
// 0: bytes32 indexed outputRoot,
// 1: uint256 indexed l2OutputIndex,
// 2: uint256 indexed l2BlockNumber,
// 3: uint256 l1Timestamp
func BuildOutputLogFilter(l2ooABI *abi.ABI) (ethereum.FilterQuery, error) {
// Get the L2OutputOracle contract `OutputProposed` event
event := l2ooABI.Events["OutputProposed"]
// Sanity check that the `OutputProposed` event is defined
if event.ID == (common.Hash{}) {
return ethereum.FilterQuery{}, ErrMissingEvent
}
query := ethereum.FilterQuery{
Topics: [][]common.Hash{
{event.ID},
},
}
return query, nil
}
package challenger
import (
"testing"
"github.com/stretchr/testify/require"
eth "github.com/ethereum/go-ethereum"
abi "github.com/ethereum/go-ethereum/accounts/abi"
common "github.com/ethereum/go-ethereum/common"
)
// TestBuildOutputLogFilter_Succeeds tests that the Output
// Log Filter is built correctly.
func TestBuildOutputLogFilter_Succeeds(t *testing.T) {
// Create a mock event id
event := abi.Event{
ID: [32]byte{0x01},
}
filterQuery := eth.FilterQuery{
Topics: [][]common.Hash{
{event.ID},
},
}
// Mock the ABI
l2ooABI := abi.ABI{
Events: map[string]abi.Event{
"OutputProposed": event,
},
}
// Build the filter
query, err := BuildOutputLogFilter(&l2ooABI)
require.Equal(t, filterQuery, query)
require.NoError(t, err)
}
// TestBuildOutputLogFilter_Fails tests that the Output
// Log Filter fails when the event definition is missing.
func TestBuildOutputLogFilter_Fails(t *testing.T) {
// Mock the ABI
l2ooABI := abi.ABI{
Events: map[string]abi.Event{},
}
// Build the filter
_, err := BuildOutputLogFilter(&l2ooABI)
require.Error(t, err)
require.ErrorIs(t, err, ErrMissingEvent)
}
package challenger
import (
"context"
"errors"
"math/big"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
"github.com/ethereum-optimism/optimism/op-node/eth"
)
var (
// supportedL2OutputVersion is the version of the L2 output that the challenger supports.
supportedL2OutputVersion = eth.Bytes32{}
// ErrInvalidBlockNumber is returned when the block number of the output does not match the expected block number.
ErrInvalidBlockNumber = errors.New("invalid block number")
// ErrUnsupportedL2OOVersion is returned when the output version is not supported.
ErrUnsupportedL2OOVersion = errors.New("unsupported l2oo version")
// ErrInvalidOutputLogTopic is returned when the output log topic is invalid.
ErrInvalidOutputLogTopic = errors.New("invalid output log topic")
// ErrInvalidOutputTopicLength is returned when the output log topic length is invalid.
ErrInvalidOutputTopicLength = errors.New("invalid output log topic length")
)
// ParseOutputLog parses a log from the L2OutputOracle contract.
func (c *Challenger) ParseOutputLog(log *types.Log) (*bindings.TypesOutputProposal, error) {
// Check the length of log topics
if len(log.Topics) != 4 {
return nil, ErrInvalidOutputTopicLength
}
// Validate the first topic is the output log topic
if log.Topics[0] != c.l2ooABI.Events["OutputProposed"].ID {
return nil, ErrInvalidOutputLogTopic
}
l2BlockNumber := new(big.Int).SetBytes(log.Topics[3][:])
expected := log.Topics[1]
return &bindings.TypesOutputProposal{
L2BlockNumber: l2BlockNumber,
OutputRoot: eth.Bytes32(expected),
}, nil
}
// ValidateOutput checks that a given output is expected via a trusted rollup node rpc.
// It returns: if the output is correct, the fetched output, error
func (c *Challenger) ValidateOutput(ctx context.Context, proposal bindings.TypesOutputProposal) (bool, eth.Bytes32, error) {
// Fetch the output from the rollup node
ctx, cancel := context.WithTimeout(ctx, c.networkTimeout)
defer cancel()
output, err := c.rollupClient.OutputAtBlock(ctx, proposal.L2BlockNumber.Uint64())
if err != nil {
c.log.Error("Failed to fetch output", "blockNum", proposal.L2BlockNumber, "err", err)
return false, eth.Bytes32{}, err
}
// Compare the output root to the expected output root
equalRoots, err := c.compareOutputRoots(output, proposal)
if err != nil {
return false, eth.Bytes32{}, err
}
return equalRoots, output.OutputRoot, nil
}
// compareOutputRoots compares the output root of the given block number to the expected output root.
func (c *Challenger) compareOutputRoots(received *eth.OutputResponse, expected bindings.TypesOutputProposal) (bool, error) {
if received.Version != supportedL2OutputVersion {
c.log.Error("Unsupported l2 output version", "version", received.Version)
return false, ErrUnsupportedL2OOVersion
}
if received.BlockRef.Number != expected.L2BlockNumber.Uint64() {
c.log.Error("Invalid blockNumber", "expected", expected.L2BlockNumber, "actual", received.BlockRef.Number)
return false, ErrInvalidBlockNumber
}
return received.OutputRoot == expected.OutputRoot, nil
}
package challenger
import (
"context"
"errors"
"math/big"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
"github.com/ethereum-optimism/optimism/op-challenger/metrics"
"github.com/ethereum-optimism/optimism/op-node/eth"
"github.com/ethereum-optimism/optimism/op-node/testlog"
)
func TestChallenger_OutputProposed_Signature(t *testing.T) {
computed := crypto.Keccak256Hash([]byte("OutputProposed(bytes32,uint256,uint256,uint256)"))
challenger := newTestChallenger(t, eth.OutputResponse{}, true)
expected := challenger.l2ooABI.Events["OutputProposed"].ID
require.Equal(t, expected, computed)
}
func TestParseOutputLog_Succeeds(t *testing.T) {
challenger := newTestChallenger(t, eth.OutputResponse{}, true)
expectedBlockNumber := big.NewInt(0x04)
expectedOutputRoot := [32]byte{0x02}
logTopic := challenger.l2ooABI.Events["OutputProposed"].ID
log := types.Log{
Topics: []common.Hash{logTopic, common.Hash(expectedOutputRoot), {0x03}, common.BigToHash(expectedBlockNumber)},
}
outputProposal, err := challenger.ParseOutputLog(&log)
require.NoError(t, err)
require.Equal(t, expectedBlockNumber, outputProposal.L2BlockNumber)
require.Equal(t, expectedOutputRoot, outputProposal.OutputRoot)
}
func TestParseOutputLog_WrongLogTopic_Errors(t *testing.T) {
challenger := newTestChallenger(t, eth.OutputResponse{}, true)
_, err := challenger.ParseOutputLog(&types.Log{
Topics: []common.Hash{{0x01}, {0x02}, {0x03}, {0x04}},
})
require.ErrorIs(t, err, ErrInvalidOutputLogTopic)
}
func TestParseOutputLog_WrongTopicLength_Errors(t *testing.T) {
challenger := newTestChallenger(t, eth.OutputResponse{}, true)
logTopic := challenger.l2ooABI.Events["OutputProposed"].ID
_, err := challenger.ParseOutputLog(&types.Log{
Topics: []common.Hash{logTopic, {0x02}, {0x03}},
})
require.ErrorIs(t, err, ErrInvalidOutputTopicLength)
}
func TestChallenger_ValidateOutput_RollupClientErrors(t *testing.T) {
output := eth.OutputResponse{
Version: supportedL2OutputVersion,
OutputRoot: eth.Bytes32{},
BlockRef: eth.L2BlockRef{},
}
challenger := newTestChallenger(t, output, true)
checked := bindings.TypesOutputProposal{
L2BlockNumber: big.NewInt(0),
OutputRoot: output.OutputRoot,
}
valid, received, err := challenger.ValidateOutput(context.Background(), checked)
require.False(t, valid)
require.Equal(t, eth.Bytes32{}, received)
require.ErrorIs(t, err, mockOutputApiError)
}
func TestChallenger_ValidateOutput_ErrorsWithWrongVersion(t *testing.T) {
output := eth.OutputResponse{
Version: eth.Bytes32{0x01},
OutputRoot: eth.Bytes32{0x01},
BlockRef: eth.L2BlockRef{},
}
challenger := newTestChallenger(t, output, false)
checked := bindings.TypesOutputProposal{
L2BlockNumber: big.NewInt(0),
OutputRoot: output.OutputRoot,
}
valid, received, err := challenger.ValidateOutput(context.Background(), checked)
require.False(t, valid)
require.Equal(t, eth.Bytes32{}, received)
require.ErrorIs(t, err, ErrUnsupportedL2OOVersion)
}
func TestChallenger_ValidateOutput_ErrorsInvalidBlockNumber(t *testing.T) {
output := eth.OutputResponse{
Version: supportedL2OutputVersion,
OutputRoot: eth.Bytes32{0x01},
BlockRef: eth.L2BlockRef{},
}
challenger := newTestChallenger(t, output, false)
checked := bindings.TypesOutputProposal{
L2BlockNumber: big.NewInt(1),
OutputRoot: output.OutputRoot,
}
valid, received, err := challenger.ValidateOutput(context.Background(), checked)
require.False(t, valid)
require.Equal(t, eth.Bytes32{}, received)
require.ErrorIs(t, err, ErrInvalidBlockNumber)
}
func TestOutput_ValidateOutput(t *testing.T) {
output := eth.OutputResponse{
Version: eth.Bytes32{},
OutputRoot: eth.Bytes32{},
BlockRef: eth.L2BlockRef{},
}
challenger := newTestChallenger(t, output, false)
checked := bindings.TypesOutputProposal{
L2BlockNumber: big.NewInt(0),
OutputRoot: output.OutputRoot,
}
valid, expected, err := challenger.ValidateOutput(context.Background(), checked)
require.Equal(t, expected, output.OutputRoot)
require.True(t, valid)
require.NoError(t, err)
}
func TestChallenger_CompareOutputRoots_ErrorsWithDifferentRoots(t *testing.T) {
output := eth.OutputResponse{
Version: eth.Bytes32{0xFF, 0xFF, 0xFF, 0xFF},
OutputRoot: eth.Bytes32{},
BlockRef: eth.L2BlockRef{},
}
challenger := newTestChallenger(t, output, false)
checked := bindings.TypesOutputProposal{
L2BlockNumber: big.NewInt(0),
OutputRoot: output.OutputRoot,
}
valid, err := challenger.compareOutputRoots(&output, checked)
require.False(t, valid)
require.ErrorIs(t, err, ErrUnsupportedL2OOVersion)
}
func TestChallenger_CompareOutputRoots_ErrInvalidBlockNumber(t *testing.T) {
output := eth.OutputResponse{
Version: supportedL2OutputVersion,
OutputRoot: eth.Bytes32{},
BlockRef: eth.L2BlockRef{},
}
challenger := newTestChallenger(t, output, false)
checked := bindings.TypesOutputProposal{
L2BlockNumber: big.NewInt(1),
OutputRoot: output.OutputRoot,
}
valid, err := challenger.compareOutputRoots(&output, checked)
require.False(t, valid)
require.ErrorIs(t, err, ErrInvalidBlockNumber)
}
func TestChallenger_CompareOutputRoots_Succeeds(t *testing.T) {
output := eth.OutputResponse{
Version: supportedL2OutputVersion,
OutputRoot: eth.Bytes32{},
BlockRef: eth.L2BlockRef{},
}
challenger := newTestChallenger(t, output, false)
checked := bindings.TypesOutputProposal{
L2BlockNumber: big.NewInt(0),
OutputRoot: output.OutputRoot,
}
valid, err := challenger.compareOutputRoots(&output, checked)
require.True(t, valid)
require.NoError(t, err)
checked = bindings.TypesOutputProposal{
L2BlockNumber: big.NewInt(0),
OutputRoot: eth.Bytes32{0x01},
}
valid, err = challenger.compareOutputRoots(&output, checked)
require.False(t, valid)
require.NoError(t, err)
}
func newTestChallenger(t *testing.T, output eth.OutputResponse, errors bool) *Challenger {
outputApi := newMockOutputApi(output, errors)
log := testlog.Logger(t, log.LvlError)
metr := metrics.NewMetrics("test")
parsedL2oo, err := bindings.L2OutputOracleMetaData.GetAbi()
require.NoError(t, err)
challenger := Challenger{
rollupClient: outputApi,
log: log,
metr: metr,
networkTimeout: time.Duration(5) * time.Second,
l2ooABI: parsedL2oo,
}
return &challenger
}
var mockOutputApiError = errors.New("mock output api error")
type mockOutputApi struct {
mock.Mock
expected eth.OutputResponse
errors bool
}
func newMockOutputApi(output eth.OutputResponse, errors bool) *mockOutputApi {
return &mockOutputApi{
expected: output,
errors: errors,
}
}
func (m *mockOutputApi) OutputAtBlock(ctx context.Context, blockNumber uint64) (*eth.OutputResponse, error) {
if m.errors {
return nil, mockOutputApiError
}
return &m.expected, nil
}
package challenger
import (
"context"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/log"
)
// SubscriptionId is a unique subscription ID.
type SubscriptionId uint64
// Increment returns the next subscription ID.
func (s *SubscriptionId) Increment() SubscriptionId {
*s++
return *s
}
// Subscription wraps an [ethereum.Subscription] to provide a restart.
type Subscription struct {
// The subscription ID
id SubscriptionId
// The current subscription
sub ethereum.Subscription
// If the subscription is started
started bool
// The query used to create the subscription
query ethereum.FilterQuery
// The log channel
logs chan types.Log
// The quit channel
quit chan struct{}
// Filter client used to open the log subscription
client ethereum.LogFilterer
// Logger
log log.Logger
}
// NewSubscription creates a new subscription.
func NewSubscription(query ethereum.FilterQuery, client ethereum.LogFilterer, log log.Logger) *Subscription {
return &Subscription{
id: SubscriptionId(0),
sub: nil,
started: false,
query: query,
logs: make(chan types.Log),
quit: make(chan struct{}),
client: client,
log: log,
}
}
// ID returns the subscription ID.
func (s *Subscription) ID() SubscriptionId {
return s.id
}
// Started returns true if the subscription has started.
func (s *Subscription) Started() bool {
return s.started
}
// Logs returns the log channel.
func (s *Subscription) Logs() <-chan types.Log {
return s.logs
}
// Subscribe constructs the subscription.
func (s *Subscription) Subscribe() error {
s.log.Info("Subscribing to", "query", s.query.Topics, "id", s.id)
sub, err := s.client.SubscribeFilterLogs(context.Background(), s.query, s.logs)
if err != nil {
s.log.Error("failed to subscribe to logs", "err", err)
return err
}
s.sub = sub
s.started = true
return nil
}
// Quit closes the subscription.
func (s *Subscription) Quit() {
s.log.Info("Quitting subscription", "id", s.id)
s.sub.Unsubscribe()
s.quit <- struct{}{}
s.started = false
s.log.Info("Quit subscription", "id", s.id)
}
package challenger
import (
"context"
"errors"
"math"
"testing"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-node/testlog"
"github.com/stretchr/testify/require"
)
type mockLogFilterClient struct{}
func (m mockLogFilterClient) FilterLogs(context.Context, ethereum.FilterQuery) ([]types.Log, error) {
panic("this should not be called by the Subscription.Subscribe method")
}
func (m mockLogFilterClient) SubscribeFilterLogs(context.Context, ethereum.FilterQuery, chan<- types.Log) (ethereum.Subscription, error) {
return nil, nil
}
func newSubscription(t *testing.T, client *mockLogFilterClient) (*Subscription, *mockLogFilterClient) {
query := ethereum.FilterQuery{}
log := testlog.Logger(t, log.LvlError)
return NewSubscription(query, client, log), client
}
func FuzzSubscriptionId_Increment(f *testing.F) {
maxUint64 := uint64(math.MaxUint64)
f.Fuzz(func(t *testing.T, id uint64) {
if id >= maxUint64 {
t.Skip("skipping due to overflow")
} else {
subId := SubscriptionId(id)
require.Equal(t, subId.Increment(), SubscriptionId(id+1))
}
})
}
func TestSubscription_Subscribe_NilClient_Panics(t *testing.T) {
defer func() {
if recover() == nil {
t.Error("expected nil client to panic")
}
}()
subscription, _ := newSubscription(t, nil)
require.NoError(t, subscription.Subscribe())
}
func TestSubscription_Subscribe(t *testing.T) {
subscription, _ := newSubscription(t, &mockLogFilterClient{})
require.NoError(t, subscription.Subscribe())
require.True(t, subscription.Started())
}
var ErrSubscriptionFailed = errors.New("failed to subscribe to logs")
type errLogFilterClient struct{}
func (m errLogFilterClient) FilterLogs(context.Context, ethereum.FilterQuery) ([]types.Log, error) {
panic("this should not be called by the Subscription.Subscribe method")
}
func (m errLogFilterClient) SubscribeFilterLogs(context.Context, ethereum.FilterQuery, chan<- types.Log) (ethereum.Subscription, error) {
return nil, ErrSubscriptionFailed
}
func TestSubscription_Subscribe_SubscriptionErrors(t *testing.T) {
query := ethereum.FilterQuery{}
log := testlog.Logger(t, log.LvlError)
subscription := Subscription{
query: query,
client: errLogFilterClient{},
log: log,
}
require.EqualError(t, subscription.Subscribe(), ErrSubscriptionFailed.Error())
}
package main
import (
"context"
"fmt"
_ "net/http/pprof"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-challenger/config"
"github.com/ethereum-optimism/optimism/op-challenger/metrics"
"github.com/ethereum-optimism/optimism/op-service/opio"
"github.com/ethereum-optimism/optimism/op-challenger/challenger"
"github.com/ethereum-optimism/optimism/op-service/pprof"
"github.com/ethereum-optimism/optimism/op-service/rpc"
)
// Main is the entrypoint into the Challenger. This method executes the
// service and blocks until the service exits.
func Main(logger log.Logger, version string, cfg *config.Config) error {
if err := cfg.Check(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
m := metrics.NewMetrics("default")
logger.Info("Initializing Challenger")
service, err := challenger.NewChallenger(*cfg, logger, m)
if err != nil {
logger.Error("Unable to create the Challenger", "error", err)
return err
}
logger.Info("Starting Challenger")
ctx, cancel := context.WithCancel(context.Background())
if err := service.Start(); err != nil {
cancel()
logger.Error("Unable to start Challenger", "error", err)
return err
}
defer service.Stop()
logger.Info("Challenger started")
pprofConfig := cfg.PprofConfig
if pprofConfig.Enabled {
logger.Info("starting pprof", "addr", pprofConfig.ListenAddr, "port", pprofConfig.ListenPort)
go func() {
if err := pprof.ListenAndServe(ctx, pprofConfig.ListenAddr, pprofConfig.ListenPort); err != nil {
logger.Error("error starting pprof", "err", err)
}
}()
}
metricsCfg := cfg.MetricsConfig
if metricsCfg.Enabled {
log.Info("starting metrics server", "addr", metricsCfg.ListenAddr, "port", metricsCfg.ListenPort)
go func() {
if err := m.Serve(ctx, metricsCfg.ListenAddr, metricsCfg.ListenPort); err != nil {
logger.Error("error starting metrics server", err)
}
}()
m.StartBalanceMetrics(ctx, logger, service.Client(), service.From())
}
rpcCfg := cfg.RPCConfig
server := rpc.NewServer(rpcCfg.ListenAddr, rpcCfg.ListenPort, version, rpc.WithLogger(logger))
if err := server.Start(); err != nil {
cancel()
return fmt.Errorf("error starting RPC server: %w", err)
}
m.RecordInfo(version)
m.RecordUp()
opio.BlockOnInterrupts()
cancel()
return nil
}
......@@ -3,14 +3,12 @@ package main
import (
"os"
log "github.com/ethereum/go-ethereum/log"
cli "github.com/urfave/cli/v2"
watch "github.com/ethereum-optimism/optimism/op-challenger/cmd/watch"
config "github.com/ethereum-optimism/optimism/op-challenger/config"
flags "github.com/ethereum-optimism/optimism/op-challenger/flags"
version "github.com/ethereum-optimism/optimism/op-challenger/version"
"github.com/ethereum/go-ethereum/log"
"github.com/urfave/cli/v2"
"github.com/ethereum-optimism/optimism/op-challenger/config"
"github.com/ethereum-optimism/optimism/op-challenger/flags"
"github.com/ethereum-optimism/optimism/op-challenger/version"
oplog "github.com/ethereum-optimism/optimism/op-service/log"
)
......@@ -35,47 +33,33 @@ var VersionWithMeta = func() string {
}()
func main() {
args := os.Args
if err := run(args, Main); err != nil {
log.Crit("Application failed", "err", err)
}
}
type ConfigAction func(log log.Logger, version string, config *config.Config) error
// run parses the supplied args to create a config.Config instance, sets up logging
// then calls the supplied ConfigAction.
// This allows testing the translation from CLI arguments to Config
func run(args []string, action ConfigAction) error {
// Set up logger with a default INFO level in case we fail to parse flags,
// otherwise the final critical log won't show what the parsing error was.
oplog.SetupDefaults()
app := cli.NewApp()
app.Version = VersionWithMeta
app.Flags = flags.Flags
app.Name = "op-challenger"
app.Usage = "Challenge Invalid L2OutputOracle Outputs"
app.Description = "A modular op-stack challenge agent for dispute games written in golang."
app.Usage = "Challenge outputs"
app.Description = "Ensures that on chain outputs are correct."
app.Action = func(ctx *cli.Context) error {
logger, err := config.LoggerFromCLI(ctx)
if err != nil {
return err
}
logger.Info("Starting challenger", "version", VersionWithMeta)
cfg, err := config.NewConfigFromCLI(ctx)
if err != nil {
return err
}
return action(logger, VersionWithMeta, cfg)
return FaultGame(VersionWithMeta, ctx)
}
app.Commands = []*cli.Command{
{
Name: "watch",
Subcommands: watch.Subcommands,
},
if err := app.Run(os.Args); err != nil {
log.Crit("Application failed", "message", err)
}
}
type ConfigAction func(log log.Logger, version string, config *config.Config) error
return app.Run(args)
func FaultGame(version string, cliCtx *cli.Context) error {
cfg, err := config.NewConfigFromCLI(cliCtx)
if err != nil {
return err
}
if err := cfg.Check(); err != nil {
return err
}
log := oplog.NewLogger(cfg.LogConfig)
log.Info("Fault game started")
return nil
}
package watch
import (
"github.com/urfave/cli/v2"
"github.com/ethereum-optimism/optimism/op-challenger/config"
)
var Subcommands = cli.Commands{
{
Name: "oracle",
Usage: "Watches the L2OutputOracle for new output proposals",
Action: func(ctx *cli.Context) error {
logger, err := config.LoggerFromCLI(ctx)
if err != nil {
return err
}
logger.Info("Listening for new output proposals")
cfg, err := config.NewConfigFromCLI(ctx)
if err != nil {
return err
}
return Oracle(logger, cfg)
},
},
{
Name: "factory",
Usage: "Watches the DisputeGameFactory for new dispute games",
Action: func(ctx *cli.Context) error {
logger, err := config.LoggerFromCLI(ctx)
if err != nil {
return err
}
logger.Info("Listening for new dispute games")
cfg, err := config.NewConfigFromCLI(ctx)
if err != nil {
return err
}
return Factory(logger, cfg)
},
},
}
package watch
import (
"fmt"
"os"
"os/signal"
"syscall"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-challenger/challenger"
"github.com/ethereum-optimism/optimism/op-challenger/config"
"github.com/ethereum-optimism/optimism/op-challenger/metrics"
)
// Factory listens to the DisputeGameFactory for newly created dispute games.
func Factory(logger log.Logger, cfg *config.Config) error {
if err := cfg.Check(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
m := metrics.NewMetrics("default")
service, err := challenger.NewChallenger(*cfg, logger, m)
if err != nil {
logger.Error("Unable to create the Challenger", "error", err)
return err
}
logger.Info("Listening for DisputeGameCreated events from the DisputeGameFactory contract", "dgf", cfg.DGFAddress.String())
subscription, err := service.NewFactorySubscription()
if err != nil {
logger.Error("Unable to create the subscription", "error", err)
return err
}
err = subscription.Subscribe()
if err != nil {
logger.Error("Unable to subscribe to the DisputeGameFactory contract", "error", err)
return err
}
defer subscription.Quit()
interruptChannel := make(chan os.Signal, 1)
signal.Notify(interruptChannel, []os.Signal{
os.Interrupt,
os.Kill,
syscall.SIGTERM,
syscall.SIGQUIT,
}...)
for {
select {
case log := <-subscription.Logs():
logger.Info("Received log", "log", log)
case <-interruptChannel:
logger.Info("Received interrupt signal, exiting...")
}
}
}
package watch
import (
"fmt"
"os"
"os/signal"
"syscall"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-challenger/challenger"
"github.com/ethereum-optimism/optimism/op-challenger/config"
"github.com/ethereum-optimism/optimism/op-challenger/metrics"
)
// Oracle listens to the L2OutputOracle for newly proposed outputs.
func Oracle(logger log.Logger, cfg *config.Config) error {
if err := cfg.Check(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
m := metrics.NewMetrics("default")
service, err := challenger.NewChallenger(*cfg, logger, m)
if err != nil {
logger.Error("Unable to create the Challenger", "error", err)
return err
}
logger.Info("Listening for OutputProposed events from the L2OutputOracle contract", "l2oo", cfg.L2OOAddress.String())
subscription, err := service.NewOracleSubscription()
if err != nil {
logger.Error("Unable to create the subscription", "error", err)
return err
}
err = subscription.Subscribe()
if err != nil {
logger.Error("Unable to subscribe to the L2OutputOracle contract", "error", err)
return err
}
defer subscription.Quit()
interruptChannel := make(chan os.Signal, 1)
signal.Notify(interruptChannel, []os.Signal{
os.Interrupt,
os.Kill,
syscall.SIGTERM,
syscall.SIGQUIT,
}...)
for {
select {
case log := <-subscription.Logs():
logger.Info("Received log", "log", log)
case <-interruptChannel:
logger.Info("Received interrupt signal, exiting...")
}
}
}
......@@ -2,94 +2,70 @@ package config
import (
"errors"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/urfave/cli/v2"
flags "github.com/ethereum-optimism/optimism/op-challenger/flags"
"github.com/ethereum-optimism/optimism/op-challenger/flags"
opservice "github.com/ethereum-optimism/optimism/op-service"
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"
txmgr "github.com/ethereum-optimism/optimism/op-service/txmgr"
"github.com/ethereum-optimism/optimism/op-service/txmgr"
)
var (
ErrMissingL1EthRPC = errors.New("missing l1 eth rpc url")
ErrMissingRollupRpc = errors.New("missing rollup rpc url")
ErrMissingL2OOAddress = errors.New("missing l2 output oracle contract address")
ErrMissingDGFAddress = errors.New("missing dispute game factory contract address")
ErrInvalidNetworkTimeout = errors.New("invalid network timeout")
ErrMissingTxMgrConfig = errors.New("missing tx manager config")
ErrMissingRPCConfig = errors.New("missing rpc config")
ErrMissingLogConfig = errors.New("missing log config")
ErrMissingMetricsConfig = errors.New("missing metrics config")
ErrMissingPprofConfig = errors.New("missing pprof config")
ErrMissingL1EthRPC = errors.New("missing l1 eth rpc url")
ErrMissingGameAddress = errors.New("missing game address")
ErrMissingAlphabetTrace = errors.New("missing alphabet trace")
)
// Config is a well typed config that is parsed from the CLI params.
// This also contains config options for auxiliary services.
// It is used to initialize the challenger.
type Config struct {
// L1EthRpc is the HTTP provider URL for L1.
L1EthRpc string
// RollupRpc is the HTTP provider URL for the rollup node.
RollupRpc string
// L2OOAddress is the L2OutputOracle contract address.
L2OOAddress common.Address
// DGFAddress is the DisputeGameFactory contract address.
DGFAddress common.Address
// NetworkTimeout is the timeout for network requests.
NetworkTimeout time.Duration
TxMgrConfig *txmgr.CLIConfig
RPCConfig *oprpc.CLIConfig
LogConfig *oplog.CLIConfig
MetricsConfig *opmetrics.CLIConfig
L1EthRpc string // L1 RPC Url
GameAddress common.Address // Address of the fault game
AlphabetTrace string // String for the AlphabetTraceProvider
TxMgrConfig txmgr.CLIConfig
RPCConfig oprpc.CLIConfig
LogConfig oplog.CLIConfig
MetricsConfig opmetrics.CLIConfig
PprofConfig oppprof.CLIConfig
}
PprofConfig *oppprof.CLIConfig
func NewConfig(L1EthRpc string,
GameAddress common.Address,
AlphabetTrace string,
TxMgrConfig txmgr.CLIConfig,
RPCConfig oprpc.CLIConfig,
LogConfig oplog.CLIConfig,
MetricsConfig opmetrics.CLIConfig,
PprofConfig oppprof.CLIConfig,
) Config {
return Config{
L1EthRpc,
GameAddress,
AlphabetTrace,
TxMgrConfig,
RPCConfig,
LogConfig,
MetricsConfig,
PprofConfig,
}
}
func (c Config) Check() error {
if c.L1EthRpc == "" {
return ErrMissingL1EthRPC
}
if c.RollupRpc == "" {
return ErrMissingRollupRpc
}
if c.L2OOAddress == (common.Address{}) {
return ErrMissingL2OOAddress
}
if c.DGFAddress == (common.Address{}) {
return ErrMissingDGFAddress
}
if c.NetworkTimeout == 0 {
return ErrInvalidNetworkTimeout
if c.GameAddress == (common.Address{}) {
return ErrMissingGameAddress
}
if c.TxMgrConfig == nil {
return ErrMissingTxMgrConfig
}
if c.RPCConfig == nil {
return ErrMissingRPCConfig
}
if c.LogConfig == nil {
return ErrMissingLogConfig
}
if c.MetricsConfig == nil {
return ErrMissingMetricsConfig
}
if c.PprofConfig == nil {
return ErrMissingPprofConfig
if c.AlphabetTrace == "" {
return ErrMissingAlphabetTrace
}
if err := c.RPCConfig.Check(); err != nil {
return err
......@@ -109,53 +85,14 @@ func (c Config) Check() error {
return nil
}
// NewConfig creates a Config with all optional values set to the CLI default value
func NewConfig(
L1EthRpc string,
RollupRpc string,
L2OOAddress common.Address,
DGFAddress common.Address,
NetworkTimeout time.Duration,
TxMgrConfig *txmgr.CLIConfig,
RPCConfig *oprpc.CLIConfig,
LogConfig *oplog.CLIConfig,
MetricsConfig *opmetrics.CLIConfig,
PprofConfig *oppprof.CLIConfig,
) *Config {
return &Config{
L1EthRpc: L1EthRpc,
RollupRpc: RollupRpc,
L2OOAddress: L2OOAddress,
DGFAddress: DGFAddress,
NetworkTimeout: NetworkTimeout,
TxMgrConfig: TxMgrConfig,
RPCConfig: RPCConfig,
LogConfig: LogConfig,
MetricsConfig: MetricsConfig,
PprofConfig: PprofConfig,
}
}
// NewConfigFromCLI parses the Config from the provided flags or environment variables.
func NewConfigFromCLI(ctx *cli.Context) (*Config, error) {
if err := flags.CheckRequired(ctx); err != nil {
return nil, err
}
l1EthRpc := ctx.String(flags.L1EthRpcFlag.Name)
if l1EthRpc == "" {
return nil, ErrMissingL1EthRPC
}
rollupRpc := ctx.String(flags.RollupRpcFlag.Name)
if rollupRpc == "" {
return nil, ErrMissingRollupRpc
}
l2ooAddress, err := opservice.ParseAddress(ctx.String(flags.L2OOAddressFlag.Name))
if err != nil {
return nil, ErrMissingL2OOAddress
}
dgfAddress, err := opservice.ParseAddress(ctx.String(flags.DGFAddressFlag.Name))
if err != nil {
return nil, ErrMissingDGFAddress
return nil, err
}
txMgrConfig := txmgr.ReadCLIConfig(ctx)
......@@ -166,15 +103,14 @@ func NewConfigFromCLI(ctx *cli.Context) (*Config, error) {
return &Config{
// Required Flags
L1EthRpc: l1EthRpc,
RollupRpc: rollupRpc,
L2OOAddress: l2ooAddress,
DGFAddress: dgfAddress,
TxMgrConfig: &txMgrConfig,
L1EthRpc: ctx.String(flags.L1EthRpcFlag.Name),
GameAddress: dgfAddress,
AlphabetTrace: ctx.String(flags.AlphabetFlag.Name),
TxMgrConfig: txMgrConfig,
// Optional Flags
RPCConfig: &rpcConfig,
LogConfig: &logConfig,
MetricsConfig: &metricsConfig,
PprofConfig: &pprofConfig,
RPCConfig: rpcConfig,
LogConfig: logConfig,
MetricsConfig: metricsConfig,
PprofConfig: pprofConfig,
}, nil
}
......@@ -8,8 +8,8 @@ import (
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"
txmgr "github.com/ethereum-optimism/optimism/op-service/txmgr"
client "github.com/ethereum-optimism/optimism/op-signer/client"
"github.com/ethereum-optimism/optimism/op-service/txmgr"
"github.com/ethereum-optimism/optimism/op-signer/client"
"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"
......@@ -17,10 +17,9 @@ import (
var (
validL1EthRpc = "http://localhost:8545"
validRollupRpc = "http://localhost:8546"
validL2OOAddress = common.HexToAddress("0x7bdd3b028C4796eF0EAf07d11394d0d9d8c24139")
validDGFAddress = common.HexToAddress("0x7bdd3b028C4796eF0EAf07d11394d0d9d8c24139")
validGameAddress = common.HexToAddress("0x7bdd3b028C4796eF0EAf07d11394d0d9d8c24139")
validNetworkTimeout = time.Duration(5) * time.Second
validAlphabetTrace = "abcdefgh"
)
var validTxMgrConfig = txmgr.CLIConfig{
......@@ -53,19 +52,15 @@ var validPprofConfig = oppprof.CLIConfig{
Enabled: false,
}
func validConfig() *Config {
cfg := NewConfig(
validL1EthRpc,
validRollupRpc,
validL2OOAddress,
validDGFAddress,
validNetworkTimeout,
&validTxMgrConfig,
&validRPCConfig,
&validLogConfig,
&validMetricsConfig,
&validPprofConfig,
)
func validConfig() Config {
cfg := NewConfig(validL1EthRpc,
validGameAddress,
validAlphabetTrace,
validTxMgrConfig,
validRPCConfig,
validLogConfig,
validMetricsConfig,
validPprofConfig)
return cfg
}
......@@ -76,16 +71,9 @@ func TestValidConfigIsValid(t *testing.T) {
}
func TestTxMgrConfig(t *testing.T) {
t.Run("Required", func(t *testing.T) {
config := validConfig()
config.TxMgrConfig = nil
err := config.Check()
require.ErrorIs(t, err, ErrMissingTxMgrConfig)
})
t.Run("Invalid", func(t *testing.T) {
config := validConfig()
config.TxMgrConfig = &txmgr.CLIConfig{}
config.TxMgrConfig = txmgr.CLIConfig{}
err := config.Check()
require.Equal(t, err.Error(), "must provide a L1 RPC url")
})
......@@ -98,30 +86,16 @@ func TestL1EthRpcRequired(t *testing.T) {
require.ErrorIs(t, err, ErrMissingL1EthRPC)
}
func TestRollupRpcRequired(t *testing.T) {
config := validConfig()
config.RollupRpc = ""
err := config.Check()
require.ErrorIs(t, err, ErrMissingRollupRpc)
}
func TestL2OOAddressRequired(t *testing.T) {
config := validConfig()
config.L2OOAddress = common.Address{}
err := config.Check()
require.ErrorIs(t, err, ErrMissingL2OOAddress)
}
func TestDGFAddressRequired(t *testing.T) {
func TestGameAddressRequired(t *testing.T) {
config := validConfig()
config.DGFAddress = common.Address{}
config.GameAddress = common.Address{}
err := config.Check()
require.ErrorIs(t, err, ErrMissingDGFAddress)
require.ErrorIs(t, err, ErrMissingGameAddress)
}
func TestNetworkTimeoutRequired(t *testing.T) {
func TestAlphabetTraceRequired(t *testing.T) {
config := validConfig()
config.NetworkTimeout = 0
config.AlphabetTrace = ""
err := config.Check()
require.ErrorIs(t, err, ErrInvalidNetworkTimeout)
require.ErrorIs(t, err, ErrMissingAlphabetTrace)
}
package config
import (
"fmt"
log "github.com/ethereum/go-ethereum/log"
cli "github.com/urfave/cli/v2"
oplog "github.com/ethereum-optimism/optimism/op-service/log"
)
// LoggerFromCLI creates a [log.Logger] from the
// supplied [cli.Context].
func LoggerFromCLI(ctx *cli.Context) (log.Logger, error) {
logCfg := oplog.ReadCLIConfig(ctx)
if err := logCfg.Check(); err != nil {
return nil, fmt.Errorf("log config error: %w", err)
}
logger := oplog.NewLogger(logCfg)
return logger, nil
}
......@@ -18,27 +18,35 @@ type AlphabetProvider struct {
func NewAlphabetProvider(state string, depth uint64) *AlphabetProvider {
return &AlphabetProvider{
state: strings.Split(state, ""),
maxLen: (1 << depth),
maxLen: uint64(1 << depth),
}
}
// Get returns the claim value at the given index in the trace.
func (ap *AlphabetProvider) Get(i uint64) (common.Hash, error) {
// GetPreimage returns the preimage for the given hash.
func (ap *AlphabetProvider) GetPreimage(i uint64) ([]byte, error) {
// The index cannot be larger than the maximum index as computed by the depth.
if i >= ap.maxLen {
return common.Hash{}, ErrIndexTooLarge
return []byte{}, ErrIndexTooLarge
}
// We extend the deepest hash to the maximum depth if the trace is not expansive.
if i >= uint64(len(ap.state)) {
return ap.Get(uint64(len(ap.state)) - 1)
return ap.GetPreimage(uint64(len(ap.state)) - 1)
}
return buildAlphabetClaimBytes(i, ap.state[i]), nil
}
// Get returns the claim value at the given index in the trace.
func (ap *AlphabetProvider) Get(i uint64) (common.Hash, error) {
claimBytes, err := ap.GetPreimage(i)
if err != nil {
return common.Hash{}, err
}
return ap.ComputeAlphabetClaim(i), nil
return common.BytesToHash(claimBytes), nil
}
// ComputeAlphabetClaim computes the claim for the given index in the trace.
func (ap *AlphabetProvider) ComputeAlphabetClaim(i uint64) common.Hash {
concatenated := append(IndexToBytes(i), []byte(ap.state[i])...)
return common.BytesToHash(concatenated)
// buildAlphabetClaimBytes constructs the claim bytes for the index and state item.
func buildAlphabetClaimBytes(i uint64, letter string) []byte {
return append(IndexToBytes(i), []byte(letter)...)
}
// IndexToBytes converts an index to a byte slice big endian
......
......@@ -50,17 +50,26 @@ func FuzzIndexToBytes(f *testing.F) {
})
}
// TestComputeAlphabetClaim tests the ComputeAlphabetClaim function.
func TestComputeAlphabetClaim(t *testing.T) {
// TestGetPreimage_Succeeds tests the GetPreimage function
// returns the correct pre-image for a index.
func TestGetPreimage_Succeeds(t *testing.T) {
ap := NewAlphabetProvider("abc", 2)
claim := ap.ComputeAlphabetClaim(0)
concatenated := append(IndexToBytes(0), []byte("a")...)
expected := common.BytesToHash(concatenated)
require.Equal(t, expected, claim)
expected := append(IndexToBytes(uint64(0)), []byte("a")...)
retrieved, err := ap.GetPreimage(uint64(0))
require.NoError(t, err)
require.Equal(t, expected, retrieved)
}
// TestGetPreimage_TooLargeIndex_Fails tests the GetPreimage
// function errors if the index is too large.
func TestGetPreimage_TooLargeIndex_Fails(t *testing.T) {
ap := NewAlphabetProvider("abc", 2)
_, err := ap.GetPreimage(4)
require.ErrorIs(t, err, ErrIndexTooLarge)
}
// TestGet tests the Get function.
func TestGet(t *testing.T) {
// TestGet_Succeeds tests the Get function.
func TestGet_Succeeds(t *testing.T) {
ap := NewAlphabetProvider("abc", 2)
claim, err := ap.Get(0)
require.NoError(t, err)
......
......@@ -26,6 +26,7 @@ type StepCallData struct {
// The [AlphabetProvider] is a minimal implementation of this interface.
type TraceProvider interface {
Get(i uint64) (common.Hash, error)
GetPreimage(i uint64) ([]byte, error)
}
// ClaimData is the core of a claim. It must be unique inside a specific game.
......
......@@ -26,29 +26,24 @@ var (
Usage: "HTTP provider URL for L1.",
EnvVars: prefixEnvVars("L1_ETH_RPC"),
}
RollupRpcFlag = &cli.StringFlag{
Name: "rollup-rpc",
Usage: "HTTP provider URL for the rollup node.",
EnvVars: prefixEnvVars("ROLLUP_RPC"),
}
L2OOAddressFlag = &cli.StringFlag{
Name: "l2oo-address",
Usage: "Address of the L2OutputOracle contract.",
EnvVars: prefixEnvVars("L2OO_ADDRESS"),
}
DGFAddressFlag = &cli.StringFlag{
Name: "dgf-address",
Usage: "Address of the DisputeGameFactory contract.",
EnvVars: prefixEnvVars("DGF_ADDRESS"),
Name: "game-address",
Usage: "Address of the Fault Game contract.",
EnvVars: prefixEnvVars("GAME_ADDRESS"),
}
AlphabetFlag = &cli.StringFlag{
Name: "alphabet",
Usage: "Alphabet Trace (temporary)",
EnvVars: prefixEnvVars("ALPHABET"),
}
// Optional Flags
)
// requiredFlags are checked by [CheckRequired]
var requiredFlags = []cli.Flag{
L1EthRpcFlag,
RollupRpcFlag,
L2OOAddressFlag,
DGFAddressFlag,
AlphabetFlag,
}
// optionalFlags is a list of unchecked cli flags
......
package metrics
import (
"context"
"github.com/ethereum-optimism/optimism/op-node/eth"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/log"
"github.com/prometheus/client_golang/prometheus"
opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics"
txmetrics "github.com/ethereum-optimism/optimism/op-service/txmgr/metrics"
)
const Namespace = "op_challenger"
type Metricer interface {
RecordInfo(version string)
RecordUp()
// Records all L1 and L2 block events
opmetrics.RefMetricer
// Record Tx metrics
txmetrics.TxMetricer
RecordValidOutput(l2ref eth.L2BlockRef)
RecordInvalidOutput(l2ref eth.L2BlockRef)
RecordOutputChallenged(l2ref eth.L2BlockRef)
}
type Metrics struct {
ns string
registry *prometheus.Registry
factory opmetrics.Factory
opmetrics.RefMetrics
txmetrics.TxMetrics
info prometheus.GaugeVec
up prometheus.Gauge
}
var _ Metricer = (*Metrics)(nil)
func NewMetrics(procName string) *Metrics {
if procName == "" {
procName = "default"
}
ns := Namespace + "_" + procName
registry := opmetrics.NewRegistry()
factory := opmetrics.With(registry)
return &Metrics{
ns: ns,
registry: registry,
factory: factory,
RefMetrics: opmetrics.MakeRefMetrics(ns, factory),
TxMetrics: txmetrics.MakeTxMetrics(ns, factory),
info: *factory.NewGaugeVec(prometheus.GaugeOpts{
Namespace: ns,
Name: "info",
Help: "Pseudo-metric tracking version and config info",
}, []string{
"version",
}),
up: factory.NewGauge(prometheus.GaugeOpts{
Namespace: ns,
Name: "up",
Help: "1 if the op-proposer has finished starting up",
}),
}
}
func (m *Metrics) Serve(ctx context.Context, host string, port int) error {
return opmetrics.ListenAndServe(ctx, m.registry, host, port)
}
func (m *Metrics) StartBalanceMetrics(ctx context.Context,
l log.Logger, client *ethclient.Client, account common.Address) {
opmetrics.LaunchBalanceMetrics(ctx, l, m.registry, m.ns, client, account)
}
// RecordInfo sets a pseudo-metric that contains versioning and
// config info for the op-proposer.
func (m *Metrics) RecordInfo(version string) {
m.info.WithLabelValues(version).Set(1)
}
// RecordUp sets the up metric to 1.
func (m *Metrics) RecordUp() {
prometheus.MustRegister()
m.up.Set(1)
}
const (
ValidOutput = "valid_output"
InvalidOutput = "invalid_output"
OutputChallenged = "output_challenged"
)
// RecordValidOutput should be called when a valid output is found
func (m *Metrics) RecordValidOutput(l2ref eth.L2BlockRef) {
m.RecordL2Ref(ValidOutput, l2ref)
}
// RecordInvalidOutput should be called when an invalid output is found
func (m *Metrics) RecordInvalidOutput(l2ref eth.L2BlockRef) {
m.RecordL2Ref(InvalidOutput, l2ref)
}
// RecordOutputChallenged should be called when an output is challenged
func (m *Metrics) RecordOutputChallenged(l2ref eth.L2BlockRef) {
m.RecordL2Ref(OutputChallenged, l2ref)
}
func (m *Metrics) Document() []opmetrics.DocumentedMetric {
return m.factory.Document()
}
package metrics
import (
"github.com/ethereum-optimism/optimism/op-node/eth"
opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics"
txmetrics "github.com/ethereum-optimism/optimism/op-service/txmgr/metrics"
)
type noopMetrics struct {
opmetrics.NoopRefMetrics
txmetrics.NoopTxMetrics
}
var NoopMetrics Metricer = new(noopMetrics)
func (*noopMetrics) RecordInfo(version string) {}
func (*noopMetrics) RecordUp() {}
func (*noopMetrics) RecordValidOutput(l2ref eth.L2BlockRef) {}
func (*noopMetrics) RecordInvalidOutput(l2ref eth.L2BlockRef) {}
func (*noopMetrics) RecordOutputChallenged(l2ref eth.L2BlockRef) {}
package types
import (
"fmt"
"github.com/ethereum-optimism/optimism/op-service/enum"
)
// GameType is the type of dispute game
type GameType uint8
// DefaultGameType returns the default dispute game type.
func DefaultGameType() GameType {
return AttestationDisputeGameType
}
// String returns the string value of a dispute game type.
func (g GameType) String() string {
return DisputeGameTypes[g]
}
const (
// AttestationDisputeGameType is the uint8 enum value for the attestation dispute game
AttestationDisputeGameType GameType = iota
// FaultDisputeGameType is the uint8 enum value for the fault dispute game
FaultDisputeGameType
// ValidityDisputeGameType is the uint8 enum value for the validity dispute game
ValidityDisputeGameType
)
// DisputeGameTypes is a list of dispute game types.
var DisputeGameTypes = []string{"attestation", "fault", "validity"}
// Valid returns true if the game type is within the valid range.
func (g GameType) Valid() bool {
return g >= AttestationDisputeGameType && g <= ValidityDisputeGameType
}
// DisputeGameType is a custom flag type for dispute game type.
type DisputeGameType struct {
Enum []enum.Stringered
selected GameType
}
// NewDisputeGameType returns a new dispute game type.
func NewDisputeGameType() *DisputeGameType {
return &DisputeGameType{
Enum: enum.StringeredList(DisputeGameTypes),
selected: DefaultGameType(),
}
}
// Set sets the dispute game type.
func (d *DisputeGameType) Set(value string) error {
for i, enum := range d.Enum {
if enum.String() == value {
d.selected = GameType(i)
return nil
}
}
return fmt.Errorf("allowed values are %s", enum.EnumString(d.Enum))
}
// String returns the selected dispute game type.
func (d DisputeGameType) String() string {
return d.selected.String()
}
// Type maps the [DisputeGameType] string value to a [GameType] enum value.
func (d DisputeGameType) Type() GameType {
return d.selected
}
package types
import (
"testing"
"github.com/stretchr/testify/require"
)
var (
disputeGames = []struct {
name string
gameType GameType
}{
{"attestation", AttestationDisputeGameType},
{"fault", FaultDisputeGameType},
{"validity", ValidityDisputeGameType},
}
)
// TestDefaultGameType returns the default dispute game type.
func TestDefaultGameType(t *testing.T) {
defaultGameType := disputeGames[0].gameType
require.Equal(t, defaultGameType, DefaultGameType())
}
// TestGameType_Valid tests the Valid function with valid inputs.
func TestGameType_Valid(t *testing.T) {
for _, game := range disputeGames {
require.True(t, game.gameType.Valid())
}
}
// TestGameType_Invalid tests the Valid function with an invalid input.
func TestGameType_Invalid(t *testing.T) {
invalid := disputeGames[len(disputeGames)-1].gameType + 1
require.False(t, GameType(invalid).Valid())
}
// FuzzGameType_Invalid checks that invalid game types are correctly
// returned as invalid by the validation [Valid] function.
func FuzzGameType_Invalid(f *testing.F) {
maxCount := len(DisputeGameTypes)
f.Fuzz(func(t *testing.T, number uint8) {
if number >= uint8(maxCount) {
require.False(t, GameType(number).Valid())
} else {
require.True(t, GameType(number).Valid())
}
})
}
// TestGameType_Default tests the default value of the DisputeGameType.
func TestGameType_Default(t *testing.T) {
d := NewDisputeGameType()
require.Equal(t, DefaultGameType(), d.selected)
require.Equal(t, DefaultGameType(), d.Type())
}
// TestGameType_String tests the Set and String function on the DisputeGameType.
func TestGameType_String(t *testing.T) {
for _, dg := range disputeGames {
t.Run(dg.name, func(t *testing.T) {
d := NewDisputeGameType()
require.Equal(t, dg.name, dg.gameType.String())
require.NoError(t, d.Set(dg.name))
require.Equal(t, dg.name, d.String())
require.Equal(t, dg.gameType, d.selected)
})
}
}
// TestGameType_Type tests the Type function on the DisputeGameType.
func TestGameType_Type(t *testing.T) {
for _, dg := range disputeGames {
t.Run(dg.name, func(t *testing.T) {
d := NewDisputeGameType()
require.Equal(t, dg.name, dg.gameType.String())
require.NoError(t, d.Set(dg.name))
require.Equal(t, dg.gameType, d.Type())
require.Equal(t, dg.gameType, d.selected)
})
}
}
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