Commit e1712a54 authored by Adrian Sutton's avatar Adrian Sutton Committed by GitHub

op-dispute-mon: Check proposals are supported by on chain data (#9847)

Checks the proposed L2 block was safe when the game was created using the safe head database if available.
If not available for any reason, it falls back to the current behaviour of considering the root valid if it matches the local node.
parent a3ca1f41
......@@ -98,27 +98,29 @@ func (f *FaultDisputeGameContract) GetBlockRange(ctx context.Context) (prestateB
return
}
// GetGameMetadata returns the game's L2 block number, root claim, status, and game duration.
func (f *FaultDisputeGameContract) GetGameMetadata(ctx context.Context, block rpcblock.Block) (uint64, common.Hash, gameTypes.GameStatus, uint64, error) {
// GetGameMetadata returns the game's L1 head, L2 block number, root claim, status, and game duration.
func (f *FaultDisputeGameContract) GetGameMetadata(ctx context.Context, block rpcblock.Block) (common.Hash, uint64, common.Hash, gameTypes.GameStatus, uint64, error) {
results, err := f.multiCaller.Call(ctx, block,
f.contract.Call(methodL1Head),
f.contract.Call(methodL2BlockNumber),
f.contract.Call(methodRootClaim),
f.contract.Call(methodStatus),
f.contract.Call(methodGameDuration))
if err != nil {
return 0, common.Hash{}, 0, 0, fmt.Errorf("failed to retrieve game metadata: %w", err)
return common.Hash{}, 0, common.Hash{}, 0, 0, fmt.Errorf("failed to retrieve game metadata: %w", err)
}
if len(results) != 4 {
return 0, common.Hash{}, 0, 0, fmt.Errorf("expected 3 results but got %v", len(results))
if len(results) != 5 {
return common.Hash{}, 0, common.Hash{}, 0, 0, fmt.Errorf("expected 3 results but got %v", len(results))
}
l2BlockNumber := results[0].GetBigInt(0).Uint64()
rootClaim := results[1].GetHash(0)
duration := results[3].GetUint64(0)
status, err := gameTypes.GameStatusFromUint8(results[2].GetUint8(0))
l1Head := results[0].GetHash(0)
l2BlockNumber := results[1].GetBigInt(0).Uint64()
rootClaim := results[2].GetHash(0)
status, err := gameTypes.GameStatusFromUint8(results[3].GetUint8(0))
if err != nil {
return 0, common.Hash{}, 0, 0, fmt.Errorf("failed to convert game status: %w", err)
return common.Hash{}, 0, common.Hash{}, 0, 0, fmt.Errorf("failed to convert game status: %w", err)
}
return l2BlockNumber, rootClaim, status, duration, nil
duration := results[4].GetUint64(0)
return l1Head, l2BlockNumber, rootClaim, status, duration, nil
}
func (f *FaultDisputeGameContract) GetGenesisOutputRoot(ctx context.Context) (common.Hash, error) {
......
......@@ -334,17 +334,20 @@ func TestGetSplitDepth(t *testing.T) {
func TestGetGameMetadata(t *testing.T) {
stubRpc, contract := setupFaultDisputeGameTest(t)
expectedL1Head := common.Hash{0x0a, 0x0b}
expectedL2BlockNumber := uint64(123)
expectedGameDuration := uint64(456)
expectedRootClaim := common.Hash{0x01, 0x02}
expectedStatus := types.GameStatusChallengerWon
block := rpcblock.ByNumber(889)
stubRpc.SetResponse(fdgAddr, methodL1Head, block, nil, []interface{}{expectedL1Head})
stubRpc.SetResponse(fdgAddr, methodL2BlockNumber, block, nil, []interface{}{new(big.Int).SetUint64(expectedL2BlockNumber)})
stubRpc.SetResponse(fdgAddr, methodRootClaim, block, nil, []interface{}{expectedRootClaim})
stubRpc.SetResponse(fdgAddr, methodStatus, block, nil, []interface{}{expectedStatus})
stubRpc.SetResponse(fdgAddr, methodGameDuration, block, nil, []interface{}{expectedGameDuration})
l2BlockNumber, rootClaim, status, duration, err := contract.GetGameMetadata(context.Background(), block)
l1Head, l2BlockNumber, rootClaim, status, duration, err := contract.GetGameMetadata(context.Background(), block)
require.NoError(t, err)
require.Equal(t, expectedL1Head, l1Head)
require.Equal(t, expectedL2BlockNumber, l2BlockNumber)
require.Equal(t, expectedRootClaim, rootClaim)
require.Equal(t, expectedStatus, status)
......
......@@ -17,7 +17,7 @@ import (
const metricsLabel = "game_caller_creator"
type GameCaller interface {
GetGameMetadata(context.Context, rpcblock.Block) (uint64, common.Hash, types.GameStatus, uint64, error)
GetGameMetadata(context.Context, rpcblock.Block) (common.Hash, uint64, common.Hash, types.GameStatus, uint64, error)
GetAllClaims(context.Context, rpcblock.Block) ([]faultTypes.Claim, error)
BondCaller
BalanceCaller
......
......@@ -51,7 +51,7 @@ func (e *Extractor) enrichGames(ctx context.Context, blockHash common.Hash, game
e.logger.Error("Failed to create game caller", "err", err)
continue
}
l2BlockNum, rootClaim, status, duration, err := caller.GetGameMetadata(ctx, rpcblock.ByHash(blockHash))
l1Head, l2BlockNum, rootClaim, status, duration, err := caller.GetGameMetadata(ctx, rpcblock.ByHash(blockHash))
if err != nil {
e.logger.Error("Failed to fetch game metadata", "err", err)
continue
......@@ -63,6 +63,7 @@ func (e *Extractor) enrichGames(ctx context.Context, blockHash common.Hash, game
}
enrichedGame := &monTypes.EnrichedGameData{
GameMetadata: game,
L1Head: l1Head,
L2BlockNumber: l2BlockNum,
RootClaim: rootClaim,
Status: status,
......
......@@ -191,12 +191,12 @@ type mockGameCaller struct {
balanceAddr common.Address
}
func (m *mockGameCaller) GetGameMetadata(_ context.Context, _ rpcblock.Block) (uint64, common.Hash, types.GameStatus, uint64, error) {
func (m *mockGameCaller) GetGameMetadata(_ context.Context, _ rpcblock.Block) (common.Hash, uint64, common.Hash, types.GameStatus, uint64, error) {
m.metadataCalls++
if m.metadataErr != nil {
return 0, common.Hash{}, 0, 0, m.metadataErr
return common.Hash{}, 0, common.Hash{}, 0, 0, m.metadataErr
}
return 0, mockRootClaim, 0, 0, nil
return common.Hash{0xaa}, 0, mockRootClaim, 0, 0, nil
}
func (m *mockGameCaller) GetAllClaims(_ context.Context, _ rpcblock.Block) ([]faultTypes.Claim, error) {
......
package extract
import (
"context"
"fmt"
monTypes "github.com/ethereum-optimism/optimism/op-dispute-mon/mon/types"
"github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)
type BlockFetcher interface {
HeaderByHash(ctx context.Context, block common.Hash) (*types.Header, error)
}
type L1HeadBlockNumEnricher struct {
client BlockFetcher
}
func NewL1HeadBlockNumEnricher(client BlockFetcher) *L1HeadBlockNumEnricher {
return &L1HeadBlockNumEnricher{client: client}
}
func (e *L1HeadBlockNumEnricher) Enrich(ctx context.Context, _ rpcblock.Block, _ GameCaller, game *monTypes.EnrichedGameData) error {
header, err := e.client.HeaderByHash(ctx, game.L1Head)
if err != nil {
return fmt.Errorf("failed to retrieve header for L1 head block %v: %w", game.L1Head, err)
}
game.L1HeadNum = header.Number.Uint64()
return nil
}
package extract
import (
"context"
"errors"
"math/big"
"testing"
"github.com/ethereum-optimism/optimism/op-dispute-mon/mon/types"
"github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock"
"github.com/ethereum/go-ethereum/common"
gethTypes "github.com/ethereum/go-ethereum/core/types"
"github.com/stretchr/testify/require"
)
func TestL1HeadEnricher(t *testing.T) {
t.Run("HeaderError", func(t *testing.T) {
client := &stubBlockFetcher{err: errors.New("boom")}
enricher := NewL1HeadBlockNumEnricher(client)
caller := &mockGameCaller{}
game := &types.EnrichedGameData{}
err := enricher.Enrich(context.Background(), rpcblock.Latest, caller, game)
require.ErrorIs(t, err, client.err)
})
t.Run("GetBalanceSuccess", func(t *testing.T) {
client := &stubBlockFetcher{num: 5000}
enricher := NewL1HeadBlockNumEnricher(client)
caller := &mockGameCaller{}
game := &types.EnrichedGameData{}
err := enricher.Enrich(context.Background(), rpcblock.Latest, caller, game)
require.NoError(t, err)
require.Equal(t, client.num, game.L1HeadNum)
})
}
type stubBlockFetcher struct {
num uint64
err error
}
func (s *stubBlockFetcher) HeaderByHash(_ context.Context, _ common.Hash) (*gethTypes.Header, error) {
if s.err != nil {
return nil, s.err
}
return &gethTypes.Header{
Number: new(big.Int).SetUint64(s.num),
}, nil
}
......@@ -20,7 +20,7 @@ var (
)
type OutputValidator interface {
CheckRootAgreement(ctx context.Context, blockNum uint64, root common.Hash) (bool, common.Hash, error)
CheckRootAgreement(ctx context.Context, l1HeadNum uint64, l2BlockNum uint64, root common.Hash) (bool, common.Hash, error)
}
type ForecastMetrics interface {
......@@ -66,7 +66,7 @@ func (f *forecast) recordBatch(batch monTypes.ForecastBatch) {
func (f *forecast) forecastGame(ctx context.Context, game *monTypes.EnrichedGameData, metrics *monTypes.ForecastBatch) error {
// Check the root agreement.
agreement, expected, err := f.validator.CheckRootAgreement(ctx, game.L2BlockNumber, game.RootClaim)
agreement, expected, err := f.validator.CheckRootAgreement(ctx, game.L1HeadNum, game.L2BlockNumber, game.RootClaim)
if err != nil {
return fmt.Errorf("%w: %w", ErrRootAgreement, err)
}
......
......@@ -323,7 +323,7 @@ type stubOutputValidator struct {
err error
}
func (s *stubOutputValidator) CheckRootAgreement(_ context.Context, _ uint64, rootClaim common.Hash) (bool, common.Hash, error) {
func (s *stubOutputValidator) CheckRootAgreement(_ context.Context, _ uint64, _ uint64, rootClaim common.Hash) (bool, common.Hash, error) {
s.calls++
if s.err != nil {
return false, common.Hash{}, s.err
......
......@@ -103,7 +103,7 @@ func (s *Service) initFromConfig(ctx context.Context, cfg *config.Config) error
}
func (s *Service) initOutputValidator() {
s.validator = newOutputValidator(s.metrics, s.rollupClient)
s.validator = newOutputValidator(s.logger, s.metrics, s.rollupClient)
}
func (s *Service) initGameCallerCreator() {
......@@ -118,6 +118,7 @@ func (s *Service) initExtractor() {
s.extractor = extract.NewExtractor(s.logger, s.game.CreateContract, s.factoryContract.GetGamesAtOrAfter,
extract.NewBondEnricher(),
extract.NewBalanceEnricher(),
extract.NewL1HeadBlockNumEnricher(s.l1Client),
)
}
......
......@@ -13,6 +13,8 @@ var ResolvedBondAmount = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 128),
type EnrichedGameData struct {
types.GameMetadata
L1Head common.Hash
L1HeadNum uint64
L2BlockNumber uint64
RootClaim common.Hash
Status types.GameStatus
......
......@@ -7,12 +7,14 @@ import (
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-service/eth"
)
type OutputRollupClient interface {
OutputAtBlock(ctx context.Context, blockNum uint64) (*eth.OutputResponse, error)
SafeHeadAtL1Block(ctx context.Context, blockNum uint64) (*eth.SafeHeadResponse, error)
}
type OutputMetrics interface {
......@@ -20,20 +22,22 @@ type OutputMetrics interface {
}
type outputValidator struct {
log log.Logger
metrics OutputMetrics
client OutputRollupClient
}
func newOutputValidator(metrics OutputMetrics, client OutputRollupClient) *outputValidator {
func newOutputValidator(logger log.Logger, metrics OutputMetrics, client OutputRollupClient) *outputValidator {
return &outputValidator{
log: logger,
metrics: metrics,
client: client,
}
}
// CheckRootAgreement validates the specified root claim against the output at the given block number.
func (o *outputValidator) CheckRootAgreement(ctx context.Context, blockNum uint64, rootClaim common.Hash) (bool, common.Hash, error) {
output, err := o.client.OutputAtBlock(ctx, blockNum)
func (o *outputValidator) CheckRootAgreement(ctx context.Context, l1HeadNum uint64, l2BlockNum uint64, rootClaim common.Hash) (bool, common.Hash, error) {
output, err := o.client.OutputAtBlock(ctx, l2BlockNum)
if err != nil {
// string match as the error comes from the remote server so we can't use Errors.Is sadly.
if strings.Contains(err.Error(), "not found") {
......@@ -44,5 +48,20 @@ func (o *outputValidator) CheckRootAgreement(ctx context.Context, blockNum uint6
}
o.metrics.RecordOutputFetchTime(float64(time.Now().Unix()))
expected := common.Hash(output.OutputRoot)
return rootClaim == expected, expected, nil
rootMatches := rootClaim == expected
if !rootMatches {
return false, expected, nil
}
// If the root matches, also check that l2 block is safe at the L1 head
safeHead, err := o.client.SafeHeadAtL1Block(ctx, l1HeadNum)
if err != nil {
o.log.Warn("Unable to verify proposed block was safe", "l1HeadNum", l1HeadNum, "l2BlockNum", l2BlockNum, "err", err)
// If safe head data isn't available, assume the output root was safe
// Avoids making the dispute mon dependent on safe head db being available
//
return true, expected, nil
}
isSafe := safeHead.SafeHead.Number >= l2BlockNum
return isSafe, expected, nil
}
......@@ -6,7 +6,9 @@ import (
"testing"
"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum-optimism/optimism/op-service/testlog"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
"github.com/stretchr/testify/require"
)
......@@ -19,37 +21,77 @@ func TestDetector_CheckRootAgreement(t *testing.T) {
t.Run("OutputFetchFails", func(t *testing.T) {
validator, rollup, metrics := setupOutputValidatorTest(t)
rollup.err = errors.New("boom")
agree, fetched, err := validator.CheckRootAgreement(context.Background(), 0, mockRootClaim)
require.ErrorIs(t, err, rollup.err)
rollup.outputErr = errors.New("boom")
agree, fetched, err := validator.CheckRootAgreement(context.Background(), 100, 0, mockRootClaim)
require.ErrorIs(t, err, rollup.outputErr)
require.Equal(t, common.Hash{}, fetched)
require.False(t, agree)
require.Zero(t, metrics.fetchTime)
})
t.Run("OutputMismatch", func(t *testing.T) {
t.Run("OutputMismatch_Safe", func(t *testing.T) {
validator, _, metrics := setupOutputValidatorTest(t)
agree, fetched, err := validator.CheckRootAgreement(context.Background(), 0, common.Hash{})
agree, fetched, err := validator.CheckRootAgreement(context.Background(), 100, 0, common.Hash{})
require.NoError(t, err)
require.Equal(t, mockRootClaim, fetched)
require.False(t, agree)
require.NotZero(t, metrics.fetchTime)
})
t.Run("OutputMatches", func(t *testing.T) {
t.Run("OutputMatches_Safe", func(t *testing.T) {
validator, _, metrics := setupOutputValidatorTest(t)
agree, fetched, err := validator.CheckRootAgreement(context.Background(), 0, mockRootClaim)
agree, fetched, err := validator.CheckRootAgreement(context.Background(), 200, 0, mockRootClaim)
require.NoError(t, err)
require.Equal(t, mockRootClaim, fetched)
require.True(t, agree)
require.NotZero(t, metrics.fetchTime)
})
t.Run("OutputMismatch_NotSafe", func(t *testing.T) {
validator, client, metrics := setupOutputValidatorTest(t)
client.safeHeadNum = 99
agree, fetched, err := validator.CheckRootAgreement(context.Background(), 100, 0, common.Hash{})
require.NoError(t, err)
require.Equal(t, mockRootClaim, fetched)
require.False(t, agree)
require.NotZero(t, metrics.fetchTime)
})
t.Run("OutputMatches_SafeHeadError", func(t *testing.T) {
validator, client, metrics := setupOutputValidatorTest(t)
client.safeHeadErr = errors.New("boom")
agree, fetched, err := validator.CheckRootAgreement(context.Background(), 200, 0, mockRootClaim)
require.NoError(t, err)
require.Equal(t, mockRootClaim, fetched)
require.True(t, agree) // Assume safe if we can't retrieve the safe head so monitoring isn't dependent on safe head db
require.NotZero(t, metrics.fetchTime)
})
t.Run("OutputMismatch_SafeHeadError", func(t *testing.T) {
validator, client, metrics := setupOutputValidatorTest(t)
client.safeHeadErr = errors.New("boom")
agree, fetched, err := validator.CheckRootAgreement(context.Background(), 100, 0, common.Hash{})
require.NoError(t, err)
require.Equal(t, mockRootClaim, fetched)
require.False(t, agree) // Not agreed because the root doesn't match
require.NotZero(t, metrics.fetchTime)
})
t.Run("OutputMatches_NotSafe", func(t *testing.T) {
validator, client, metrics := setupOutputValidatorTest(t)
client.safeHeadNum = 99
agree, fetched, err := validator.CheckRootAgreement(context.Background(), 200, 100, mockRootClaim)
require.NoError(t, err)
require.Equal(t, mockRootClaim, fetched)
require.False(t, agree)
require.NotZero(t, metrics.fetchTime)
})
t.Run("OutputNotFound", func(t *testing.T) {
validator, rollup, metrics := setupOutputValidatorTest(t)
// This crazy error is what we actually get back from the API
rollup.err = errors.New("failed to get L2 block ref with sync status: failed to determine L2BlockRef of height 42984924, could not get payload: not found")
agree, fetched, err := validator.CheckRootAgreement(context.Background(), 42984924, mockRootClaim)
rollup.outputErr = errors.New("failed to get L2 block ref with sync status: failed to determine L2BlockRef of height 42984924, could not get payload: not found")
agree, fetched, err := validator.CheckRootAgreement(context.Background(), 100, 42984924, mockRootClaim)
require.NoError(t, err)
require.Equal(t, common.Hash{}, fetched)
require.False(t, agree)
......@@ -58,9 +100,10 @@ func TestDetector_CheckRootAgreement(t *testing.T) {
}
func setupOutputValidatorTest(t *testing.T) (*outputValidator, *stubRollupClient, *stubOutputMetrics) {
client := &stubRollupClient{}
logger := testlog.Logger(t, log.LvlInfo)
client := &stubRollupClient{safeHeadNum: 99999999999}
metrics := &stubOutputMetrics{}
validator := newOutputValidator(metrics, client)
validator := newOutputValidator(logger, metrics, client)
return validator, client, metrics
}
......@@ -73,11 +116,24 @@ func (s *stubOutputMetrics) RecordOutputFetchTime(fetchTime float64) {
}
type stubRollupClient struct {
blockNum uint64
err error
blockNum uint64
outputErr error
safeHeadErr error
safeHeadNum uint64
}
func (s *stubRollupClient) OutputAtBlock(ctx context.Context, blockNum uint64) (*eth.OutputResponse, error) {
s.blockNum = blockNum
return &eth.OutputResponse{OutputRoot: eth.Bytes32(mockRootClaim)}, s.err
return &eth.OutputResponse{OutputRoot: eth.Bytes32(mockRootClaim)}, s.outputErr
}
func (s *stubRollupClient) SafeHeadAtL1Block(_ context.Context, _ uint64) (*eth.SafeHeadResponse, error) {
if s.safeHeadErr != nil {
return nil, s.safeHeadErr
}
return &eth.SafeHeadResponse{
SafeHead: eth.BlockID{
Number: s.safeHeadNum,
},
}, nil
}
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