Commit 2ab81cef authored by Adrian Sutton's avatar Adrian Sutton

Merge branch 'develop' into cleanup-devnet-modified-file

parents 208731a6 8d8597a2
......@@ -1142,7 +1142,7 @@ workflows:
working_directory: proxyd
- indexer-tests
- go-lint-test-build:
name: op-heartbeat tests
name: op-heartbeat-tests
binary_name: op-heartbeat
working_directory: op-heartbeat
- semgrep-scan
......@@ -1224,7 +1224,11 @@ workflows:
target: test-external-geth
- bedrock-go-tests:
requires:
- go-mod-tidy
- cannon-build-test-vectors
- cannon-go-lint-and-test
- check-generated-mocks-op-node
- check-generated-mocks-op-service
- op-batcher-lint
- op-bootnode-lint
- op-bindings-lint
......@@ -1238,6 +1242,7 @@ workflows:
- op-batcher-tests
- op-bindings-tests
- op-chain-ops-tests
- op-heartbeat-tests
- op-node-tests
- op-proposer-tests
- op-challenger-tests
......@@ -1245,6 +1250,7 @@ workflows:
- op-service-tests
- op-e2e-WS-tests
- op-e2e-HTTP-tests
- op-e2e-ext-geth-tests
- docker-build:
name: op-node-docker-build
docker_file: op-node/Dockerfile
......
......@@ -57,6 +57,7 @@ Refer to the Directory Structure section below to understand which packages are
├── <a href="./op-exporter">op-exporter</a>: Prometheus exporter client
├── <a href="./op-heartbeat">op-heartbeat</a>: Heartbeat monitor service
├── <a href="./op-node">op-node</a>: rollup consensus-layer client
├── <a href="./op-preimage">op-preimage</a>: Go bindings for Preimage Oracle
├── <a href="./op-program">op-program</a>: Fault proof program
├── <a href="./op-proposer">op-proposer</a>: L2-Output Submitter, submits proposals to L1
├── <a href="./op-service">op-service</a>: Common codebase utilities
......
......@@ -9,7 +9,6 @@ import (
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/log"
"github.com/urfave/cli/v2"
......@@ -331,12 +330,18 @@ func Run(ctx *cli.Context) error {
}
if proofAt(state) {
preStateHash := crypto.Keccak256Hash(state.EncodeWitness())
preStateHash, err := state.EncodeWitness().StateHash()
if err != nil {
return fmt.Errorf("failed to hash prestate witness: %w", err)
}
witness, err := stepFn(true)
if err != nil {
return fmt.Errorf("failed at proof-gen step %d (PC: %08x): %w", step, state.PC, err)
}
postStateHash := crypto.Keccak256Hash(state.EncodeWitness())
postStateHash, err := state.EncodeWitness().StateHash()
if err != nil {
return fmt.Errorf("failed to hash poststate witness: %w", err)
}
proof := &Proof{
Step: step,
Pre: preStateHash,
......
......@@ -5,7 +5,6 @@ import (
"os"
"github.com/ethereum-optimism/optimism/cannon/mipsevm"
"github.com/ethereum/go-ethereum/crypto"
"github.com/urfave/cli/v2"
)
......@@ -31,7 +30,10 @@ func Witness(ctx *cli.Context) error {
return fmt.Errorf("invalid input state (%v): %w", input, err)
}
witness := state.EncodeWitness()
h := crypto.Keccak256Hash(witness)
h, err := witness.StateHash()
if err != nil {
return fmt.Errorf("failed to compute witness hash: %w", err)
}
if output != "" {
if err := os.WriteFile(output, witness, 0755); err != nil {
return fmt.Errorf("writing output to %v: %w", output, err)
......
......@@ -15,7 +15,6 @@ import (
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core/state"
"github.com/ethereum/go-ethereum/core/vm"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/eth/tracers/logger"
"github.com/stretchr/testify/require"
......@@ -92,7 +91,10 @@ func (m *MIPSEVM) Step(t *testing.T, stepWitness *StepWitness) []byte {
logs := m.evmState.Logs()
require.Equal(t, 1, len(logs), "expecting a log with post-state")
evmPost := logs[0].Data
require.Equal(t, crypto.Keccak256Hash(evmPost), postHash, "logged state must be accurate")
stateHash, err := StateWitness(evmPost).StateHash()
require.NoError(t, err, "state hash could not be computed")
require.Equal(t, stateHash, postHash, "logged state must be accurate")
m.env.StateDB.RevertToSnapshot(snap)
t.Logf("EVM step took %d gas, and returned stateHash %s", startingGas-leftOverGas, postHash)
......
......@@ -2,11 +2,16 @@ package mipsevm
import (
"encoding/binary"
"fmt"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/crypto"
)
// StateWitnessSize is the size of the state witness encoding in bytes.
var StateWitnessSize = 226
type State struct {
Memory *Memory `json:"memory"`
......@@ -37,7 +42,11 @@ type State struct {
LastHint hexutil.Bytes `json:"lastHint,omitempty"`
}
func (s *State) EncodeWitness() []byte {
func (s *State) VMStatus() uint8 {
return vmStatus(s.Exited, s.ExitCode)
}
func (s *State) EncodeWitness() StateWitness {
out := make([]byte, 0)
memRoot := s.Memory.MerkleRoot()
out = append(out, memRoot[:]...)
......@@ -60,3 +69,41 @@ func (s *State) EncodeWitness() []byte {
}
return out
}
type StateWitness []byte
const (
VMStatusValid = 0
VMStatusInvalid = 1
VMStatusPanic = 2
VMStatusUnfinished = 3
)
func (sw StateWitness) StateHash() (common.Hash, error) {
if len(sw) != 226 {
return common.Hash{}, fmt.Errorf("Invalid witness length. Got %d, expected at least 88", len(sw))
}
hash := crypto.Keccak256Hash(sw)
offset := 32*2 + 4*6
exitCode := sw[offset]
exited := sw[offset+1]
status := vmStatus(exited == 1, exitCode)
hash[0] = status
return hash, nil
}
func vmStatus(exited bool, exitCode uint8) uint8 {
if !exited {
return VMStatusUnfinished
}
switch exitCode {
case 0:
return VMStatusValid
case 1:
return VMStatusInvalid
default:
return VMStatusPanic
}
}
......@@ -82,6 +82,53 @@ func TestState(t *testing.T) {
}
}
// Run through all permutations of `exited` / `exitCode` and ensure that the
// correct witness, state hash, and VM Status is produced.
func TestStateHash(t *testing.T) {
cases := []struct {
exited bool
exitCode uint8
}{
{exited: false, exitCode: 0},
{exited: false, exitCode: 1},
{exited: false, exitCode: 2},
{exited: false, exitCode: 3},
{exited: true, exitCode: 0},
{exited: true, exitCode: 1},
{exited: true, exitCode: 2},
{exited: true, exitCode: 3},
}
exitedOffset := 32*2 + 4*6
for _, c := range cases {
state := &State{
Memory: NewMemory(),
Exited: c.exited,
ExitCode: c.exitCode,
}
actualWitness := state.EncodeWitness()
actualStateHash, err := StateWitness(actualWitness).StateHash()
require.NoError(t, err, "Error hashing witness")
require.Equal(t, len(actualWitness), StateWitnessSize, "Incorrect witness size")
expectedWitness := make(StateWitness, 226)
memRoot := state.Memory.MerkleRoot()
copy(expectedWitness[:32], memRoot[:])
expectedWitness[exitedOffset] = c.exitCode
var exited uint8
if c.exited {
exited = 1
}
expectedWitness[exitedOffset+1] = uint8(exited)
require.Equal(t, expectedWitness[:], actualWitness[:], "Incorrect witness")
expectedStateHash := crypto.Keccak256Hash(actualWitness)
expectedStateHash[0] = vmStatus(c.exited, c.exitCode)
require.Equal(t, expectedStateHash, actualStateHash, "Incorrect state hash")
}
}
func TestHello(t *testing.T) {
elfProgram, err := elf.Open("../example/bin/hello.elf")
require.NoError(t, err, "open ELF file")
......
comment:
layout: "reach, diff, flags, files"
behavior: default
require_changes: true # only post the comment if coverage changes
comment: false
ignore:
- "op-e2e"
- "**/*.t.sol"
......
......@@ -6,3 +6,4 @@ The directory layout is divided into the following sub-directories.
- [`postmortems/`](./postmortems/): Timestamped post-mortem documents.
- [`security-reviews`](./security-reviews/): Audit summaries and other security review documents.
- [`fault-proof-alpha`](./fault-proof-alpha): Information on the alpha version of the fault proof system.
## Fault Proofs Alpha
The fault proof alpha is a pre-release version of the OP Stack fault proof system.
This documentation provides an overview of the system and instructions on how to help
test the fault proof system.
The overall design of this system along with the APIs and interfaces it exposes are not
finalized and may change without notice.
### Contents
* Overview
* [Deployment Details](./deployments.md)
* [Manual Usage](./manual.md)
* [Creating Traces with Cannon](./cannon.md)
* [Automation with `op-challenger`](./run-challenger.md)
## Generate Traces with `cannon` and `op-program`
Normally, `op-challenger` handles creating the required traces as part of responding to games. However, for manual
testing it may be useful to manually generate the trace. This can be done by running `cannon` directly.
### Prerequisites
- The cannon pre-state downloaded from [Goerli deployment](./deployments.md#goerli).
- A Goerli L1 node.
- An archive node is not required.
- Public RPC providers can be used, however a significant number of requests will need to be made which may exceed
rate limits for free plans.
- An OP-Goerli L2 archive node with `debug` APIs enabled.
- An archive node is required to ensure world-state pre-images remain available.
- Public RPC providers are generally not usable as they don’t support the `debug_dbGet` RPC method.
### Compilation
To compile the required programs, in the top level of the monorepo run:
```bash
make cannon-prestate
```
This will compile the `cannon` executable to `cannon/bin/cannon` as well as the `op-program` executable used to fetch
pre-image data to `op-program/bin/op-program`.
### Run Cannon
To run cannon to generate a proof use:
```bash
mkdir -p temp/cannon/proofs temp/cannon/snapshots temp/cannon/preimages
./cannon/bin/cannon run \
--pprof.cpu \
--info-at '%10000000' \
--proof-at '=<TRACE_INDEX>' \
--stop-at '=<STOP_INDEX>' \
--proof-fmt 'temp/cannon/proofs/%d.json' \
--snapshot-at '%1000000000' \
--snapshot-fmt 'temp/cannon/snapshots/%d.json.gz' \
--input <PRESTATE> \
--output temp/cannon/stop-state.json \
-- \
./op-program/bin/op-program \
--network goerli \
--l1 <L1_URL> \
--l2 <L2_URL> \
--l1.head <L1_HEAD> \
--l2.claim <L2_CLAIM> \
--l2.head <L2_HEAD> \
--l2.blocknumber <L2_BLOCK_NUMBER> \
--datadir temp/cannon/preimages \
--log.format terminal \
--server
```
The placeholders are:
- `<TRACE_INDEX>` the index in the trace to generate a proof for
- `<STOP_INDEX>` the index to stop execution at. Typically this is one instruction after `<TRACE_INDEX>` to stop as soon
as the required proof has been generated.
- `<PRESTATE>` the prestate.json downloaded above. Note that this needs to precisely match the prestate used on-chain so
must be the downloaded version and not a version built locally.
- `<L1_URL>` the Goerli L1 JSON RPC endpoint
- `<L2_URL>` the OP-Goerli L2 archive node JSON RPC endpoint
- `<L1_HEAD>` the hash of the L1 head block used for the dispute game
- `<L2_CLAIM>` the output root immediately prior to the disputed root in the L2 output oracle
- `<L2_HEAD>` the hash of the L2 block that `<L2_CLAIM>`is from
- `<L2_BLOCK_NUMBER>` the block number that `<L2_CLAIM>` is from
The generated proof will be stored in the `temp/cannon/proofs/` directory. The hash to use as the claim value is
the `post` field of the generated proof which provides the hash of the cannon state witness after execution of the step.
Since cannon can be very slow to execute, the above command uses the `--snapshot-at` option to generate a snapshot of
the cannon state every 1000000000 instructions. Once generated, these snapshots can be used as the `--input` to begin
execution at that step rather than from the very beginning. Generated snapshots are stored in
the `temp/cannon/snapshots` directory.
See `./cannon/bin/cannon --help` for further information on the options available.
### Trace Extension
Fault dispute games always use a trace with a fixed length of `2 ^ MAX_GAME_DEPTH`. The trace generated by `cannon`
stops when the client program exits, so this trace must be extended by repeating the hash of the final state in the
actual trace for all remaining steps. Cannon does not perform this trace extension automatically.
If cannon stops execution before the trace index you requested a proof at, it simply will not generate a proof. When it
stops executing, it will write its final state to `temp/cannon/stop-state.json` (controlled by the `--output` option).
The `step` field of this state contains the last step cannon executed. Once the final step is known, rerun cannon to
generate the proof at that final step and use the `post` hash as the claim value for all later trace indices.
## Fault Proof Alpha Deployment Information
### Goerli
Information on the fault proofs alpha deployment to Goerli is not yet available.
### Local Devnet
The local devnet includes a deployment of the fault proof alpha. To start the devnet, in the top level of this repo,
run:
```bash
make devnet-up
```
| Input | Value |
|----------------------|-------------------------------------------------------------|
| Dispute Game Factory | Run `jq -r .DisputeGameFactoryProxy .devnet/addresses.json` |
| Absolute Prestate | `op-program/bin/prestate.json` |
| Max Depth | 30 |
| Max Game Duration | 1200 (20 minutes) |
See the [op-challenger README](../../op-challenger#running-with-cannon-on-local-devnet) for information on
running `op-challenger` against the local devnet.
## Manual Fault Proof Interactions
### Creating a Game
The process of disputing an output root starts by creating a new dispute game. There are conceptually three key inputs
required for a dispute game:
- The output root being disputed
- The agreed output root the derivation process will start from
- The L1 head block that defines the canonical L1 chain containing all required batch data to perform the derivation
The creator of the game selects the output root to dispute. It is identified by its L2 block number which can be used to
look up the full details in the L2 output oracle.
The agreed output root is defined as the output root immediately prior to the disputed output root in the L2 output
oracle. Therefore, a dispute game should only be created for the first invalid output root. If it is successfully
disputed, all output roots after it are considered invalid by inference.
The L1 head block can be any L1 block where the disputed output root is present in the L2 output oracle. Proposers
should therefore ensure that all batch data has been submitted to L1 before submitting a proposal. The L1 head block is
recorded in the `BlockOracle` and then referenced by its block number.
Creating a game requires two separate transactions. First the L1 head block is recorded in the `BlockOracle` by calling
its `checkpoint` function. This records the parent of the block the transaction is included in. The `BlockOracle` emits
a log `Checkpoint(blockNumber, blockHash, childTimestamp)`.
Now, using the L1 head along with output root info available in the L2 output oracle, cannon can be executed to
determine the root claim to use when creating the game. In simple cases, where the claim is expected to be incorrect, an
arbitrary hash can be used for claim values. For more advanced cases [cannon can be used](./cannon.md) to generate a
trace, including the claim values to use at specific steps. Note that it is not valid to create a game that disputes an
output root, using the final hash from a trace that confirms the output root is valid. To dispute an output root
successfully, the trace must resolve that the disputed output root is invalid.
The game can then be created by calling the `create` method on the `DisputeGameFactory` contract. This requires three
parameters:
- `gameType` - a `uint8` representing the type of game to create. For fault dispute games using cannon and op-program
traces, the game type is 0.
- `rootClaim` - a `bytes32` hash of the final state from the trace.
- `extraData` - arbitrary bytes which are used as the initial inputs for the game. For fault dispute games using cannon
and op-program traces, this is the abi encoding of `(uint256(l2_block_number), uint256(l1_checkpoint))`.
- `l2_block_number` is the L2 block number from the output root being disputed
- `l1_checkpoint` is the L1 block number recorded by the `BlockOracle` checkpoint
This emits a log event `DisputeGameCreated(gameAddress, gameType, rootClaim)` where `gameAddress` is the address of the
newly created dispute game.
The helper script, [create_game.sh](../../op-challenger#create_gamesh) can be used to easily create a new dispute
game and also acts as an example of using `cast` to manually create a game.
### Performing Moves
The dispute game progresses by actors countering existing claims via either the `attack` or `defend` methods in
the `FaultDisputeGame` contract. Note that only `attack` can be used to counter the root claim. In both cases, there are
two inputs required:
- `parentIndex` - the index in the claims array of the parent claim that is being countered.
- `claim` - a `bytes32` hash of the state at the trace index corresponding to the new claim’s position.
The helper script, [move.sh](../../op-challenger#movesh), can be used to easily perform moves and also
acts as an example of using `cast` to manually call `attack` and `defend`.
### Performing Steps
Attacking or defending are teh only available actions before the maximum depth of the game is reached. To counter claims
at the maximum depth, a step must be performed instead. Calling the `step` method in the `FaultDisputeGame` contract
counters a claim at the maximum depth by running a single step of the cannon VM on chain. The `step` method will revert
unless the cannon execution confirms the claim being countered is invalid. Note, if an actor's clock runs out at any
point, the game can be [resolved](#resolving-a-game).
The inputs for step are:
- `claimIndex` - the index in the claims array of the claim that is being countered
- `isAttack` - Similar to regular moves, steps can either be attacking or defending
- `stateData` - the full cannon state witness to use as the starting state for execution
- `proof` - the additional proof data for the state witness required by cannon to perform the step
When a step is attacking, the caller is asserting that the claim at `claimIndex` is incorrect, and the claim for
the previous trace index (made at a previous level in the game) was correct. The `stateData` must be the pre-image for
the agreed correct hash at the previous trace index. The call to `step` will revert if the post-state from cannon
matches the claim at `claimIndex` since the on-chain execution has proven the claim correct and it should not be
countered.
When a step is defending, the caller is asserting that the claim at `claimIndex` is correct, and the claim for
the next trace index (made at a previous level in the game) is incorrect. The `stateData` must be the pre-image for the
hash in the claim at `claimIndex`.
The `step` function will revert with `ValidStep()` if the cannon execution proves that the claim attempting to be
countered is correct. As a result, claims at the maximum game depth can only be countered by a valid execution of the
single instruction in cannon running on-chain.
#### Populating the Pre-image Oracle
When the instruction to be executed as part of a `step` call reads from some pre-image data, that data must be loaded
into the pre-image oracle prior to calling `step`.
For [local pre-image keys](../../specs/fault-proof.md#type-1-local-key), the pre-image must be populated via
the `FaultDisputeGame` contract by calling the `addLocalData` function.
For [global keccak256 keys](../../specs/fault-proof.md#type-2-global-keccak256-key), the data should be added directly
to the pre-image oracle contract.
### Resolving a Game
The final action required for a game is to resolve it by calling the `resolve` method in the `FaultDisputeGame`
contract. This can only be done once the clock of the left-most uncontested claim’s parent has expired. A game can only
be resolved once.
There are no inputs required for the `resolve` method. When successful, a log event is emitted with the game’s final
status.
The helper script, [resolve.sh](../../op-challenger#resolvesh), can be used to easily resolve a game and also acts as an
example of using `cast` to manually call `resolve` and understand the result.
## Running op-challenger
`op-challenger` is a program that implements the honest actor algorithm to automatically “play” the dispute games.
### Prerequisites
- The cannon pre-state downloaded from [Goerli deployment](./deployments.md#goerli).
- An account on the Goerli testnet with funds available. The amount of GöETH required depends on the number of claims
the challenger needs to post, but 0.01 ETH should be plenty to start.
- A Goerli L1 node.
- An archive node is not required.
- Public RPC providers can be used, however a significant number of requests will need to be made which may exceed
rate limits for free plans.
- An OP-Goerli L2 archive node with `debug` APIs enabled.
- An archive node is required to ensure world-state pre-images remain available.
- Public RPC providers are generally not usable as they don’t support the `debug_dbGet` RPC method.
- Approximately 3.5Gb of disk space for each game being played.
### Starting op-challenger
When executing `op-challenger`, there are a few placeholders that need to be set to concreate values:
- `<L1_URL>` the Goerli L1 JSON RPC endpoint
- `<DISPUTE_GAME_FACTORY_ADDRESS>` the address of the dispute game factory contract (see
the [Goerli deployment details](./deployments.md#goerli))
- `<PRESTATE>` the prestate.json downloaded above. Note that this needs to precisely match the prestate used on-chain so
must be the downloaded version and not a version built locally (see the [Goerli deployment details](./deployments.md#goerli))
- `<L2_URL>` the OP-Goerli L2 archive node JSON RPC endpoint
- `<PRIVATE_KEY>` the private key for a funded Goerli account. For other ways to specify the account to use
see `./op-challenger/bin/op-challenger --help`
From inside the monorepo directory, run the challenger with these placeholders set.
```bash
# Build the required components
make op-challenger op-program cannon
# Run op-challenger
./op-challenger/bin/op-challenger \
--trace-type cannon \
--l1-eth-rpc <L1_URL> \
--game-factory-address <DISPUTE_GAME_FACTORY_ADDRESS> \
--agree-with-proposed-output=true \
--datadir temp/challenger-goerli \
--cannon-network goerli \
--cannon-bin ./cannon/bin/cannon \
--cannon-server ./op-program/bin/op-program \
--cannon-prestate <PRESTATE> \
--cannon-l2 <L2_URL> \
--private-key <PRIVATE_KEY>
```
### Restricting Games to Play
By default `op-challenger` will generate traces and respond to any game created by the dispute game factory contract. On
a public testnet like Goerli, that could be a large number of games, requiring significant CPU and disk resources. To
avoid this, `op-challenger` supports specifying an allowlist of games for it to respond to with the `--game-allowlist`
option.
```bash
./op-challenger/bin/op-challenger \
... \
--game-allowlist <GAME_ADDR> <GAME_ADDR> <GAME_ADDR>...
```
......@@ -2,18 +2,21 @@ FROM golang:1.20.7-alpine3.18 as builder
RUN apk --no-cache add make jq bash git alpine-sdk
COPY ./go.mod /app/go.mod
COPY ./go.sum /app/go.sum
WORKDIR /app
RUN go mod download
COPY ./endpoint-monitor /app/endpoint-monitor
COPY ./op-service /app/op-service
COPY ./op-node /app/op-node
COPY ./go.mod /app/go.mod
COPY ./go.sum /app/go.sum
COPY ./.git /app/.git
WORKDIR /app/endpoint-monitor
RUN go mod download
RUN make build
FROM alpine:3.18
......
......@@ -2,20 +2,21 @@ FROM --platform=$BUILDPLATFORM golang:1.20.7-alpine3.18 as builder
RUN apk add --no-cache make gcc musl-dev linux-headers git jq bash
COPY ./go.mod /app/go.mod
COPY ./go.sum /app/go.sum
WORKDIR /app
RUN go mod download
# build indexer with the shared go.mod & go.sum files
COPY ./indexer /app/indexer
COPY ./op-bindings /app/op-bindings
COPY ./op-service /app/op-service
COPY ./op-node /app/op-node
COPY ./go.mod /app/go.mod
COPY ./go.sum /app/go.sum
COPY ./.git /app/.git
WORKDIR /app/indexer
RUN go mod download
RUN make indexer
FROM alpine:3.18
......
......@@ -33,3 +33,28 @@ TODO add indexer to the optimism devnet compose file (previously removed for bre
`docker-compose.dev.yml` is git ignored. Fill in your own docker-compose file here.
## Architecture
![Architectural Diagram](./assets/architecture.png)
The indexer application supports two separate services for collective operation:
**Indexer API** - Provides a lightweight API service that supports paginated lookups for bridge events.
**Indexer Service** - A polling based service that constantly reads and persists OP Stack chain data (i.e, block meta, system contract events, synchronized bridge events) from a L1 and L2 chain.
### Indexer API
TBD
### Indexer Service
![Service Component Diagram](./assets/indexer-service.png)
The indexer service is responsible for polling and processing real-time batches of L1 and L2 chain data. The indexer service is currently composed of the following key components:
- **Poller Routines** - Individually polls the L1/L2 chain for new blocks and OP Stack system contract events.
- **Insertion Routines** - Awaits new batches from the poller routines and inserts them into the database upon retrieval.
- **Bridge Routine** - Polls the database directly for new L1 blocks and bridge events. Upon retrieval, the bridge routine will:
* Process and persist new bridge events
* Synchronize L1 proven/finalized withdrawals with their L2 initialization counterparts
### Database
The indexer service currently supports a Postgres database for storing L1/L2 OP Stack chain data. The most up-to-date database schemas can be found in the `./migrations` directory.
**NOTE:** The indexer service implementation currently does not natively support database migration. Because of this a database must be manually updated to ensure forward compatibility with the latest indexer service implementation.
\ No newline at end of file
......@@ -4,13 +4,18 @@ import (
"context"
"fmt"
"net/http"
"runtime/debug"
"sync"
"github.com/ethereum-optimism/optimism/indexer/api/routes"
"github.com/ethereum-optimism/optimism/indexer/config"
"github.com/ethereum-optimism/optimism/indexer/database"
"github.com/ethereum-optimism/optimism/op-service/httputil"
"github.com/ethereum-optimism/optimism/op-service/metrics"
"github.com/ethereum/go-ethereum/log"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus"
)
const ethereumAddressRegex = `^0x[a-fA-F0-9]{40}$`
......@@ -18,28 +23,95 @@ const ethereumAddressRegex = `^0x[a-fA-F0-9]{40}$`
type Api struct {
log log.Logger
Router *chi.Mux
serverConfig config.ServerConfig
metricsConfig config.ServerConfig
metricsRegistry *prometheus.Registry
}
func NewApi(logger log.Logger, bv database.BridgeTransfersView) *Api {
r := chi.NewRouter()
h := routes.NewRoutes(logger, bv, r)
const (
MetricsNamespace = "op_indexer"
)
func chiMetricsMiddleware(rec metrics.HTTPRecorder) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return metrics.NewHTTPRecordingMiddleware(rec, next)
}
}
func NewApi(logger log.Logger, bv database.BridgeTransfersView, serverConfig config.ServerConfig, metricsConfig config.ServerConfig) *Api {
apiRouter := chi.NewRouter()
h := routes.NewRoutes(logger, bv, apiRouter)
mr := metrics.NewRegistry()
promRecorder := metrics.NewPromHTTPRecorder(mr, MetricsNamespace)
apiRouter.Use(chiMetricsMiddleware(promRecorder))
apiRouter.Use(middleware.Recoverer)
apiRouter.Use(middleware.Heartbeat("/healthz"))
r.Use(middleware.Heartbeat("/healthz"))
apiRouter.Get(fmt.Sprintf("/api/v0/deposits/{address:%s}", ethereumAddressRegex), h.L1DepositsHandler)
apiRouter.Get(fmt.Sprintf("/api/v0/withdrawals/{address:%s}", ethereumAddressRegex), h.L2WithdrawalsHandler)
r.Get(fmt.Sprintf("/api/v0/deposits/{address:%s}", ethereumAddressRegex), h.L1DepositsHandler)
r.Get(fmt.Sprintf("/api/v0/withdrawals/{address:%s}", ethereumAddressRegex), h.L2WithdrawalsHandler)
return &Api{log: logger, Router: r}
return &Api{log: logger, Router: apiRouter, metricsRegistry: mr, serverConfig: serverConfig, metricsConfig: metricsConfig}
}
func (a *Api) Listen(ctx context.Context, port int) error {
a.log.Info("api server listening...", "port", port)
server := http.Server{Addr: fmt.Sprintf(":%d", port), Handler: a.Router}
func (a *Api) Start(ctx context.Context) error {
var wg sync.WaitGroup
errCh := make(chan error, 2)
processCtx, processCancel := context.WithCancel(ctx)
runProcess := func(start func(ctx context.Context) error) {
wg.Add(1)
go func() {
defer func() {
if err := recover(); err != nil {
a.log.Error("halting api on panic", "err", err)
debug.PrintStack()
errCh <- fmt.Errorf("panic: %v", err)
}
processCancel()
wg.Done()
}()
errCh <- start(processCtx)
}()
}
runProcess(a.startServer)
runProcess(a.startMetricsServer)
wg.Wait()
err := <-errCh
if err != nil {
a.log.Error("api stopped", "err", err)
} else {
a.log.Info("api stopped")
}
return err
}
func (a *Api) startServer(ctx context.Context) error {
a.log.Info("api server listening...", "port", a.serverConfig.Port)
server := http.Server{Addr: fmt.Sprintf(":%d", a.serverConfig.Port), Handler: a.Router}
err := httputil.ListenAndServeContext(ctx, &server)
if err != nil {
a.log.Error("api server stopped", "err", err)
} else {
a.log.Info("api server stopped")
}
return err
}
func (a *Api) startMetricsServer(ctx context.Context) error {
a.log.Info("starting metrics server...", "port", a.metricsConfig.Port)
err := metrics.ListenAndServe(ctx, a.metricsRegistry, a.metricsConfig.Host, a.metricsConfig.Port)
if err != nil {
a.log.Error("metrics server stopped", "err", err)
} else {
a.log.Info("metrics server stopped")
}
return err
}
......@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"testing"
"github.com/ethereum-optimism/optimism/indexer/config"
"github.com/ethereum-optimism/optimism/indexer/database"
"github.com/ethereum-optimism/optimism/op-node/testlog"
"github.com/ethereum/go-ethereum/common"
......@@ -18,6 +19,15 @@ type MockBridgeTransfersView struct{}
var mockAddress = "0x4204204204204204204204204204204204204204"
var apiConfig = config.ServerConfig{
Host: "localhost",
Port: 8080,
}
var metricsConfig = config.ServerConfig{
Host: "localhost",
Port: 7300,
}
var (
deposit = database.L1BridgeDeposit{
TransactionSourceHash: common.HexToHash("abc"),
......@@ -77,7 +87,7 @@ func (mbv *MockBridgeTransfersView) L2BridgeWithdrawalsByAddress(address common.
}
func TestHealthz(t *testing.T) {
logger := testlog.Logger(t, log.LvlInfo)
api := NewApi(logger, &MockBridgeTransfersView{})
api := NewApi(logger, &MockBridgeTransfersView{}, apiConfig, metricsConfig)
request, err := http.NewRequest("GET", "/healthz", nil)
assert.Nil(t, err)
......@@ -89,7 +99,7 @@ func TestHealthz(t *testing.T) {
func TestL1BridgeDepositsHandler(t *testing.T) {
logger := testlog.Logger(t, log.LvlInfo)
api := NewApi(logger, &MockBridgeTransfersView{})
api := NewApi(logger, &MockBridgeTransfersView{}, apiConfig, metricsConfig)
request, err := http.NewRequest("GET", fmt.Sprintf("/api/v0/deposits/%s", mockAddress), nil)
assert.Nil(t, err)
......@@ -101,7 +111,7 @@ func TestL1BridgeDepositsHandler(t *testing.T) {
func TestL2BridgeWithdrawalsByAddressHandler(t *testing.T) {
logger := testlog.Logger(t, log.LvlInfo)
api := NewApi(logger, &MockBridgeTransfersView{})
api := NewApi(logger, &MockBridgeTransfersView{}, apiConfig, metricsConfig)
request, err := http.NewRequest("GET", fmt.Sprintf("/api/v0/withdrawals/%s", mockAddress), nil)
assert.Nil(t, err)
......
......@@ -38,7 +38,7 @@ func runIndexer(ctx *cli.Context) error {
}
defer db.Close()
indexer, err := indexer.NewIndexer(log, db, cfg.Chain, cfg.RPCs, cfg.Metrics)
indexer, err := indexer.NewIndexer(log, db, cfg.Chain, cfg.RPCs, cfg.HTTPServer, cfg.MetricsServer)
if err != nil {
log.Error("failed to create indexer", "err", err)
return err
......@@ -62,8 +62,8 @@ func runApi(ctx *cli.Context) error {
}
defer db.Close()
api := api.NewApi(log, db.BridgeTransfers)
return api.Listen(ctx.Context, cfg.API.Port)
api := api.NewApi(log, db.BridgeTransfers, cfg.HTTPServer, cfg.MetricsServer)
return api.Start(ctx.Context)
}
func runAll(ctx *cli.Context) error {
......
......@@ -2,7 +2,6 @@ package config
import (
"fmt"
"math/big"
"os"
"reflect"
......@@ -24,8 +23,8 @@ type Config struct {
Chain ChainConfig `toml:"chain"`
RPCs RPCsConfig `toml:"rpcs"`
DB DBConfig `toml:"db"`
API APIConfig `toml:"api"`
Metrics MetricsConfig `toml:"metrics"`
HTTPServer ServerConfig `toml:"http"`
MetricsServer ServerConfig `toml:"metrics"`
}
// fetch this via onchain config from RPCsConfig and remove from config in future
......@@ -66,10 +65,15 @@ func (c *L1Contracts) AsSlice() ([]common.Address, error) {
type ChainConfig struct {
// Configure known chains with the l2 chain id
Preset int
L1Contracts L1Contracts `toml:"l1-contracts"`
// L1StartingHeight is the block height to start indexing from
L1StartingHeight uint `toml:"l1-starting-height"`
// These configuration options will be removed once
// native reorg handling is implemented
L1ConfirmationDepth uint `toml:"l1-confirmation-depth"`
L2ConfirmationDepth uint `toml:"l2-confirmation-depth"`
L1PollingInterval uint `toml:"l1-polling-interval"`
L2PollingInterval uint `toml:"l2-polling-interval"`
......@@ -77,11 +81,6 @@ type ChainConfig struct {
L2HeaderBufferSize uint `toml:"l2-header-buffer-size"`
}
// L1StartHeight returns the block height to start indexing from
func (cc *ChainConfig) L1StartHeight() *big.Int {
return big.NewInt(int64(cc.L1StartingHeight))
}
// RPCsConfig configures the RPC urls
type RPCsConfig struct {
L1RPC string `toml:"l1-rpc"`
......@@ -97,14 +96,8 @@ type DBConfig struct {
Password string `toml:"password"`
}
// APIConfig configures the API server
type APIConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
}
// MetricsConfig configures the metrics server
type MetricsConfig struct {
// Configures the a server
type ServerConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
}
......
......@@ -33,7 +33,7 @@ func TestLoadConfig(t *testing.T) {
password = "postgres"
name = "indexer"
[api]
[http]
host = "127.0.0.1"
port = 8080
......@@ -65,10 +65,10 @@ func TestLoadConfig(t *testing.T) {
require.Equal(t, conf.DB.User, "postgres")
require.Equal(t, conf.DB.Password, "postgres")
require.Equal(t, conf.DB.Name, "indexer")
require.Equal(t, conf.API.Host, "127.0.0.1")
require.Equal(t, conf.API.Port, 8080)
require.Equal(t, conf.Metrics.Host, "127.0.0.1")
require.Equal(t, conf.Metrics.Port, 7300)
require.Equal(t, conf.HTTPServer.Host, "127.0.0.1")
require.Equal(t, conf.HTTPServer.Port, 8080)
require.Equal(t, conf.MetricsServer.Host, "127.0.0.1")
require.Equal(t, conf.MetricsServer.Port, 7300)
}
func TestLoadConfig_WithoutPreset(t *testing.T) {
......
......@@ -233,7 +233,7 @@ func (db *blocksDB) LatestEpoch() (*Epoch, error) {
// L2 for a faster query. Per the protocol, the L2 block that starts a new epoch
// will have a matching timestamp with the L1 origin.
query := db.gorm.Table("l1_block_headers").Order("l1_block_headers.timestamp DESC")
query = query.Joins("INNER JOIN l2_block_headers ON l1_block_headers.timestamp = l2_block_headers.timestamp")
query = query.Joins("INNER JOIN l2_block_headers ON l2_block_headers.timestamp = l1_block_headers.timestamp")
query = query.Select("*")
var epoch Epoch
......
......@@ -47,7 +47,10 @@ type L2TransactionWithdrawal struct {
type BridgeTransactionsView interface {
L1TransactionDeposit(common.Hash) (*L1TransactionDeposit, error)
L1LatestBlockHeader() (*L1BlockHeader, error)
L2TransactionWithdrawal(common.Hash) (*L2TransactionWithdrawal, error)
L2LatestBlockHeader() (*L2BlockHeader, error)
}
type BridgeTransactionsDB interface {
......@@ -94,6 +97,37 @@ func (db *bridgeTransactionsDB) L1TransactionDeposit(sourceHash common.Hash) (*L
return &deposit, nil
}
func (db *bridgeTransactionsDB) L1LatestBlockHeader() (*L1BlockHeader, error) {
// Markers for an indexed bridge event
// L1: Latest Transaction Deposit, Latest Proven/Finalized Withdrawal
l1DepositQuery := db.gorm.Table("l1_transaction_deposits").Order("l1_transaction_deposits.timestamp DESC").Limit(1)
l1DepositQuery = l1DepositQuery.Joins("INNER JOIN l1_contract_events ON l1_contract_events.guid = l1_transaction_deposits.initiated_l1_event_guid")
l1DepositQuery = l1DepositQuery.Select("l1_contract_events.*")
l1ProvenQuery := db.gorm.Table("l2_transaction_withdrawals")
l1ProvenQuery = l1ProvenQuery.Joins("INNER JOIN l1_contract_events ON l1_contract_events.guid = l2_transaction_withdrawals.proven_l1_event_guid")
l1ProvenQuery = l1ProvenQuery.Order("l1_contract_events.timestamp DESC").Select("l1_contract_events.*").Limit(1)
l1FinalizedQuery := db.gorm.Table("l2_transaction_withdrawals")
l1FinalizedQuery = l1FinalizedQuery.Joins("INNER JOIN l1_contract_events ON l1_contract_events.guid = l2_transaction_withdrawals.proven_l1_event_guid")
l1FinalizedQuery = l1FinalizedQuery.Order("l1_contract_events.timestamp DESC").Select("l1_contract_events.*").Limit(1)
l1Query := db.gorm.Table("((?) UNION (?) UNION (?)) AS latest_bridge_events", l1DepositQuery.Limit(1), l1ProvenQuery, l1FinalizedQuery)
l1Query = l1Query.Joins("INNER JOIN l1_block_headers ON l1_block_headers.hash = latest_bridge_events.block_hash")
l1Query = l1Query.Order("l1_block_headers.number DESC").Select("l1_block_headers.*")
var l1Header L1BlockHeader
result := l1Query.Take(&l1Header)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, result.Error
}
return &l1Header, nil
}
/**
* Transactions withdrawn from L2
*/
......@@ -149,3 +183,25 @@ func (db *bridgeTransactionsDB) MarkL2TransactionWithdrawalFinalizedEvent(withdr
result := db.gorm.Save(&withdrawal)
return result.Error
}
func (db *bridgeTransactionsDB) L2LatestBlockHeader() (*L2BlockHeader, error) {
// L2: Inclusion of the latest deposit
l1DepositQuery := db.gorm.Table("l1_transaction_deposits").Order("l1_transaction_deposits.timestamp DESC")
l1DepositQuery = l1DepositQuery.Joins("INNER JOIN l1_contract_events ON l1_contract_events.guid = l1_transaction_deposits.initiated_l1_event_guid")
l1DepositQuery = l1DepositQuery.Select("l1_contract_events.*")
l2Query := db.gorm.Table("(?) AS l1_deposit_events", l1DepositQuery)
l2Query = l2Query.Joins("INNER JOIN l2_block_headers ON l2_block_headers.timestamp = l1_deposit_events.timestamp")
l2Query = l2Query.Order("l2_block_headers.timestamp DESC").Select("l2_block_headers.*")
var l2Header L2BlockHeader
result := l2Query.Take(&l2Header)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, result.Error
}
return &l2Header, nil
}
......@@ -2,6 +2,7 @@ package database
import (
"errors"
"fmt"
"gorm.io/gorm"
......@@ -133,21 +134,31 @@ func (db *bridgeTransfersDB) L1BridgeDepositsByAddress(address common.Address, c
limit = defaultLimit
}
cursorClause := ""
if cursor != "" {
sourceHash := common.HexToHash(cursor)
txDeposit := new(L1TransactionDeposit)
result := db.gorm.Model(&L1TransactionDeposit{}).Where(&L1TransactionDeposit{SourceHash: sourceHash}).Take(txDeposit)
if result.Error != nil || errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("unable to find transaction with supplied cursor source hash %s: %w", sourceHash, result.Error)
}
cursorClause = fmt.Sprintf("l1_transaction_deposits.timestamp <= %d", txDeposit.Tx.Timestamp)
}
// TODO join with l1_bridged_tokens and l2_bridged_tokens
ethAddressString := predeploys.LegacyERC20ETHAddr.String()
// Coalesce l1 transaction deposits that are simply ETH sends
ethTransactionDeposits := db.gorm.Model(&L1TransactionDeposit{})
ethTransactionDeposits = ethTransactionDeposits.Where(Transaction{FromAddress: address}).Where(`data = '0x' AND amount > 0`)
ethTransactionDeposits = ethTransactionDeposits.Where(Transaction{FromAddress: address}).Where("data = '0x' AND amount > 0")
ethTransactionDeposits = ethTransactionDeposits.Joins("INNER JOIN l1_contract_events ON l1_contract_events.guid = initiated_l1_event_guid")
ethTransactionDeposits = ethTransactionDeposits.Select(`
from_address, to_address, amount, data, source_hash AS transaction_source_hash,
l2_transaction_hash, l1_contract_events.transaction_hash AS l1_transaction_hash,
l1_transaction_deposits.timestamp, NULL AS cross_domain_message_hash, ? AS local_token_address, ? AS remote_token_address`, ethAddressString, ethAddressString)
if cursor != "" {
// Probably need to fix this and compare timestamps
ethTransactionDeposits = ethTransactionDeposits.Where("source_hash < ?", cursor)
ethTransactionDeposits = ethTransactionDeposits.Order("timestamp DESC").Limit(limit + 1)
if cursorClause != "" {
ethTransactionDeposits = ethTransactionDeposits.Where(cursorClause)
}
depositsQuery := db.gorm.Model(&L1BridgeDeposit{})
......@@ -157,10 +168,9 @@ l1_transaction_deposits.timestamp, NULL AS cross_domain_message_hash, ? AS local
l1_bridge_deposits.from_address, l1_bridge_deposits.to_address, l1_bridge_deposits.amount, l1_bridge_deposits.data, transaction_source_hash,
l2_transaction_hash, l1_contract_events.transaction_hash AS l1_transaction_hash,
l1_bridge_deposits.timestamp, cross_domain_message_hash, local_token_address, remote_token_address`)
if cursor != "" {
// Probably need to fix this and compare timestamps
depositsQuery = depositsQuery.Where("source_hash < ?", cursor)
depositsQuery = depositsQuery.Order("timestamp DESC").Limit(limit + 1)
if cursorClause != "" {
depositsQuery = depositsQuery.Where(cursorClause)
}
query := db.gorm.Table("(?) AS deposits", depositsQuery)
......@@ -179,16 +189,11 @@ l1_bridge_deposits.timestamp, cross_domain_message_hash, local_token_address, re
hasNextPage := false
if len(deposits) > limit {
hasNextPage = true
nextCursor = deposits[limit].L1BridgeDeposit.TransactionSourceHash.String()
deposits = deposits[:limit]
nextCursor = deposits[limit].L1TransactionHash.String()
}
response := &L1BridgeDepositsResponse{
Deposits: deposits,
Cursor: nextCursor,
HasNextPage: hasNextPage,
}
response := &L1BridgeDepositsResponse{Deposits: deposits, Cursor: nextCursor, HasNextPage: hasNextPage}
return response, nil
}
......@@ -242,6 +247,17 @@ func (db *bridgeTransfersDB) L2BridgeWithdrawalsByAddress(address common.Address
limit = defaultLimit
}
cursorClause := ""
if cursor != "" {
withdrawalHash := common.HexToHash(cursor)
var txWithdrawal L2TransactionWithdrawal
result := db.gorm.Model(&L2TransactionWithdrawal{}).Where(&L2TransactionWithdrawal{WithdrawalHash: withdrawalHash}).Take(&txWithdrawal)
if result.Error != nil || errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("unable to find transaction with supplied cursor withdrawal hash %s: %w", withdrawalHash, result.Error)
}
cursorClause = fmt.Sprintf("l2_transaction_withdrawals.timestamp <= %d", txWithdrawal.Tx.Timestamp)
}
// TODO join with l1_bridged_tokens and l2_bridged_tokens
ethAddressString := predeploys.LegacyERC20ETHAddr.String()
......@@ -255,10 +271,9 @@ func (db *bridgeTransfersDB) L2BridgeWithdrawalsByAddress(address common.Address
from_address, to_address, amount, data, withdrawal_hash AS transaction_withdrawal_hash,
l2_contract_events.transaction_hash AS l2_transaction_hash, proven_l1_events.transaction_hash AS proven_l1_transaction_hash, finalized_l1_events.transaction_hash AS finalized_l1_transaction_hash,
l2_transaction_withdrawals.timestamp, NULL AS cross_domain_message_hash, ? AS local_token_address, ? AS remote_token_address`, ethAddressString, ethAddressString)
if cursor != "" {
// Probably need to fix this and compare timestamps
ethTransactionWithdrawals = ethTransactionWithdrawals.Where("withdrawal_hash < ?", cursor)
ethTransactionWithdrawals = ethTransactionWithdrawals.Order("timestamp DESC").Limit(limit + 1)
if cursorClause != "" {
ethTransactionWithdrawals = ethTransactionWithdrawals.Where(cursorClause)
}
withdrawalsQuery := db.gorm.Model(&L2BridgeWithdrawal{})
......@@ -270,17 +285,16 @@ l2_transaction_withdrawals.timestamp, NULL AS cross_domain_message_hash, ? AS lo
l2_bridge_withdrawals.from_address, l2_bridge_withdrawals.to_address, l2_bridge_withdrawals.amount, l2_bridge_withdrawals.data, transaction_withdrawal_hash,
l2_contract_events.transaction_hash AS l2_transaction_hash, proven_l1_events.transaction_hash AS proven_l1_transaction_hash, finalized_l1_events.transaction_hash AS finalized_l1_transaction_hash,
l2_bridge_withdrawals.timestamp, cross_domain_message_hash, local_token_address, remote_token_address`)
if cursor != "" {
// Probably need to fix this and compare timestamps
withdrawalsQuery = withdrawalsQuery.Where("withdrawal_hash < ?", cursor)
withdrawalsQuery = withdrawalsQuery.Order("timestamp DESC").Limit(limit + 1)
if cursorClause != "" {
withdrawalsQuery = withdrawalsQuery.Where(cursorClause)
}
query := db.gorm.Table("(?) AS withdrawals", withdrawalsQuery)
query = query.Joins("UNION (?)", ethTransactionWithdrawals)
query = query.Select("*").Order("timestamp DESC").Limit(limit + 1)
withdrawals := []L2BridgeWithdrawalWithTransactionHashes{}
result := query.Scan(&withdrawals)
result := query.Find(&withdrawals)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, nil
......@@ -292,21 +306,10 @@ l2_bridge_withdrawals.timestamp, cross_domain_message_hash, local_token_address,
hasNextPage := false
if len(withdrawals) > limit {
hasNextPage = true
nextCursor = withdrawals[limit].L2BridgeWithdrawal.TransactionWithdrawalHash.String()
withdrawals = withdrawals[:limit]
nextCursor = withdrawals[limit].L2TransactionHash.String()
}
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, result.Error
}
response := &L2BridgeWithdrawalsResponse{
Withdrawals: withdrawals,
Cursor: nextCursor,
HasNextPage: hasNextPage,
}
response := &L2BridgeWithdrawalsResponse{Withdrawals: withdrawals, Cursor: nextCursor, HasNextPage: hasNextPage}
return response, nil
}
......@@ -43,9 +43,9 @@ func createE2ETestSuite(t *testing.T) E2ETestSuite {
dbUser := os.Getenv("DB_USER")
dbName := setupTestDatabase(t)
// Replace the handler of the global logger with the testlog
logger := testlog.Logger(t, log.LvlInfo)
log.Root().SetHandler(logger.GetHandler())
// Discard the Global Logger as each component
// has its own configured logger
log.Root().SetHandler(log.DiscardHandler())
// Rollup System Configuration and Start
opCfg := op_e2e.DefaultSystemConfig(t)
......@@ -71,8 +71,10 @@ func createE2ETestSuite(t *testing.T) E2ETestSuite {
L2RPC: opSys.EthInstances["sequencer"].HTTPEndpoint(),
},
Chain: config.ChainConfig{
L1PollingInterval: 1000,
L2PollingInterval: 1000,
L1PollingInterval: uint(opCfg.DeployConfig.L1BlockTime) * 1000,
L1ConfirmationDepth: 0,
L2PollingInterval: uint(opCfg.DeployConfig.L2BlockTime) * 1000,
L2ConfirmationDepth: 0,
L1Contracts: config.L1Contracts{
OptimismPortalProxy: opCfg.L1Deployments.OptimismPortalProxy,
L2OutputOracleProxy: opCfg.L1Deployments.L2OutputOracleProxy,
......@@ -80,17 +82,16 @@ func createE2ETestSuite(t *testing.T) E2ETestSuite {
L1StandardBridgeProxy: opCfg.L1Deployments.L1StandardBridgeProxy,
},
},
Metrics: config.MetricsConfig{
Host: "127.0.0.1",
Port: 0,
},
HTTPServer: config.ServerConfig{Host: "127.0.0.1", Port: 0},
MetricsServer: config.ServerConfig{Host: "127.0.0.1", Port: 0},
}
db, err := database.NewDB(indexerCfg.DB)
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
indexer, err := indexer.NewIndexer(logger, db, indexerCfg.Chain, indexerCfg.RPCs, indexerCfg.Metrics)
indexerLog := testlog.Logger(t, log.LvlInfo).New("role", "indexer")
indexer, err := indexer.NewIndexer(indexerLog, db, indexerCfg.Chain, indexerCfg.RPCs, indexerCfg.HTTPServer, indexerCfg.MetricsServer)
require.NoError(t, err)
indexerCtx, indexerStop := context.WithCancel(context.Background())
......
......@@ -16,7 +16,9 @@ import (
type Config struct {
LoopIntervalMsec uint
HeaderBufferSize uint
StartHeight *big.Int
ConfirmationDepth *big.Int
}
type ETL struct {
......
......@@ -3,6 +3,7 @@ package etl
import (
"context"
"fmt"
"sync"
"time"
"github.com/ethereum-optimism/optimism/indexer/config"
......@@ -17,6 +18,8 @@ type L1ETL struct {
ETL
db *database.DB
mu *sync.Mutex
listeners []chan interface{}
}
// NewL1ETL creates a new L1ETL instance that will start indexing from different starting points
......@@ -41,7 +44,7 @@ func NewL1ETL(cfg Config, log log.Logger, db *database.DB, metrics Metricer, cli
fromHeader = latestHeader.RLPHeader.Header()
} else if cfg.StartHeight.BitLen() > 0 {
log.Info("no indexed state in storage, starting from supplied L1 height", "height", cfg.StartHeight.String())
log.Info("no indexed state starting from supplied L1 height", "height", cfg.StartHeight.String())
header, err := client.BlockHeaderByNumber(cfg.StartHeight)
if err != nil {
return nil, fmt.Errorf("could not fetch starting block header: %w", err)
......@@ -50,7 +53,7 @@ func NewL1ETL(cfg Config, log log.Logger, db *database.DB, metrics Metricer, cli
fromHeader = header
} else {
log.Info("no indexed state in storage, starting from L1 genesis")
log.Info("no indexed state, starting from genesis")
}
// NOTE - The use of un-buffered channel here assumes that downstream consumers
......@@ -62,13 +65,13 @@ func NewL1ETL(cfg Config, log log.Logger, db *database.DB, metrics Metricer, cli
log: log,
metrics: metrics,
headerTraversal: node.NewHeaderTraversal(client, fromHeader),
headerTraversal: node.NewHeaderTraversal(client, fromHeader, cfg.ConfirmationDepth),
ethClient: client,
contracts: cSlice,
etlBatches: etlBatches,
}
return &L1ETL{ETL: etl, db: db}, nil
return &L1ETL{ETL: etl, db: db, mu: new(sync.Mutex)}, nil
}
func (l1Etl *L1ETL) Start(ctx context.Context) error {
......@@ -129,6 +132,29 @@ func (l1Etl *L1ETL) Start(ctx context.Context) error {
}
batch.Logger.Info("indexed batch")
// Notify Listeners
l1Etl.mu.Lock()
for i := range l1Etl.listeners {
select {
case l1Etl.listeners[i] <- struct{}{}:
default:
// do nothing if the listener hasn't picked
// up the previous notif
}
}
l1Etl.mu.Unlock()
}
}
}
// Notify returns a channel that'll receive a value every time new data has
// been persisted by the L1ETL
func (l1Etl *L1ETL) Notify() <-chan interface{} {
receiver := make(chan interface{})
l1Etl.mu.Lock()
defer l1Etl.mu.Unlock()
l1Etl.listeners = append(l1Etl.listeners, receiver)
return receiver
}
......@@ -49,7 +49,7 @@ func NewL2ETL(cfg Config, log log.Logger, db *database.DB, metrics Metricer, cli
log: log,
metrics: metrics,
headerTraversal: node.NewHeaderTraversal(client, fromHeader),
headerTraversal: node.NewHeaderTraversal(client, fromHeader, cfg.ConfirmationDepth),
ethClient: client,
contracts: l2Contracts,
etlBatches: etlBatches,
......
......@@ -3,10 +3,16 @@ package indexer
import (
"context"
"fmt"
"math/big"
"net/http"
"runtime/debug"
"sync"
"github.com/ethereum/go-ethereum/log"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus"
"github.com/ethereum-optimism/optimism/indexer/config"
......@@ -14,6 +20,7 @@ import (
"github.com/ethereum-optimism/optimism/indexer/etl"
"github.com/ethereum-optimism/optimism/indexer/node"
"github.com/ethereum-optimism/optimism/indexer/processors"
"github.com/ethereum-optimism/optimism/op-service/httputil"
"github.com/ethereum-optimism/optimism/op-service/metrics"
)
......@@ -23,17 +30,24 @@ type Indexer struct {
log log.Logger
db *database.DB
metricsConfig config.MetricsConfig
httpConfig config.ServerConfig
metricsConfig config.ServerConfig
metricsRegistry *prometheus.Registry
L1ETL *etl.L1ETL
L2ETL *etl.L2ETL
BridgeProcessor *processors.BridgeProcessor
}
// NewIndexer initializes an instance of the Indexer
func NewIndexer(logger log.Logger, db *database.DB, chainConfig config.ChainConfig, rpcsConfig config.RPCsConfig, metricsConfig config.MetricsConfig) (*Indexer, error) {
func NewIndexer(
log log.Logger,
db *database.DB,
chainConfig config.ChainConfig,
rpcsConfig config.RPCsConfig,
httpConfig config.ServerConfig,
metricsConfig config.ServerConfig,
) (*Indexer, error) {
metricsRegistry := metrics.NewRegistry()
// L1
......@@ -41,8 +55,13 @@ func NewIndexer(logger log.Logger, db *database.DB, chainConfig config.ChainConf
if err != nil {
return nil, err
}
l1Cfg := etl.Config{LoopIntervalMsec: chainConfig.L1PollingInterval, HeaderBufferSize: chainConfig.L1HeaderBufferSize, StartHeight: chainConfig.L1StartHeight()}
l1Etl, err := etl.NewL1ETL(l1Cfg, logger, db, etl.NewMetrics(metricsRegistry, "l1"), l1EthClient, chainConfig.L1Contracts)
l1Cfg := etl.Config{
LoopIntervalMsec: chainConfig.L1PollingInterval,
HeaderBufferSize: chainConfig.L1HeaderBufferSize,
ConfirmationDepth: big.NewInt(int64(chainConfig.L1ConfirmationDepth)),
StartHeight: big.NewInt(int64(chainConfig.L1StartingHeight)),
}
l1Etl, err := etl.NewL1ETL(l1Cfg, log, db, etl.NewMetrics(metricsRegistry, "l1"), l1EthClient, chainConfig.L1Contracts)
if err != nil {
return nil, err
}
......@@ -52,22 +71,27 @@ func NewIndexer(logger log.Logger, db *database.DB, chainConfig config.ChainConf
if err != nil {
return nil, err
}
l2Cfg := etl.Config{LoopIntervalMsec: chainConfig.L2PollingInterval, HeaderBufferSize: chainConfig.L2HeaderBufferSize}
l2Etl, err := etl.NewL2ETL(l2Cfg, logger, db, etl.NewMetrics(metricsRegistry, "l2"), l2EthClient)
l2Cfg := etl.Config{
LoopIntervalMsec: chainConfig.L2PollingInterval,
HeaderBufferSize: chainConfig.L2HeaderBufferSize,
ConfirmationDepth: big.NewInt(int64(chainConfig.L2ConfirmationDepth)),
}
l2Etl, err := etl.NewL2ETL(l2Cfg, log, db, etl.NewMetrics(metricsRegistry, "l2"), l2EthClient)
if err != nil {
return nil, err
}
// Bridge
bridgeProcessor, err := processors.NewBridgeProcessor(logger, db, chainConfig)
bridgeProcessor, err := processors.NewBridgeProcessor(log, db, l1Etl, chainConfig)
if err != nil {
return nil, err
}
indexer := &Indexer{
log: logger,
log: log,
db: db,
httpConfig: httpConfig,
metricsConfig: metricsConfig,
metricsRegistry: metricsRegistry,
......@@ -79,6 +103,23 @@ func NewIndexer(logger log.Logger, db *database.DB, chainConfig config.ChainConf
return indexer, nil
}
func (i *Indexer) startHttpServer(ctx context.Context) error {
i.log.Info("starting http server...", "port", i.httpConfig.Host)
r := chi.NewRouter()
r.Use(middleware.Heartbeat("/healthz"))
server := http.Server{Addr: fmt.Sprintf("%s:%d", i.httpConfig.Host, i.httpConfig.Port), Handler: r}
err := httputil.ListenAndServeContext(ctx, &server)
if err != nil {
i.log.Error("http server stopped", "err", err)
} else {
i.log.Info("http server stopped")
}
return err
}
func (i *Indexer) startMetricsServer(ctx context.Context) error {
i.log.Info("starting metrics server...", "port", i.metricsConfig.Port)
err := metrics.ListenAndServe(ctx, i.metricsRegistry, i.metricsConfig.Host, i.metricsConfig.Port)
......@@ -94,7 +135,7 @@ func (i *Indexer) startMetricsServer(ctx context.Context) error {
// Start starts the indexing service on L1 and L2 chains
func (i *Indexer) Run(ctx context.Context) error {
var wg sync.WaitGroup
errCh := make(chan error, 4)
errCh := make(chan error, 5)
// if any goroutine halts, we stop the entire indexer
processCtx, processCancel := context.WithCancel(ctx)
......@@ -121,6 +162,7 @@ func (i *Indexer) Run(ctx context.Context) error {
runProcess(i.L2ETL.Start)
runProcess(i.BridgeProcessor.Start)
runProcess(i.startMetricsServer)
runProcess(i.startHttpServer)
wg.Wait()
err := <-errCh
......
# Chain configures l1 chain addresses
# Can configure them manually or use a preset l2 ChainId for known chains including OP Mainnet, OP Goerli, Base, Base Goerli, Zora, and Zora goerli
[chain]
# OP Goerli
preset = 420
# L1 Config
l1-polling-interval = 0
l1-header-buffer-size = 0
l1-confirmation-depth = 0
l1-starting-height = 0
# L2 Config
l2-polling-interval = 0
l2-header-buffer-size = 0
l2-confirmation-depth = 0
# OP Goerli
preset = 420
l1-starting-height = 0
[rpcs]
l1-rpc = "${INDEXER_RPC_URL_L1}"
l2-rpc = "${INDEXER_RPC_URL_L2}"
[db]
host = "postgres"
port = 5432
user = "db_username"
password = "db_password"
name = "db_name"
host = "$INDEXER_DB_HOST"
# this port may be problematic once we depoly
# the DATABASE_URL looks like this for previous services and didn't include a port
# DATABASE_URL="postgresql://${INDEXER_DB_USER}:${INDEXER_DB_PASS}@localhost/${INDEXER_DB_NAME}?host=${INDEXER_DB_HOST}"
# If not problematic delete these comments
port = $INDEXER_DB_PORT
user = "$INDEXER_DB_USER"
password = "$INDEXER_DB_PASS"
name = "$INDEXER_DB_NAME"
[api]
[http]
host = "127.0.0.1"
port = 8080
......
......@@ -25,8 +25,6 @@ const (
)
type EthClient interface {
FinalizedBlockHeight() (*big.Int, error)
BlockHeaderByNumber(*big.Int) (*types.Header, error)
BlockHeaderByHash(common.Hash) (*types.Header, error)
BlockHeadersByRange(*big.Int, *big.Int) ([]types.Header, error)
......@@ -52,24 +50,6 @@ func DialEthClient(rpcUrl string, metrics Metricer) (EthClient, error) {
return client, nil
}
// FinalizedBlockHeight retrieves the latest block height in a finalized state
func (c *client) FinalizedBlockHeight() (*big.Int, error) {
ctxwt, cancel := context.WithTimeout(context.Background(), defaultRequestTimeout)
defer cancel()
// **NOTE** Local devnet is having issues with the "finalized" block tag. Temp switch
// to "latest" to iterate faster locally but this needs to be updated
var header *types.Header
err := c.rpc.CallContext(ctxwt, &header, "eth_getBlockByNumber", "latest", false)
if err != nil {
return nil, err
} else if header == nil {
return nil, ethereum.NotFound
}
return header.Number, nil
}
// BlockHeaderByHash retrieves the block header attributed to the supplied hash
func (c *client) BlockHeaderByHash(hash common.Hash) (*types.Header, error) {
ctxwt, cancel := context.WithTimeout(context.Background(), defaultRequestTimeout)
......
......@@ -15,14 +15,15 @@ var (
type HeaderTraversal struct {
ethClient EthClient
lastHeader *types.Header
blockConfirmationDepth *big.Int
}
// NewHeaderTraversal instantiates a new instance of HeaderTraversal against the supplied rpc client.
// The HeaderTraversal will start fetching blocks starting from the supplied header unless
// nil, indicating genesis.
func NewHeaderTraversal(ethClient EthClient, fromHeader *types.Header) *HeaderTraversal {
return &HeaderTraversal{ethClient: ethClient, lastHeader: fromHeader}
// The HeaderTraversal will start fetching blocks starting from the supplied header unless nil, indicating genesis.
func NewHeaderTraversal(ethClient EthClient, fromHeader *types.Header, confDepth *big.Int) *HeaderTraversal {
return &HeaderTraversal{ethClient: ethClient, lastHeader: fromHeader, blockConfirmationDepth: confDepth}
}
// LastHeader returns the last header that was fetched by the HeaderTraversal
......@@ -34,13 +35,19 @@ func (f *HeaderTraversal) LastHeader() *types.Header {
// NextFinalizedHeaders retrives the next set of headers that have been
// marked as finalized by the connected client, bounded by the supplied size
func (f *HeaderTraversal) NextFinalizedHeaders(maxSize uint64) ([]types.Header, error) {
finalizedBlockHeight, err := f.ethClient.FinalizedBlockHeight()
latestBlockHeader, err := f.ethClient.BlockHeaderByNumber(nil)
if err != nil {
return nil, fmt.Errorf("unable to query latest finalized height: %w", err)
return nil, fmt.Errorf("unable to query latest block: %w", err)
}
endHeight := new(big.Int).Sub(latestBlockHeader.Number, f.blockConfirmationDepth)
if endHeight.Sign() < 0 {
// No blocks with the provided confirmation depth available
return nil, nil
}
if f.lastHeader != nil {
cmp := f.lastHeader.Number.Cmp(finalizedBlockHeight)
cmp := f.lastHeader.Number.Cmp(endHeight)
if cmp == 0 {
return nil, nil
} else if cmp > 0 {
......@@ -53,7 +60,7 @@ func (f *HeaderTraversal) NextFinalizedHeaders(maxSize uint64) ([]types.Header,
nextHeight = new(big.Int).Add(f.lastHeader.Number, bigOne)
}
endHeight := clampBigInt(nextHeight, finalizedBlockHeight, maxSize)
endHeight = clampBigInt(nextHeight, endHeight, maxSize)
headers, err := f.ethClient.BlockHeadersByRange(nextHeight, endHeight)
if err != nil {
return nil, fmt.Errorf("error querying blocks by range: %w", err)
......
......@@ -37,10 +37,10 @@ func TestHeaderTraversalNextFinalizedHeadersNoOp(t *testing.T) {
// start from block 10 as the latest fetched block
lastHeader := &types.Header{Number: big.NewInt(10)}
headerTraversal := NewHeaderTraversal(client, lastHeader)
headerTraversal := NewHeaderTraversal(client, lastHeader, bigZero)
// no new headers when matched with head
client.On("FinalizedBlockHeight").Return(big.NewInt(10), nil)
client.On("BlockHeaderByNumber", (*big.Int)(nil)).Return(lastHeader, nil)
headers, err := headerTraversal.NextFinalizedHeaders(100)
require.NoError(t, err)
require.Empty(t, headers)
......@@ -50,11 +50,11 @@ func TestHeaderTraversalNextFinalizedHeadersCursored(t *testing.T) {
client := new(MockEthClient)
// start from genesis
headerTraversal := NewHeaderTraversal(client, nil)
headerTraversal := NewHeaderTraversal(client, nil, bigZero)
// blocks [0..4]
headers := makeHeaders(5, nil)
client.On("FinalizedBlockHeight").Return(big.NewInt(4), nil).Times(1) // Times so that we can override next
client.On("BlockHeaderByNumber", (*big.Int)(nil)).Return(&headers[4], nil).Times(1) // Times so that we can override next
client.On("BlockHeadersByRange", mock.MatchedBy(BigIntMatcher(0)), mock.MatchedBy(BigIntMatcher(4))).Return(headers, nil)
headers, err := headerTraversal.NextFinalizedHeaders(5)
require.NoError(t, err)
......@@ -62,7 +62,7 @@ func TestHeaderTraversalNextFinalizedHeadersCursored(t *testing.T) {
// blocks [5..9]
headers = makeHeaders(5, &headers[len(headers)-1])
client.On("FinalizedBlockHeight").Return(big.NewInt(9), nil)
client.On("BlockHeaderByNumber", (*big.Int)(nil)).Return(&headers[4], nil)
client.On("BlockHeadersByRange", mock.MatchedBy(BigIntMatcher(5)), mock.MatchedBy(BigIntMatcher(9))).Return(headers, nil)
headers, err = headerTraversal.NextFinalizedHeaders(5)
require.NoError(t, err)
......@@ -73,10 +73,10 @@ func TestHeaderTraversalNextFinalizedHeadersMaxSize(t *testing.T) {
client := new(MockEthClient)
// start from genesis
headerTraversal := NewHeaderTraversal(client, nil)
headerTraversal := NewHeaderTraversal(client, nil, bigZero)
// 100 "available" headers
client.On("FinalizedBlockHeight").Return(big.NewInt(100), nil)
client.On("BlockHeaderByNumber", (*big.Int)(nil)).Return(&types.Header{Number: big.NewInt(100)}, nil)
// clamped by the supplied size
headers := makeHeaders(5, nil)
......@@ -97,11 +97,11 @@ func TestHeaderTraversalMismatchedProviderStateError(t *testing.T) {
client := new(MockEthClient)
// start from genesis
headerTraversal := NewHeaderTraversal(client, nil)
headerTraversal := NewHeaderTraversal(client, nil, bigZero)
// blocks [0..4]
headers := makeHeaders(5, nil)
client.On("FinalizedBlockHeight").Return(big.NewInt(4), nil).Times(1) // Times so that we can override next
client.On("BlockHeaderByNumber", (*big.Int)(nil)).Return(&headers[4], nil).Times(1) // Times so that we can override next
client.On("BlockHeadersByRange", mock.MatchedBy(BigIntMatcher(0)), mock.MatchedBy(BigIntMatcher(4))).Return(headers, nil)
headers, err := headerTraversal.NextFinalizedHeaders(5)
require.NoError(t, err)
......@@ -109,7 +109,7 @@ func TestHeaderTraversalMismatchedProviderStateError(t *testing.T) {
// blocks [5..9]. Next batch is not chained correctly (starts again from genesis)
headers = makeHeaders(5, nil)
client.On("FinalizedBlockHeight").Return(big.NewInt(9), nil)
client.On("BlockHeaderByNumber", (*big.Int)(nil)).Return(&types.Header{Number: big.NewInt(9)}, nil)
client.On("BlockHeadersByRange", mock.MatchedBy(BigIntMatcher(5)), mock.MatchedBy(BigIntMatcher(9))).Return(headers, nil)
headers, err = headerTraversal.NextFinalizedHeaders(5)
require.Nil(t, headers)
......
......@@ -20,9 +20,9 @@ func (m *MockEthClient) BlockHeaderByNumber(number *big.Int) (*types.Header, err
return args.Get(0).(*types.Header), args.Error(1)
}
func (m *MockEthClient) FinalizedBlockHeight() (*big.Int, error) {
args := m.Called()
return args.Get(0).(*big.Int), args.Error(1)
func (m *MockEthClient) BlockHeaderByHash(hash common.Hash) (*types.Header, error) {
args := m.Called(hash)
return args.Get(0).(*types.Header), args.Error(1)
}
func (m *MockEthClient) BlockHeadersByRange(from, to *big.Int) ([]types.Header, error) {
......@@ -30,11 +30,6 @@ func (m *MockEthClient) BlockHeadersByRange(from, to *big.Int) ([]types.Header,
return args.Get(0).([]types.Header), args.Error(1)
}
func (m *MockEthClient) BlockHeaderByHash(hash common.Hash) (*types.Header, error) {
args := m.Called(hash)
return args.Get(0).(*types.Header), args.Error(1)
}
func (m *MockEthClient) StorageHash(address common.Address, blockNumber *big.Int) (common.Hash, error) {
args := m.Called(address, blockNumber)
return args.Get(0).(common.Hash), args.Error(1)
......
......@@ -4,10 +4,10 @@ import (
"context"
"errors"
"math/big"
"time"
"github.com/ethereum-optimism/optimism/indexer/config"
"github.com/ethereum-optimism/optimism/indexer/database"
"github.com/ethereum-optimism/optimism/indexer/etl"
"github.com/ethereum-optimism/optimism/indexer/processors/bridge"
"github.com/ethereum/go-ethereum/core/types"
......@@ -17,6 +17,7 @@ import (
type BridgeProcessor struct {
log log.Logger
db *database.DB
l1Etl *etl.L1ETL
chainConfig config.ChainConfig
// NOTE: We'll need this processor to handle for reorgs events.
......@@ -25,48 +26,40 @@ type BridgeProcessor struct {
LatestL2Header *types.Header
}
func NewBridgeProcessor(log log.Logger, db *database.DB, chainConfig config.ChainConfig) (*BridgeProcessor, error) {
func NewBridgeProcessor(log log.Logger, db *database.DB, l1Etl *etl.L1ETL, chainConfig config.ChainConfig) (*BridgeProcessor, error) {
log = log.New("processor", "bridge")
latestL1Header, err := bridge.L1LatestBridgeEventHeader(db, chainConfig)
latestL1Header, err := db.BridgeTransactions.L1LatestBlockHeader()
if err != nil {
return nil, err
}
latestL2Header, err := bridge.L2LatestBridgeEventHeader(db)
latestL2Header, err := db.BridgeTransactions.L2LatestBlockHeader()
if err != nil {
return nil, err
}
// Since the bridge processor indexes events based on epochs, there's
// no scenario in which we have indexed L2 data with no L1 data.
//
// NOTE: Technically there is an exception if our bridging contracts are
// used to bridges native from L2 and an op-chain happens to launch where
// only L2 native bridge events have occurred. This is a rare situation now
// and it's worth the assertion as an integrity check. We can revisit this
// as more chains launch with primarily L2-native activity.
if latestL1Header == nil && latestL2Header != nil {
log.Error("detected indexed L2 bridge activity with no indexed L1 state", "l2_block_number", latestL2Header.Number)
return nil, errors.New("detected indexed L2 bridge activity with no indexed L1 state")
}
var l1Header, l2Header *types.Header
if latestL1Header == nil && latestL2Header == nil {
log.Info("no indexed state, starting from genesis")
log.Info("no indexed state, starting from rollup genesis")
} else {
log.Info("detected the latest indexed state", "l1_block_number", latestL1Header.Number, "l2_block_number", latestL2Header.Number)
l1Height, l2Height := big.NewInt(0), big.NewInt(0)
if latestL1Header != nil {
l1Height = latestL1Header.Number
l1Header = latestL1Header.RLPHeader.Header()
}
if latestL2Header != nil {
l2Height = latestL2Header.Number
l2Header = latestL2Header.RLPHeader.Header()
}
log.Info("detected latest indexed state", "l1_block_number", l1Height, "l2_block_number", l2Height)
}
return &BridgeProcessor{log, db, chainConfig, latestL1Header, latestL2Header}, nil
return &BridgeProcessor{log, db, l1Etl, chainConfig, l1Header, l2Header}, nil
}
func (b *BridgeProcessor) Start(ctx context.Context) error {
done := ctx.Done()
// NOTE: This should run on same iterval as L1 ETL rather than as finding the
// lasted epoch is constrained to how much L1 data we've indexed.
pollTicker := time.NewTicker(5 * time.Second)
defer pollTicker.Stop()
// In order to ensure all seen bridge finalization events correspond with seen
// bridge initiated events, we establish a shared marker between L1 and L2 when
// processing events.
......@@ -75,9 +68,7 @@ func (b *BridgeProcessor) Start(ctx context.Context) error {
// sequencing epoch and corresponding L1 origin that has also been indexed
// serves as this shared marker.
// TODOs:
// 1. Fix Logging. Should be clear if we're looking at L1 or L2 side of things
l1EtlUpdates := b.l1Etl.Notify()
b.log.Info("starting bridge processor...")
for {
select {
......@@ -85,38 +76,49 @@ func (b *BridgeProcessor) Start(ctx context.Context) error {
b.log.Info("stopping bridge processor")
return nil
case <-pollTicker.C:
case <-l1EtlUpdates:
latestEpoch, err := b.db.Blocks.LatestEpoch()
if err != nil {
return err
} else if latestEpoch == nil {
if b.LatestL1Header != nil || b.LatestL2Header != nil {
// Once we have some indexed state `latestEpoch` can never return nil
b.log.Error("bridge events indexed, but no indexed epoch returned", "latest_bridge_l1_block_number", b.LatestL1Header.Number)
return errors.New("bridge events indexed, but no indexed epoch returned")
}
if latestEpoch == nil {
if b.LatestL1Header != nil {
// Once we have some satte `latestEpoch` should never return nil.
b.log.Error("started with indexed bridge state, but no blocks epochs returned", "latest_bridge_l1_block_number", b.LatestL1Header.Number)
return errors.New("started with indexed bridge state, but no blocks epochs returned")
} else {
b.log.Warn("no indexed block state. waiting...")
b.log.Warn("no indexed epochs available. waiting...")
continue
}
}
// Integrity Checks
if b.LatestL1Header != nil && latestEpoch.L1BlockHeader.Hash == b.LatestL1Header.Hash() {
// Marked as a warning since the bridge should always be processing at least 1 new epoch
b.log.Warn("all available epochs indexed by the bridge", "latest_epoch_number", b.LatestL1Header.Number)
b.log.Warn("all available epochs indexed", "latest_bridge_l1_block_number", b.LatestL1Header.Number)
continue
}
if b.LatestL1Header != nil && latestEpoch.L1BlockHeader.Number.Cmp(b.LatestL1Header.Number) <= 0 {
b.log.Error("non-increasing l1 block height observed", "latest_bridge_l1_block_number", b.LatestL1Header.Number, "latest_epoch_number", latestEpoch.L1BlockHeader.Number)
return errors.New("non-increasing l1 block heght observed")
}
if b.LatestL2Header != nil && latestEpoch.L2BlockHeader.Number.Cmp(b.LatestL2Header.Number) <= 0 {
b.log.Error("non-increasing l2 block height observed", "latest_bridge_l2_block_number", b.LatestL2Header.Number, "latest_epoch_number", latestEpoch.L2BlockHeader.Number)
return errors.New("non-increasing l2 block heght observed")
}
// Process Bridge Events
toL1Height, toL2Height := latestEpoch.L1BlockHeader.Number, latestEpoch.L2BlockHeader.Number
fromL1Height, fromL2Height := big.NewInt(0), big.NewInt(0)
if b.LatestL1Header != nil {
// `NewBridgeProcessor` ensures that LatestL2Header must not be nil if LatestL1Header is set
fromL1Height = new(big.Int).Add(b.LatestL1Header.Number, big.NewInt(1))
}
if b.LatestL2Header != nil {
fromL2Height = new(big.Int).Add(b.LatestL2Header.Number, big.NewInt(1))
}
batchLog := b.log.New("epoch_start_number", fromL1Height, "epoch_end_number", toL1Height)
batchLog.Info("scanning bridge events")
batchLog.Info("scanning for new bridge events")
err = b.db.Transaction(func(tx *database.DB) error {
l1BridgeLog := b.log.New("from_l1_block_number", fromL1Height, "to_l1_block_number", toL1Height)
l2BridgeLog := b.log.New("from_l2_block_number", fromL2Height, "to_l2_block_number", toL2Height)
......@@ -145,7 +147,7 @@ func (b *BridgeProcessor) Start(ctx context.Context) error {
// Try again on a subsequent interval
batchLog.Error("unable to index new bridge events", "err", err)
} else {
batchLog.Info("done indexing new bridge events", "latest_l1_block_number", toL1Height, "latest_l2_block_number", toL2Height)
batchLog.Info("done indexing bridge events", "latest_l1_block_number", toL1Height, "latest_l2_block_number", toL2Height)
b.LatestL1Header = latestEpoch.L1BlockHeader.RLPHeader.Header()
b.LatestL2Header = latestEpoch.L2BlockHeader.RLPHeader.Header()
}
......
......@@ -8,7 +8,6 @@ import (
"github.com/ethereum-optimism/optimism/indexer/config"
"github.com/ethereum-optimism/optimism/indexer/database"
"github.com/ethereum-optimism/optimism/indexer/processors/contracts"
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/log"
......@@ -65,7 +64,7 @@ func L1ProcessInitiatedBridgeEvents(log log.Logger, db *database.DB, chainConfig
// extract the deposit hash from the previous TransactionDepositedEvent
portalDeposit, ok := portalDeposits[logKey{sentMessage.Event.BlockHash, sentMessage.Event.LogIndex - 1}]
if !ok {
return fmt.Errorf("missing expected preceding TransactionDeposit for SentMessage. tx_hash = %s", sentMessage.Event.TransactionHash)
return fmt.Errorf("expected TransactionDeposit preceding SentMessage event. tx_hash = %s", sentMessage.Event.TransactionHash)
}
l1BridgeMessages[i] = database.L1BridgeMessage{TransactionSourceHash: portalDeposit.DepositTx.SourceHash, BridgeMessage: sentMessage.BridgeMessage}
......@@ -94,11 +93,11 @@ func L1ProcessInitiatedBridgeEvents(log log.Logger, db *database.DB, chainConfig
// extract the cross domain message hash & deposit source hash from the following events
portalDeposit, ok := portalDeposits[logKey{initiatedBridge.Event.BlockHash, initiatedBridge.Event.LogIndex + 1}]
if !ok {
return fmt.Errorf("missing expected following TransactionDeposit for BridgeInitiated. tx_hash = %s", initiatedBridge.Event.TransactionHash)
return fmt.Errorf("expected TransactionDeposit following BridgeInitiated event. tx_hash = %s", initiatedBridge.Event.TransactionHash)
}
sentMessage, ok := sentMessages[logKey{initiatedBridge.Event.BlockHash, initiatedBridge.Event.LogIndex + 2}]
if !ok {
return fmt.Errorf("missing expected following SentMessage for BridgeInitiated. tx_hash = %s", initiatedBridge.Event.TransactionHash)
return fmt.Errorf("expected SentMessage following TransactionDeposit event. tx_hash = %s", initiatedBridge.Event.TransactionHash)
}
initiatedBridge.BridgeTransfer.CrossDomainMessageHash = &sentMessage.BridgeMessage.MessageHash
......@@ -216,7 +215,7 @@ func L1ProcessFinalizedBridgeEvents(log log.Logger, db *database.DB, chainConfig
finalizedBridge := finalizedBridges[i]
relayedMessage, ok := relayedMessages[logKey{finalizedBridge.Event.BlockHash, finalizedBridge.Event.LogIndex + 1}]
if !ok {
return fmt.Errorf("missing following RelayedMessage for BridgeFinalized event. tx_hash = %s", finalizedBridge.Event.TransactionHash)
return fmt.Errorf("expected RelayedMessage following BridgeFinalized event. tx_hash = %s", finalizedBridge.Event.TransactionHash)
}
// Since the message hash is computed from the relayed message, this ensures the deposit fields must match. For good measure,
......@@ -233,80 +232,3 @@ func L1ProcessFinalizedBridgeEvents(log log.Logger, db *database.DB, chainConfig
// a-ok!
return nil
}
// L1LatestBridgeEventHeader returns the latest header for which and on-chain event
// has been observed on L1 -- Both initiated L1 events and finalization markers on L2.
func L1LatestBridgeEventHeader(db *database.DB, chainConfig config.ChainConfig) (*types.Header, error) {
portalAbi, err := bindings.OptimismPortalMetaData.GetAbi()
if err != nil {
return nil, err
}
depositEventID := portalAbi.Events["TransactionDeposited"].ID
provenEventID := portalAbi.Events["WithdrawalProven"].ID
finalizedEventID := portalAbi.Events["WithdrawalFinalized"].ID
// (1) Initiated L1 Events
// Since all initaited bridge events eventually reach the OptimismPortal to
// conduct the deposit, we can simply look for the last deposited transaction
// event on L2.
var latestDepositHeader *types.Header
contractEventFilter := database.ContractEvent{ContractAddress: chainConfig.L1Contracts.OptimismPortalProxy, EventSignature: depositEventID}
depositEvent, err := db.ContractEvents.L1LatestContractEventWithFilter(contractEventFilter)
if err != nil {
return nil, err
}
if depositEvent != nil {
l1BlockHeader, err := db.Blocks.L1BlockHeader(depositEvent.BlockHash)
if err != nil {
return nil, err
}
if l1BlockHeader != nil {
latestDepositHeader = l1BlockHeader.RLPHeader.Header()
}
}
// (2) Finalization markers for L2
// Like initiated L1 events, all withdrawals must flow through the OptimismPortal
// contract. We must look for both proven and finalized withdrawal events.
var latestWithdrawHeader *types.Header
contractEventFilter.EventSignature = finalizedEventID
withdrawEvent, err := db.ContractEvents.L1LatestContractEventWithFilter(contractEventFilter)
if err != nil {
return nil, err
}
if withdrawEvent != nil {
// Check if a have a later detected proven event
contractEventFilter.EventSignature = provenEventID
provenEvent, err := db.ContractEvents.L1LatestContractEventWithFilter(contractEventFilter)
if err != nil {
return nil, err
}
if provenEvent != nil && provenEvent.Timestamp > withdrawEvent.Timestamp {
withdrawEvent = provenEvent
}
l1BlockHeader, err := db.Blocks.L1BlockHeader(withdrawEvent.BlockHash)
if err != nil {
return nil, err
}
latestWithdrawHeader = l1BlockHeader.RLPHeader.Header()
}
if latestDepositHeader == nil {
// If there has been no seen deposits yet, there could have been no seen withdrawals
if latestWithdrawHeader != nil {
return nil, errors.New("detected an indexed withdrawal without any deposits")
}
return nil, nil
} else if latestWithdrawHeader == nil {
return latestDepositHeader, nil
} else {
// both deposits & withdrawals have occurred
if latestDepositHeader.Time > latestWithdrawHeader.Time {
return latestDepositHeader, nil
} else {
return latestWithdrawHeader, nil
}
}
}
......@@ -7,10 +7,8 @@ import (
"github.com/ethereum-optimism/optimism/indexer/database"
"github.com/ethereum-optimism/optimism/indexer/processors/contracts"
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
"github.com/ethereum-optimism/optimism/op-bindings/predeploys"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/log"
)
......@@ -65,7 +63,7 @@ func L2ProcessInitiatedBridgeEvents(log log.Logger, db *database.DB, fromHeight
// extract the withdrawal hash from the previous MessagePassed event
messagePassed, ok := messagesPassed[logKey{sentMessage.Event.BlockHash, sentMessage.Event.LogIndex - 1}]
if !ok {
return fmt.Errorf("missing expected preceding MessagePassedEvent for SentMessage. tx_hash = %s", sentMessage.Event.TransactionHash)
return fmt.Errorf("expected MessagePassedEvent preceding SentMessage. tx_hash = %s", sentMessage.Event.TransactionHash)
}
l2BridgeMessages[i] = database.L2BridgeMessage{TransactionWithdrawalHash: messagePassed.WithdrawalHash, BridgeMessage: sentMessage.BridgeMessage}
......@@ -94,11 +92,11 @@ func L2ProcessInitiatedBridgeEvents(log log.Logger, db *database.DB, fromHeight
// extract the cross domain message hash & deposit source hash from the following events
messagePassed, ok := messagesPassed[logKey{initiatedBridge.Event.BlockHash, initiatedBridge.Event.LogIndex + 1}]
if !ok {
return fmt.Errorf("missing expected following MessagePassed for BridgeInitiated. tx_hash = %s", initiatedBridge.Event.TransactionHash)
return fmt.Errorf("expected MessagePassed following BridgeInitiated event. tx_hash = %s", initiatedBridge.Event.TransactionHash)
}
sentMessage, ok := sentMessages[logKey{initiatedBridge.Event.BlockHash, initiatedBridge.Event.LogIndex + 2}]
if !ok {
return fmt.Errorf("missing expected following SentMessage for BridgeInitiated. tx_hash = %s", initiatedBridge.Event.TransactionHash)
return fmt.Errorf("expected SentMessage following MessagePassed event. tx_hash = %s", initiatedBridge.Event.TransactionHash)
}
initiatedBridge.BridgeTransfer.CrossDomainMessageHash = &sentMessage.BridgeMessage.MessageHash
......@@ -165,7 +163,7 @@ func L2ProcessFinalizedBridgeEvents(log log.Logger, db *database.DB, fromHeight
finalizedBridge := finalizedBridges[i]
relayedMessage, ok := relayedMessages[logKey{finalizedBridge.Event.BlockHash, finalizedBridge.Event.LogIndex + 1}]
if !ok {
return fmt.Errorf("missing following RelayedMessage for BridgeFinalized event. tx_hash = %s", finalizedBridge.Event.TransactionHash)
return fmt.Errorf("expected RelayedMessage following BridgeFinalized event. tx_hash = %s", finalizedBridge.Event.TransactionHash)
}
// Since the message hash is computed from the relayed message, this ensures the withdrawal fields must match. For good measure,
......@@ -182,71 +180,3 @@ func L2ProcessFinalizedBridgeEvents(log log.Logger, db *database.DB, fromHeight
// a-ok!
return nil
}
// L2LatestBridgeEventHeader returns the latest header for which and on-chain event
// has been observed on L2 -- Both initiated L2 events and finalization markers from L1.
func L2LatestBridgeEventHeader(db *database.DB) (*types.Header, error) {
l2ToL1MessagePasserAbi, err := bindings.L2ToL1MessagePasserMetaData.GetAbi()
if err != nil {
return nil, err
}
crossDomainMessengerAbi, err := bindings.CrossDomainMessengerMetaData.GetAbi()
if err != nil {
return nil, err
}
messagePassedID := l2ToL1MessagePasserAbi.Events["MessagePassed"].ID
relayedEventID := crossDomainMessengerAbi.Events["RelayedMessage"].ID
// (1) Initiated L2 Events
// Since all initiated bridge events eventually reach the L2ToL1MessagePasser to
// initiate the withdrawal, we can simply look for the last message passed from
// this cont
var latestWithdrawHeader *types.Header
contractEventFilter := database.ContractEvent{ContractAddress: predeploys.L2ToL1MessagePasserAddr, EventSignature: messagePassedID}
withdrawEvent, err := db.ContractEvents.L2LatestContractEventWithFilter(contractEventFilter)
if err != nil {
return nil, err
}
if withdrawEvent != nil {
l2BlockHeader, err := db.Blocks.L2BlockHeader(withdrawEvent.BlockHash)
if err != nil {
return nil, err
}
if l2BlockHeader != nil {
latestWithdrawHeader = l2BlockHeader.RLPHeader.Header()
}
}
// (2) Finalization markers for L1
// Since deposited transactions from L1 are apart of the block derivation process,
// there are no native finalization markers for OptimismPortal#TransactionDeposited.
// The lowest layer to check for here is the CrossDomainMessenger#RelayedMessage event.
// This also converts the StandardBridge which simply is an extension of the messenger.
var latestRelayedMessageHeader *types.Header
contractEventFilter = database.ContractEvent{ContractAddress: predeploys.L2CrossDomainMessengerAddr, EventSignature: relayedEventID}
relayedEvent, err := db.ContractEvents.L2LatestContractEventWithFilter(contractEventFilter)
if err != nil {
return nil, err
}
if relayedEvent != nil {
l2BlockHeader, err := db.Blocks.L2BlockHeader(relayedEvent.BlockHash)
if err != nil {
return nil, err
}
if l2BlockHeader != nil {
latestRelayedMessageHeader = l2BlockHeader.RLPHeader.Header()
}
}
// No causaal relationship between withdraw and relayed messages
if latestWithdrawHeader == nil || latestRelayedMessageHeader == nil {
return nil, nil
} else {
if latestWithdrawHeader.Time > latestRelayedMessageHeader.Time {
return latestWithdrawHeader, nil
} else {
return latestRelayedMessageHeader, nil
}
}
}
This diff is collapsed.
......@@ -4,21 +4,24 @@ ARG VERSION=v0.0.0
RUN apk add --no-cache make gcc musl-dev linux-headers git jq bash
COPY ./go.mod /app/go.mod
COPY ./go.sum /app/go.sum
WORKDIR /app
RUN go mod download
# build op-batcher with the shared go.mod & go.sum files
COPY ./op-batcher /app/op-batcher
COPY ./op-bindings /app/op-bindings
COPY ./op-node /app/op-node
COPY ./op-service /app/op-service
COPY ./op-signer /app/op-signer
COPY ./go.mod /app/go.mod
COPY ./go.sum /app/go.sum
COPY ./.git /app/.git
WORKDIR /app/op-batcher
RUN go mod download
ARG TARGETOS TARGETARCH
RUN make op-batcher VERSION="$VERSION" GOOS=$TARGETOS GOARCH=$TARGETARCH
......
......@@ -12,6 +12,7 @@ version:
compile:
cd $(contracts-dir) && \
forge clean && \
pnpm build
bindings: compile bindings-build
......
This diff is collapsed.
......@@ -13,7 +13,7 @@ const AlphabetVMStorageLayoutJSON = "{\"storage\":[{\"astId\":1000,\"contract\":
var AlphabetVMStorageLayout = new(solc.StorageLayout)
var AlphabetVMDeployedBin = "0x608060405234801561001057600080fd5b50600436106100365760003560e01c80637dc0d1d01461003b578063f8e0cb9614610085575b600080fd5b60005461005b9073ffffffffffffffffffffffffffffffffffffffff1681565b60405173ffffffffffffffffffffffffffffffffffffffff90911681526020015b60405180910390f35b6100986100933660046101a8565b6100a6565b60405190815260200161007c565b60008060007f000000000000000000000000000000000000000000000000000000000000000087876040516100dc929190610214565b60405180910390200361010057600091506100f986880188610224565b905061011f565b61010c8688018861023d565b90925090508161011b8161028e565b9250505b8161012b8260016102c6565b6040805160208101939093528201526060016040516020818303038152906040528051906020012092505050949350505050565b60008083601f84011261017157600080fd5b50813567ffffffffffffffff81111561018957600080fd5b6020830191508360208285010111156101a157600080fd5b9250929050565b600080600080604085870312156101be57600080fd5b843567ffffffffffffffff808211156101d657600080fd5b6101e28883890161015f565b909650945060208701359150808211156101fb57600080fd5b506102088782880161015f565b95989497509550505050565b8183823760009101908152919050565b60006020828403121561023657600080fd5b5035919050565b6000806040838503121561025057600080fd5b50508035926020909101359150565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b60007fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82036102bf576102bf61025f565b5060010190565b600082198211156102d9576102d961025f565b50019056fea164736f6c634300080f000a"
var AlphabetVMDeployedBin = "0x608060405234801561001057600080fd5b50600436106100365760003560e01c80637dc0d1d01461003b578063f8e0cb9614610085575b600080fd5b60005461005b9073ffffffffffffffffffffffffffffffffffffffff1681565b60405173ffffffffffffffffffffffffffffffffffffffff90911681526020015b60405180910390f35b610098610093366004610212565b6100a6565b60405190815260200161007c565b600080600060087f0000000000000000000000000000000000000000000000000000000000000000901b600888886040516100e292919061027e565b6040518091039020901b0361010857600091506101018688018861028e565b9050610127565b610114868801886102a7565b909250905081610123816102f8565b9250505b81610133826001610330565b604080516020810193909352820152606001604080517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe081840301815291905280516020909101207effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff167f010000000000000000000000000000000000000000000000000000000000000017979650505050505050565b60008083601f8401126101db57600080fd5b50813567ffffffffffffffff8111156101f357600080fd5b60208301915083602082850101111561020b57600080fd5b9250929050565b6000806000806040858703121561022857600080fd5b843567ffffffffffffffff8082111561024057600080fd5b61024c888389016101c9565b9096509450602087013591508082111561026557600080fd5b50610272878288016101c9565b95989497509550505050565b8183823760009101908152919050565b6000602082840312156102a057600080fd5b5035919050565b600080604083850312156102ba57600080fd5b50508035926020909101359150565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b60007fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8203610329576103296102c9565b5060010190565b60008219821115610343576103436102c9565b50019056fea164736f6c634300080f000a"
func init() {
if err := json.Unmarshal([]byte(AlphabetVMStorageLayoutJSON), AlphabetVMStorageLayout); err != nil {
......
This source diff could not be displayed because it is too large. You can view the blob instead.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -141,9 +141,8 @@ func createContractInput(input abi.Argument, inputs []ContractInput) ([]Contract
return nil, err
}
// TODO: could probably do better than string comparison?
internalType := input.Type.String()
if inputType == "tuple" {
if input.Type.T == abi.TupleTy {
internalType = input.Type.TupleRawName
}
......
......@@ -4,6 +4,13 @@ ARG VERSION=v0.0.0
RUN apk add --no-cache make gcc musl-dev linux-headers git jq bash
COPY ./go.mod /app/go.mod
COPY ./go.sum /app/go.sum
WORKDIR /app
RUN go mod download
# build op-challenger with the shared go.mod & go.sum files
COPY ./op-challenger /app/op-challenger
COPY ./op-program /app/op-program
......@@ -12,8 +19,6 @@ COPY ./op-bindings /app/op-bindings
COPY ./op-node /app/op-node
COPY ./op-service /app/op-service
COPY ./op-signer /app/op-signer
COPY ./go.mod /app/go.mod
COPY ./go.sum /app/go.sum
COPY ./.git /app/.git
# Copy cannon and its dependencies
......@@ -23,8 +28,6 @@ COPY ./op-chain-ops /app/op-chain-ops
WORKDIR /app/op-program
RUN go mod download
ARG TARGETOS TARGETARCH
RUN make op-program-host VERSION="$VERSION" GOOS=$TARGETOS GOARCH=$TARGETARCH
......
......@@ -7,16 +7,167 @@ games, and validity games. To learn more about dispute games, visit the
## Quickstart
First, clone this repo. Then, run `make`, which will build all required targets.
Alternatively, run `make devnet` to bring up the [devnet](../ops-bedrock/devnet-up.sh)
which deploys the [mock dispute game contracts](./contracts) as well as an
`op-challenger` instance.
First, clone this repo. Then run:
Alternatively, you can build the `op-challenger` binary locally using the pre-configured
[Makefile](./Makefile) target by running `make build`, and then running `./op-challenger --help`
```shell
cd op-challenger
make alphabet
```
This creates a local devnet, starts a dispute game using the simple alphabet trace type and runs two op-challenger
instances with differing views of the correct alphabet to play the game.
Alternatively, you can build the `op-challenger` binary directly using the pre-configured
[Makefile](./Makefile) target by running `make build`, and then running `./bin/op-challenger --help`
to see a list of available options.
## Usage
`op-challenger` is configurable via command line flags and environment variables. The help menu
shows the available config options and can be accessed by running `./op-challenger --help`.
### Running with Cannon on Local Devnet
To run `op-challenger` against the local devnet, first ensure the required components are built and the devnet is running.
From the top level of the repository run:
```shell
make devnet-clean
make cannon-prestate op-challenger
make devnet-up
```
Then start `op-challenger` with:
```shell
DISPUTE_GAME_FACTORY=$(jq -r .DisputeGameFactoryProxy .devnet/addresses.json)
./op-challenger/bin/op-challenger \
--trace-type cannon \
--l1-eth-rpc http://localhost:8545 \
--game-factory-address $DISPUTE_GAME_FACTORY \
--agree-with-proposed-output=true \
--datadir temp/challenger-data \
--cannon-rollup-config .devnet/rollup.json \
--cannon-l2-genesis .devnet/genesis-l2.json \
--cannon-bin ./cannon/bin/cannon \
--cannon-server ./op-program/bin/op-program \
--cannon-prestate ./op-program/bin/prestate.json \
--cannon-l2 http://localhost:9545 \
--mnemonic "test test test test test test test test test test test junk" \
--hd-path "m/44'/60'/0'/0/8" \
--num-confirmations 1
```
The mnemonic and hd-path above is a prefunded address on the devnet. The challenger respond to any created games by
posting the correct trace as the counter-claim. The scripts below can then be used to create and interact with games.
## Scripts
The [scripts](scripts) directory contains a collection of scripts to assist with manually creating and playing games.
This are not intended to be used in production, only to support manual testing and to aid with understanding how
dispute games work. They also serve as examples of how to use `cast` to manually interact with the dispute game
contracts.
### Understanding Revert Reasons
When actions performed by these scripts fails, they typically print a message that includes the
abi encoded revert reason provided by the contract. e.g.
```
Error:
(code: 3, message: execution reverted, data: Some(String("0x67fe1950")))
```
The `cast 4byte` command can be used to decode these revert reasons. e.g.
```shell
$ cast 4byte 0x67fe1950
GameNotInProgress()
```
### Dependencies
These scripts assume that the following tools are installed and available on the current `PATH`:
* `cast` (https://book.getfoundry.sh/cast/)
* `jq` (https://jqlang.github.io/jq/)
* `bash`
### [create_game.sh](scripts/create_game.sh)
```shell
./scripts/create_game.sh <RPC_URL> <GAME_FACTORY_ADDRESS> <ROOT_CLAIM> <SIGNER_ARGS>...
```
Starts a new fault dispute game that disputes the latest output proposal in the L2 output oracle.
* `RPC_URL` - the RPC endpoint of the L1 endpoint to use (e.g. `http://localhost:8545`).
* `GAME_FACTORY_ADDRESS` - the address of the dispute game factory contract on L1.
* `ROOT_CLAIM` a hex encoded 32 byte hash to use as the root claim for the created game.
* `SIGNER_ARGS` the remaining args are past as arguments to `cast` when sending transactions.
These arguments must specify a way for `cast` to sign the transactions.
See `cast send --help` for supported options.
Creating a dispute game requires sending two transactions. The first transaction creates a
checkpoint in the `BlockOracle` that records the L1 block that will be used as the L1 head
when generating the cannon execution trace. The second transaction then creates the actual
dispute game, specifying the disputed L2 block number and previously checkpointed L1 head block.
### [move.sh](scripts/move.sh)
```shell
./scripts/move.sh <RPC_URL> <GAME_ADDRESS> (attack|defend) <PARENT_INDEX> <CLAIM> <SIGNER_ARGS>...
```
Performs a move to either attack or defend the latest claim in the specified game.
* `RPC_URL` - the RPC endpoint of the L1 endpoint to use (e.g. `http://localhost:8545`).
* `GAME_ADDRESS` - the address of the dispute game to perform the move in.
* `(attack|defend)` - the type of move to make.
* `attack` indicates that the state hash in your local cannon trace differs to the state
hash included in the latest claim.
* `defend` indicates that the state hash in your local cannon trace matches the state hash
included in the latest claim.
* `PARENT_INDEX` - the index of the parent claim that will be countered by this new claim.
The special value of `latest` will counter the latest claim added to the game.
* `CLAIM` - the state hash to include in the counter-claim you are posting.
* `SIGNER_ARGS` the remaining args are past as arguments to `cast` when sending transactions.
These arguments must specify a way for `cast` to sign the transactions.
See `cast send --help` for supported options.
### [resolve.sh](scripts/resolve.sh)
```shell
./scripts/resolve.sh <RPC_URL> <GAME_ADDRESS> <SIGNER_ARGS>...
```
Resolves a dispute game. Note that this will fail if the dispute game has already been resolved
or if the clocks have not yet expired and further moves are possible.
If the game is resolved successfully, the result is printed.
* `RPC_URL` - the RPC endpoint of the L1 endpoint to use (e.g. `http://localhost:8545`).
* `GAME_ADDRESS` - the address of the dispute game to resolve.
* `SIGNER_ARGS` the remaining args are past as arguments to `cast` when sending transactions.
These arguments must specify a way for `cast` to sign the transactions.
See `cast send --help` for supported options.
### [list_games.sh](scripts/list_games.sh)
```shell
./scripts/list_games.sh <RPC> <GAME_FACTORY_ADDRESS>
```
Prints the games created by the game factory along with their current status.
* `RPC_URL` - the RPC endpoint of the L1 endpoint to use (e.g. `http://localhost:8545`).
* `GAME_FACTORY_ADDRESS` - the address of the dispute game factory contract on L1.
### [list_claims.sh](scripts/list_claims.sh)
```shell
./scripts/list_claims.sh <RPC> <GAME_ADDR>
```
Prints the list of current claims in a dispute game.
* `RPC_URL` - the RPC endpoint of the L1 endpoint to use (e.g. `http://localhost:8545`).
* `GAME_ADDRESS` - the address of the dispute game to list the move in.
......@@ -254,6 +254,28 @@ func TestCannonSnapshotFreq(t *testing.T) {
cfg := configForArgs(t, addRequiredArgs(config.TraceTypeCannon, "--cannon-snapshot-freq=1234"))
require.Equal(t, uint(1234), cfg.CannonSnapshotFreq)
})
t.Run("Invalid", func(t *testing.T) {
verifyArgsInvalid(t, "invalid value \"abc\" for flag -cannon-snapshot-freq",
addRequiredArgs(config.TraceTypeCannon, "--cannon-snapshot-freq=abc"))
})
}
func TestCannonInfoFreq(t *testing.T) {
t.Run("UsesDefault", func(t *testing.T) {
cfg := configForArgs(t, addRequiredArgs(config.TraceTypeCannon))
require.Equal(t, config.DefaultCannonInfoFreq, cfg.CannonInfoFreq)
})
t.Run("Valid", func(t *testing.T) {
cfg := configForArgs(t, addRequiredArgs(config.TraceTypeCannon, "--cannon-info-freq=1234"))
require.Equal(t, uint(1234), cfg.CannonInfoFreq)
})
t.Run("Invalid", func(t *testing.T) {
verifyArgsInvalid(t, "invalid value \"abc\" for flag -cannon-info-freq",
addRequiredArgs(config.TraceTypeCannon, "--cannon-info-freq=abc"))
})
}
func TestGameWindow(t *testing.T) {
......
......@@ -26,6 +26,7 @@ var (
ErrMissingL1EthRPC = errors.New("missing l1 eth rpc url")
ErrMissingGameFactoryAddress = errors.New("missing game factory address")
ErrMissingCannonSnapshotFreq = errors.New("missing cannon snapshot freq")
ErrMissingCannonInfoFreq = errors.New("missing cannon info freq")
ErrMissingCannonRollupConfig = errors.New("missing cannon network or rollup config path")
ErrMissingCannonL2Genesis = errors.New("missing cannon network or l2 genesis path")
ErrCannonNetworkAndRollupConfig = errors.New("only specify one of network or rollup config path")
......@@ -78,6 +79,7 @@ func ValidTraceType(value TraceType) bool {
const (
DefaultCannonSnapshotFreq = uint(1_000_000_000)
DefaultCannonInfoFreq = uint(10_000_000)
// DefaultGameWindow is the default maximum time duration in the past
// that the challenger will look for games to progress.
// The default value is 11 days, which is a 4 day resolution buffer
......@@ -111,6 +113,7 @@ type Config struct {
CannonL2GenesisPath string
CannonL2 string // L2 RPC Url
CannonSnapshotFreq uint // Frequency of snapshots to create when executing cannon (in VM instructions)
CannonInfoFreq uint // Frequency of cannon progress log messages (in VM instructions)
TxMgrConfig txmgr.CLIConfig
MetricsConfig opmetrics.CLIConfig
......@@ -140,6 +143,7 @@ func NewConfig(
Datadir: datadir,
CannonSnapshotFreq: DefaultCannonSnapshotFreq,
CannonInfoFreq: DefaultCannonInfoFreq,
GameWindow: DefaultGameWindow,
}
}
......@@ -194,6 +198,9 @@ func (c Config) Check() error {
if c.CannonSnapshotFreq == 0 {
return ErrMissingCannonSnapshotFreq
}
if c.CannonInfoFreq == 0 {
return ErrMissingCannonInfoFreq
}
}
if c.TraceType == TraceTypeAlphabet && c.AlphabetTrace == "" {
return ErrMissingAlphabetTrace
......
......@@ -132,6 +132,14 @@ func TestCannonSnapshotFreq(t *testing.T) {
})
}
func TestCannonInfoFreq(t *testing.T) {
t.Run("MustNotBeZero", func(t *testing.T) {
cfg := validConfig(TraceTypeCannon)
cfg.CannonInfoFreq = 0
require.ErrorIs(t, cfg.Check(), ErrMissingCannonInfoFreq)
})
}
func TestCannonNetworkOrRollupConfigRequired(t *testing.T) {
cfg := validConfig(TraceTypeCannon)
cfg.CannonNetwork = ""
......
......@@ -116,6 +116,12 @@ var (
EnvVars: prefixEnvVars("CANNON_SNAPSHOT_FREQ"),
Value: config.DefaultCannonSnapshotFreq,
}
CannonInfoFreqFlag = &cli.UintFlag{
Name: "cannon-info-freq",
Usage: "Frequency of cannon info log messages to generate in VM steps (cannon trace type only)",
EnvVars: prefixEnvVars("CANNON_INFO_FREQ"),
Value: config.DefaultCannonInfoFreq,
}
GameWindowFlag = &cli.DurationFlag{
Name: "game-window",
Usage: "The time window which the challenger will look for games to progress.",
......@@ -146,6 +152,7 @@ var optionalFlags = []cli.Flag{
CannonPreStateFlag,
CannonL2Flag,
CannonSnapshotFreqFlag,
CannonInfoFreqFlag,
GameWindowFlag,
}
......@@ -250,6 +257,7 @@ func NewConfigFromCLI(ctx *cli.Context) (*config.Config, error) {
Datadir: ctx.String(DatadirFlag.Name),
CannonL2: ctx.String(CannonL2Flag.Name),
CannonSnapshotFreq: ctx.Uint(CannonSnapshotFreqFlag.Name),
CannonInfoFreq: ctx.Uint(CannonInfoFreqFlag.Name),
AgreeWithProposedOutput: ctx.Bool(AgreeWithProposedOutputFlag.Name),
TxMgrConfig: txMgrConfig,
MetricsConfig: metricsConfig,
......
......@@ -7,13 +7,15 @@ import (
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/solver"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-challenger/metrics"
"github.com/ethereum/go-ethereum/log"
)
// Responder takes a response action & executes.
// For full op-challenger this means executing the transaction on chain.
type Responder interface {
CallResolve(ctx context.Context) (types.GameStatus, error)
CallResolve(ctx context.Context) (gameTypes.GameStatus, error)
Resolve(ctx context.Context) error
Respond(ctx context.Context, response types.Claim) error
Step(ctx context.Context, stepData types.StepCallData) error
......@@ -24,6 +26,7 @@ type ClaimLoader interface {
}
type Agent struct {
metrics metrics.Metricer
solver *solver.Solver
loader ClaimLoader
responder Responder
......@@ -33,8 +36,9 @@ type Agent struct {
log log.Logger
}
func NewAgent(loader ClaimLoader, maxDepth int, trace types.TraceProvider, responder Responder, updater types.OracleUpdater, agreeWithProposedOutput bool, log log.Logger) *Agent {
func NewAgent(m metrics.Metricer, loader ClaimLoader, maxDepth int, trace types.TraceProvider, responder Responder, updater types.OracleUpdater, agreeWithProposedOutput bool, log log.Logger) *Agent {
return &Agent{
metrics: m,
solver: solver.NewSolver(maxDepth, trace),
loader: loader,
responder: responder,
......@@ -71,10 +75,10 @@ func (a *Agent) Act(ctx context.Context) error {
// shouldResolve returns true if the agent should resolve the game.
// This method will return false if the game is still in progress.
func (a *Agent) shouldResolve(ctx context.Context, status types.GameStatus) bool {
expected := types.GameStatusDefenderWon
func (a *Agent) shouldResolve(status gameTypes.GameStatus) bool {
expected := gameTypes.GameStatusDefenderWon
if a.agreeWithProposedOutput {
expected = types.GameStatusChallengerWon
expected = gameTypes.GameStatusChallengerWon
}
if expected != status {
a.log.Warn("Game will be lost", "expected", expected, "actual", status)
......@@ -82,20 +86,19 @@ func (a *Agent) shouldResolve(ctx context.Context, status types.GameStatus) bool
return expected == status
}
// tryResolve resolves the game if it is in a terminal state
// and returns true if the game resolves successfully.
// tryResolve resolves the game if it is in a winning state
// Returns true if the game is resolvable (regardless of whether it was actually resolved)
func (a *Agent) tryResolve(ctx context.Context) bool {
status, err := a.responder.CallResolve(ctx)
if err != nil {
if err != nil || status == gameTypes.GameStatusInProgress {
return false
}
if !a.shouldResolve(ctx, status) {
return false
if !a.shouldResolve(status) {
return true
}
a.log.Info("Resolving game")
if err := a.responder.Resolve(ctx); err != nil {
a.log.Error("Failed to resolve the game", "err", err)
return false
}
return true
}
......@@ -134,6 +137,7 @@ func (a *Agent) move(ctx context.Context, claim types.Claim, game types.Game) er
log.Debug("Skipping duplicate move")
return nil
}
a.metrics.RecordGameMove()
log.Info("Performing move")
return a.responder.Respond(ctx, move)
}
......@@ -170,6 +174,7 @@ func (a *Agent) step(ctx context.Context, claim types.Claim, game types.Game) er
a.log.Info("Performing step", "is_attack", step.IsAttack,
"depth", step.LeafClaim.Depth(), "index_at_depth", step.LeafClaim.IndexAtDepth(), "value", step.LeafClaim.Value)
a.metrics.RecordGameStep()
callData := types.StepCallData{
ClaimIndex: uint64(step.LeafClaim.ContractIndex),
IsAttack: step.IsAttack,
......
......@@ -2,9 +2,14 @@ package fault
import (
"context"
"errors"
"testing"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/test"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/alphabet"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-challenger/metrics"
"github.com/ethereum/go-ethereum/log"
"github.com/stretchr/testify/require"
......@@ -13,19 +18,143 @@ import (
// TestShouldResolve tests the resolution logic.
func TestShouldResolve(t *testing.T) {
log := testlog.Logger(t, log.LvlCrit)
t.Run("AgreeWithProposedOutput", func(t *testing.T) {
agent := NewAgent(nil, 0, nil, nil, nil, true, log)
require.False(t, agent.shouldResolve(context.Background(), types.GameStatusDefenderWon))
require.True(t, agent.shouldResolve(context.Background(), types.GameStatusChallengerWon))
require.False(t, agent.shouldResolve(context.Background(), types.GameStatusInProgress))
agent, _, _ := setupTestAgent(t, true)
require.False(t, agent.shouldResolve(gameTypes.GameStatusDefenderWon))
require.True(t, agent.shouldResolve(gameTypes.GameStatusChallengerWon))
require.False(t, agent.shouldResolve(gameTypes.GameStatusInProgress))
})
t.Run("DisagreeWithProposedOutput", func(t *testing.T) {
agent := NewAgent(nil, 0, nil, nil, nil, false, log)
require.True(t, agent.shouldResolve(context.Background(), types.GameStatusDefenderWon))
require.False(t, agent.shouldResolve(context.Background(), types.GameStatusChallengerWon))
require.False(t, agent.shouldResolve(context.Background(), types.GameStatusInProgress))
agent, _, _ := setupTestAgent(t, false)
require.True(t, agent.shouldResolve(gameTypes.GameStatusDefenderWon))
require.False(t, agent.shouldResolve(gameTypes.GameStatusChallengerWon))
require.False(t, agent.shouldResolve(gameTypes.GameStatusInProgress))
})
}
func TestDoNotMakeMovesWhenGameIsResolvable(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
agreeWithProposedOutput bool
callResolveStatus gameTypes.GameStatus
shouldResolve bool
}{
{
name: "Agree_Losing",
agreeWithProposedOutput: true,
callResolveStatus: gameTypes.GameStatusDefenderWon,
shouldResolve: false,
},
{
name: "Agree_Winning",
agreeWithProposedOutput: true,
callResolveStatus: gameTypes.GameStatusChallengerWon,
shouldResolve: true,
},
{
name: "Disagree_Losing",
agreeWithProposedOutput: false,
callResolveStatus: gameTypes.GameStatusChallengerWon,
shouldResolve: false,
},
{
name: "Disagree_Winning",
agreeWithProposedOutput: false,
callResolveStatus: gameTypes.GameStatusDefenderWon,
shouldResolve: true,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
agent, claimLoader, responder := setupTestAgent(t, test.agreeWithProposedOutput)
responder.callResolveStatus = test.callResolveStatus
require.NoError(t, agent.Act(ctx))
require.Equal(t, 1, responder.callResolveCount, "should check if game is resolvable")
require.Zero(t, claimLoader.callCount, "should not fetch claims for resolvable game")
if test.shouldResolve {
require.EqualValues(t, 1, responder.resolveCount, "should resolve winning game")
} else {
require.Zero(t, responder.resolveCount, "should not resolve losing game")
}
})
}
}
func TestLoadClaimsWhenGameNotResolvable(t *testing.T) {
// Checks that if the game isn't resolvable, that the agent continues on to start checking claims
agent, claimLoader, responder := setupTestAgent(t, false)
responder.callResolveErr = errors.New("game is not resolvable")
depth := 4
claimBuilder := test.NewClaimBuilder(t, depth, alphabet.NewTraceProvider("abcdefg", uint64(depth)))
claimLoader.claims = []types.Claim{
claimBuilder.CreateRootClaim(true),
}
require.NoError(t, agent.Act(context.Background()))
require.EqualValues(t, 1, claimLoader.callCount, "should load claims for unresolvable game")
}
func setupTestAgent(t *testing.T, agreeWithProposedOutput bool) (*Agent, *stubClaimLoader, *stubResponder) {
logger := testlog.Logger(t, log.LvlInfo)
claimLoader := &stubClaimLoader{}
depth := 4
trace := alphabet.NewTraceProvider("abcd", uint64(depth))
responder := &stubResponder{}
updater := &stubUpdater{}
agent := NewAgent(metrics.NoopMetrics, claimLoader, depth, trace, responder, updater, agreeWithProposedOutput, logger)
return agent, claimLoader, responder
}
type stubClaimLoader struct {
callCount int
claims []types.Claim
}
func (s *stubClaimLoader) FetchClaims(ctx context.Context) ([]types.Claim, error) {
s.callCount++
return s.claims, nil
}
type stubResponder struct {
callResolveCount int
callResolveStatus gameTypes.GameStatus
callResolveErr error
resolveCount int
resolveErr error
}
func (s *stubResponder) CallResolve(ctx context.Context) (gameTypes.GameStatus, error) {
s.callResolveCount++
return s.callResolveStatus, s.callResolveErr
}
func (s *stubResponder) Resolve(ctx context.Context) error {
s.resolveCount++
return s.resolveErr
}
func (s *stubResponder) Respond(ctx context.Context, response types.Claim) error {
panic("Not implemented")
}
func (s *stubResponder) Step(ctx context.Context, stepData types.StepCallData) error {
panic("Not implemented")
}
type stubUpdater struct {
}
func (s *stubUpdater) UpdateOracle(ctx context.Context, data *types.PreimageOracleData) error {
panic("Not implemented")
}
......@@ -6,6 +6,7 @@ import (
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
......@@ -49,9 +50,9 @@ func NewLoaderFromBindings(fdgAddr common.Address, client bind.ContractCaller) (
}
// GetGameStatus returns the current game status.
func (l *loader) GetGameStatus(ctx context.Context) (types.GameStatus, error) {
func (l *loader) GetGameStatus(ctx context.Context) (gameTypes.GameStatus, error) {
status, err := l.caller.Status(&bind.CallOpts{Context: ctx})
return types.GameStatus(status), err
return gameTypes.GameStatus(status), err
}
// GetClaimCount returns the number of claims in the game.
......@@ -138,16 +139,15 @@ func (l *loader) FetchClaims(ctx context.Context) ([]types.Claim, error) {
}
// FetchAbsolutePrestateHash fetches the hashed absolute prestate from the fault dispute game.
func (l *loader) FetchAbsolutePrestateHash(ctx context.Context) ([]byte, error) {
func (l *loader) FetchAbsolutePrestateHash(ctx context.Context) (common.Hash, error) {
callOpts := bind.CallOpts{
Context: ctx,
}
absolutePrestate, err := l.caller.ABSOLUTEPRESTATE(&callOpts)
if err != nil {
return nil, err
return common.Hash{}, err
}
returnValue := absolutePrestate[:]
return returnValue, nil
return absolutePrestate, nil
}
......@@ -7,6 +7,7 @@ import (
"testing"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
......@@ -30,15 +31,15 @@ func TestLoader_GetGameStatus(t *testing.T) {
}{
{
name: "challenger won status",
status: uint8(types.GameStatusChallengerWon),
status: uint8(gameTypes.GameStatusChallengerWon),
},
{
name: "defender won status",
status: uint8(types.GameStatusDefenderWon),
status: uint8(gameTypes.GameStatusDefenderWon),
},
{
name: "in progress status",
status: uint8(types.GameStatusInProgress),
status: uint8(gameTypes.GameStatusInProgress),
},
{
name: "error bubbled up",
......@@ -57,7 +58,7 @@ func TestLoader_GetGameStatus(t *testing.T) {
require.ErrorIs(t, err, mockStatusError)
} else {
require.NoError(t, err)
require.Equal(t, types.GameStatus(test.status), status)
require.Equal(t, gameTypes.GameStatus(test.status), status)
}
})
}
......
......@@ -11,17 +11,18 @@ import (
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/alphabet"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/cannon"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-challenger/metrics"
"github.com/ethereum-optimism/optimism/op-service/txmgr"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/log"
)
type actor func(ctx context.Context) error
type GameInfo interface {
GetGameStatus(context.Context) (types.GameStatus, error)
GetGameStatus(context.Context) (gameTypes.GameStatus, error)
GetClaimCount(context.Context) (uint64, error)
}
......@@ -30,13 +31,13 @@ type GamePlayer struct {
agreeWithProposedOutput bool
loader GameInfo
logger log.Logger
completed bool
status gameTypes.GameStatus
}
func NewGamePlayer(
ctx context.Context,
logger log.Logger,
m metrics.Metricer,
cfg *config.Config,
dir string,
addr common.Address,
......@@ -55,14 +56,14 @@ func NewGamePlayer(
if err != nil {
return nil, fmt.Errorf("failed to fetch game status: %w", err)
}
if status != types.GameStatusInProgress {
if status != gameTypes.GameStatusInProgress {
logger.Info("Game already resolved", "status", status)
// Game is already complete so skip creating the trace provider, loading game inputs etc.
return &GamePlayer{
logger: logger,
loader: loader,
agreeWithProposedOutput: cfg.AgreeWithProposedOutput,
completed: true,
status: status,
// Act function does nothing because the game is already complete
act: func(ctx context.Context) error {
return nil
......@@ -79,7 +80,7 @@ func NewGamePlayer(
var updater types.OracleUpdater
switch cfg.TraceType {
case config.TraceTypeCannon:
cannonProvider, err := cannon.NewTraceProvider(ctx, logger, cfg, client, dir, addr)
cannonProvider, err := cannon.NewTraceProvider(ctx, logger, m, cfg, client, dir, addr)
if err != nil {
return nil, fmt.Errorf("create cannon trace provider: %w", err)
}
......@@ -105,36 +106,36 @@ func NewGamePlayer(
}
return &GamePlayer{
act: NewAgent(loader, int(gameDepth), provider, responder, updater, cfg.AgreeWithProposedOutput, logger).Act,
act: NewAgent(m, loader, int(gameDepth), provider, responder, updater, cfg.AgreeWithProposedOutput, logger).Act,
agreeWithProposedOutput: cfg.AgreeWithProposedOutput,
loader: loader,
logger: logger,
completed: status != types.GameStatusInProgress,
status: status,
}, nil
}
func (g *GamePlayer) ProgressGame(ctx context.Context) bool {
if g.completed {
func (g *GamePlayer) ProgressGame(ctx context.Context) gameTypes.GameStatus {
if g.status != gameTypes.GameStatusInProgress {
// Game is already complete so don't try to perform further actions.
g.logger.Trace("Skipping completed game")
return true
return g.status
}
g.logger.Trace("Checking if actions are required")
if err := g.act(ctx); err != nil {
g.logger.Error("Error when acting on game", "err", err)
}
if status, err := g.loader.GetGameStatus(ctx); err != nil {
status, err := g.loader.GetGameStatus(ctx)
if err != nil {
g.logger.Warn("Unable to retrieve game status", "err", err)
} else {
g.logGameStatus(ctx, status)
g.completed = status != types.GameStatusInProgress
return g.completed
return gameTypes.GameStatusInProgress
}
return false
g.logGameStatus(ctx, status)
g.status = status
return status
}
func (g *GamePlayer) logGameStatus(ctx context.Context, status types.GameStatus) {
if status == types.GameStatusInProgress {
func (g *GamePlayer) logGameStatus(ctx context.Context, status gameTypes.GameStatus) {
if status == gameTypes.GameStatusInProgress {
claimCount, err := g.loader.GetClaimCount(ctx)
if err != nil {
g.logger.Error("Failed to get claim count for in progress game", "err", err)
......@@ -143,11 +144,11 @@ func (g *GamePlayer) logGameStatus(ctx context.Context, status types.GameStatus)
g.logger.Info("Game info", "claims", claimCount, "status", status)
return
}
var expectedStatus types.GameStatus
var expectedStatus gameTypes.GameStatus
if g.agreeWithProposedOutput {
expectedStatus = types.GameStatusChallengerWon
expectedStatus = gameTypes.GameStatusChallengerWon
} else {
expectedStatus = types.GameStatusDefenderWon
expectedStatus = gameTypes.GameStatusDefenderWon
}
if expectedStatus == status {
g.logger.Info("Game won", "status", status)
......@@ -157,22 +158,21 @@ func (g *GamePlayer) logGameStatus(ctx context.Context, status types.GameStatus)
}
type PrestateLoader interface {
FetchAbsolutePrestateHash(ctx context.Context) ([]byte, error)
FetchAbsolutePrestateHash(ctx context.Context) (common.Hash, error)
}
// ValidateAbsolutePrestate validates the absolute prestate of the fault game.
func ValidateAbsolutePrestate(ctx context.Context, trace types.TraceProvider, loader PrestateLoader) error {
providerPrestate, err := trace.AbsolutePreState(ctx)
providerPrestateHash, err := trace.AbsolutePreStateCommitment(ctx)
if err != nil {
return fmt.Errorf("failed to get the trace provider's absolute prestate: %w", err)
}
providerPrestateHash := crypto.Keccak256(providerPrestate)
onchainPrestate, err := loader.FetchAbsolutePrestateHash(ctx)
if err != nil {
return fmt.Errorf("failed to get the onchain absolute prestate: %w", err)
}
if !bytes.Equal(providerPrestateHash, onchainPrestate) {
return fmt.Errorf("trace provider's absolute prestate does not match onchain absolute prestate")
if !bytes.Equal(providerPrestateHash[:], onchainPrestate[:]) {
return fmt.Errorf("trace provider's absolute prestate does not match onchain absolute prestate: Provider: %s | Chain %s", providerPrestateHash.Hex(), onchainPrestate.Hex())
}
return nil
}
......@@ -6,7 +6,9 @@ import (
"fmt"
"testing"
"github.com/ethereum-optimism/optimism/cannon/mipsevm"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-node/testlog"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
......@@ -22,8 +24,8 @@ var (
func TestProgressGame_LogErrorFromAct(t *testing.T) {
handler, game, actor := setupProgressGameTest(t, true)
actor.actErr = errors.New("boom")
done := game.ProgressGame(context.Background())
require.False(t, done, "should not be done")
status := game.ProgressGame(context.Background())
require.Equal(t, gameTypes.GameStatusInProgress, status)
require.Equal(t, 1, actor.callCount, "should perform next actions")
errLog := handler.FindLog(log.LvlError, "Error when acting on game")
require.NotNil(t, errLog, "should log error")
......@@ -38,42 +40,42 @@ func TestProgressGame_LogErrorFromAct(t *testing.T) {
func TestProgressGame_LogGameStatus(t *testing.T) {
tests := []struct {
name string
status types.GameStatus
status gameTypes.GameStatus
agreeWithOutput bool
logLevel log.Lvl
logMsg string
}{
{
name: "GameLostAsDefender",
status: types.GameStatusChallengerWon,
status: gameTypes.GameStatusChallengerWon,
agreeWithOutput: false,
logLevel: log.LvlError,
logMsg: "Game lost",
},
{
name: "GameLostAsChallenger",
status: types.GameStatusDefenderWon,
status: gameTypes.GameStatusDefenderWon,
agreeWithOutput: true,
logLevel: log.LvlError,
logMsg: "Game lost",
},
{
name: "GameWonAsDefender",
status: types.GameStatusDefenderWon,
status: gameTypes.GameStatusDefenderWon,
agreeWithOutput: false,
logLevel: log.LvlInfo,
logMsg: "Game won",
},
{
name: "GameWonAsChallenger",
status: types.GameStatusChallengerWon,
status: gameTypes.GameStatusChallengerWon,
agreeWithOutput: true,
logLevel: log.LvlInfo,
logMsg: "Game won",
},
{
name: "GameInProgress",
status: types.GameStatusInProgress,
status: gameTypes.GameStatusInProgress,
agreeWithOutput: true,
logLevel: log.LvlInfo,
logMsg: "Game info",
......@@ -85,9 +87,9 @@ func TestProgressGame_LogGameStatus(t *testing.T) {
handler, game, gameState := setupProgressGameTest(t, test.agreeWithOutput)
gameState.status = test.status
done := game.ProgressGame(context.Background())
status := game.ProgressGame(context.Background())
require.Equal(t, 1, gameState.callCount, "should perform next actions")
require.Equal(t, test.status != types.GameStatusInProgress, done, "should be done when not in progress")
require.Equal(t, test.status, status)
errLog := handler.FindLog(test.logLevel, test.logMsg)
require.NotNil(t, errLog, "should log game result")
require.Equal(t, test.status, errLog.GetContextValue("status"))
......@@ -96,19 +98,19 @@ func TestProgressGame_LogGameStatus(t *testing.T) {
}
func TestDoNotActOnCompleteGame(t *testing.T) {
for _, status := range []types.GameStatus{types.GameStatusChallengerWon, types.GameStatusDefenderWon} {
for _, status := range []gameTypes.GameStatus{gameTypes.GameStatusChallengerWon, gameTypes.GameStatusDefenderWon} {
t.Run(status.String(), func(t *testing.T) {
_, game, gameState := setupProgressGameTest(t, true)
gameState.status = status
done := game.ProgressGame(context.Background())
fetched := game.ProgressGame(context.Background())
require.Equal(t, 1, gameState.callCount, "acts the first time")
require.True(t, done, "should be done")
require.Equal(t, status, fetched)
// Should not act when it knows the game is already complete
done = game.ProgressGame(context.Background())
fetched = game.ProgressGame(context.Background())
require.Equal(t, 1, gameState.callCount, "does not act after game is complete")
require.True(t, done, "should still be done")
require.Equal(t, status, fetched)
})
}
}
......@@ -119,8 +121,9 @@ func TestValidateAbsolutePrestate(t *testing.T) {
t.Run("ValidPrestates", func(t *testing.T) {
prestate := []byte{0x00, 0x01, 0x02, 0x03}
prestateHash := crypto.Keccak256(prestate)
prestateHash[0] = mipsevm.VMStatusUnfinished
mockTraceProvider := newMockTraceProvider(false, prestate)
mockLoader := newMockPrestateLoader(false, prestateHash)
mockLoader := newMockPrestateLoader(false, common.BytesToHash(prestateHash))
err := ValidateAbsolutePrestate(context.Background(), mockTraceProvider, mockLoader)
require.NoError(t, err)
})
......@@ -128,7 +131,7 @@ func TestValidateAbsolutePrestate(t *testing.T) {
t.Run("TraceProviderErrors", func(t *testing.T) {
prestate := []byte{0x00, 0x01, 0x02, 0x03}
mockTraceProvider := newMockTraceProvider(true, prestate)
mockLoader := newMockPrestateLoader(false, prestate)
mockLoader := newMockPrestateLoader(false, common.BytesToHash(prestate))
err := ValidateAbsolutePrestate(context.Background(), mockTraceProvider, mockLoader)
require.ErrorIs(t, err, mockTraceProviderError)
})
......@@ -136,14 +139,14 @@ func TestValidateAbsolutePrestate(t *testing.T) {
t.Run("LoaderErrors", func(t *testing.T) {
prestate := []byte{0x00, 0x01, 0x02, 0x03}
mockTraceProvider := newMockTraceProvider(false, prestate)
mockLoader := newMockPrestateLoader(true, prestate)
mockLoader := newMockPrestateLoader(true, common.BytesToHash(prestate))
err := ValidateAbsolutePrestate(context.Background(), mockTraceProvider, mockLoader)
require.ErrorIs(t, err, mockLoaderError)
})
t.Run("PrestateMismatch", func(t *testing.T) {
mockTraceProvider := newMockTraceProvider(false, []byte{0x00, 0x01, 0x02, 0x03})
mockLoader := newMockPrestateLoader(false, []byte{0x00})
mockLoader := newMockPrestateLoader(false, common.BytesToHash([]byte{0x00}))
err := ValidateAbsolutePrestate(context.Background(), mockTraceProvider, mockLoader)
require.Error(t, err)
})
......@@ -166,7 +169,7 @@ func setupProgressGameTest(t *testing.T, agreeWithProposedRoot bool) (*testlog.C
}
type stubGameState struct {
status types.GameStatus
status gameTypes.GameStatus
claimCount uint64
callCount int
actErr error
......@@ -178,7 +181,7 @@ func (s *stubGameState) Act(ctx context.Context) error {
return s.actErr
}
func (s *stubGameState) GetGameStatus(ctx context.Context) (types.GameStatus, error) {
func (s *stubGameState) GetGameStatus(ctx context.Context) (gameTypes.GameStatus, error) {
return s.status, nil
}
......@@ -209,21 +212,31 @@ func (m *mockTraceProvider) AbsolutePreState(ctx context.Context) ([]byte, error
}
return m.prestate, nil
}
func (m *mockTraceProvider) AbsolutePreStateCommitment(ctx context.Context) (common.Hash, error) {
prestate, err := m.AbsolutePreState(ctx)
if err != nil {
return common.Hash{}, err
}
hash := common.BytesToHash(crypto.Keccak256(prestate))
hash[0] = mipsevm.VMStatusUnfinished
return hash, nil
}
type mockLoader struct {
prestateError bool
prestate []byte
prestate common.Hash
}
func newMockPrestateLoader(prestateError bool, prestate []byte) *mockLoader {
func newMockPrestateLoader(prestateError bool, prestate common.Hash) *mockLoader {
return &mockLoader{
prestateError: prestateError,
prestate: prestate,
}
}
func (m *mockLoader) FetchAbsolutePrestateHash(ctx context.Context) ([]byte, error) {
func (m *mockLoader) FetchAbsolutePrestateHash(ctx context.Context) (common.Hash, error) {
if m.prestateError {
return nil, mockLoaderError
return common.Hash{}, mockLoaderError
}
return m.prestate, nil
}
......@@ -6,6 +6,7 @@ import (
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-service/txmgr"
"github.com/ethereum/go-ethereum"
......@@ -81,23 +82,23 @@ func (r *faultResponder) BuildTx(ctx context.Context, response types.Claim) ([]b
// CallResolve determines if the resolve function on the fault dispute game contract
// would succeed. Returns the game status if the call would succeed, errors otherwise.
func (r *faultResponder) CallResolve(ctx context.Context) (types.GameStatus, error) {
func (r *faultResponder) CallResolve(ctx context.Context) (gameTypes.GameStatus, error) {
txData, err := r.buildResolveData()
if err != nil {
return types.GameStatusInProgress, err
return gameTypes.GameStatusInProgress, err
}
res, err := r.txMgr.Call(ctx, ethereum.CallMsg{
To: &r.fdgAddr,
Data: txData,
}, nil)
if err != nil {
return types.GameStatusInProgress, err
return gameTypes.GameStatusInProgress, err
}
var status uint8
if err = r.fdgAbi.UnpackIntoInterface(&status, "resolve", res); err != nil {
return types.GameStatusInProgress, err
return gameTypes.GameStatusInProgress, err
}
return types.GameStatusFromUint8(status)
return gameTypes.GameStatusFromUint8(status)
}
// Resolve executes a resolve transaction to resolve a fault dispute game.
......
......@@ -8,6 +8,7 @@ import (
"github.com/ethereum-optimism/optimism/op-bindings/bindings"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-node/testlog"
"github.com/ethereum-optimism/optimism/op-service/txmgr"
......@@ -32,7 +33,7 @@ func TestCallResolve(t *testing.T) {
mockTxMgr.callFails = true
status, err := responder.CallResolve(context.Background())
require.ErrorIs(t, err, mockCallError)
require.Equal(t, types.GameStatusInProgress, status)
require.Equal(t, gameTypes.GameStatusInProgress, status)
require.Equal(t, 0, mockTxMgr.calls)
})
......@@ -41,7 +42,7 @@ func TestCallResolve(t *testing.T) {
mockTxMgr.callBytes = []byte{0x00, 0x01}
status, err := responder.CallResolve(context.Background())
require.Error(t, err)
require.Equal(t, types.GameStatusInProgress, status)
require.Equal(t, gameTypes.GameStatusInProgress, status)
require.Equal(t, 1, mockTxMgr.calls)
})
......@@ -49,7 +50,7 @@ func TestCallResolve(t *testing.T) {
responder, mockTxMgr := newTestFaultResponder(t)
status, err := responder.CallResolve(context.Background())
require.NoError(t, err)
require.Equal(t, types.GameStatusInProgress, status)
require.Equal(t, gameTypes.GameStatusInProgress, status)
require.Equal(t, 1, mockTxMgr.calls)
})
}
......
package solver
import (
"bytes"
"context"
"errors"
"fmt"
......@@ -132,7 +133,7 @@ func (s *Solver) defend(ctx context.Context, claim types.Claim) (*types.Claim, e
// agreeWithClaim returns true if the claim is correct according to the internal [TraceProvider].
func (s *Solver) agreeWithClaim(ctx context.Context, claim types.ClaimData) (bool, error) {
ourValue, err := s.traceAtPosition(ctx, claim.Position)
return ourValue == claim.Value, err
return bytes.Equal(ourValue[:], claim.Value[:]), err
}
// traceAtPosition returns the [common.Hash] from internal [TraceProvider] at the given [Position].
......
......@@ -6,6 +6,7 @@ import (
"math/big"
"strings"
"github.com/ethereum-optimism/optimism/cannon/mipsevm"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
......@@ -58,7 +59,7 @@ func (ap *AlphabetTraceProvider) Get(ctx context.Context, i uint64) (common.Hash
if err != nil {
return common.Hash{}, err
}
return crypto.Keccak256Hash(claimBytes), nil
return alphabetStateHash(claimBytes), nil
}
// AbsolutePreState returns the absolute pre-state for the alphabet trace.
......@@ -66,11 +67,27 @@ func (ap *AlphabetTraceProvider) AbsolutePreState(ctx context.Context) ([]byte,
return common.Hex2Bytes("0000000000000000000000000000000000000000000000000000000000000060"), nil
}
func (ap *AlphabetTraceProvider) AbsolutePreStateCommitment(ctx context.Context) (common.Hash, error) {
prestate, err := ap.AbsolutePreState(ctx)
if err != nil {
return common.Hash{}, err
}
hash := common.BytesToHash(crypto.Keccak256(prestate))
hash[0] = mipsevm.VMStatusUnfinished
return hash, nil
}
// BuildAlphabetPreimage constructs the claim bytes for the index and state item.
func BuildAlphabetPreimage(i uint64, letter string) []byte {
return append(IndexToBytes(i), LetterToBytes(letter)...)
}
func alphabetStateHash(state []byte) common.Hash {
h := crypto.Keccak256Hash(state)
h[0] = mipsevm.VMStatusInvalid
return h
}
// IndexToBytes converts an index to a byte slice big endian
func IndexToBytes(i uint64) []byte {
big := new(big.Int)
......
......@@ -6,12 +6,11 @@ import (
"testing"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/stretchr/testify/require"
)
func alphabetClaim(index uint64, letter string) common.Hash {
return crypto.Keccak256Hash(BuildAlphabetPreimage(index, letter))
return alphabetStateHash(BuildAlphabetPreimage(index, letter))
}
// TestAlphabetProvider_Get_ClaimsByTraceIndex tests the [fault.AlphabetProvider] Get function.
......@@ -60,7 +59,7 @@ func FuzzIndexToBytes(f *testing.F) {
// returns the correct pre-image for a index.
func TestGetStepData_Succeeds(t *testing.T) {
ap := NewTraceProvider("abc", 2)
expected := BuildAlphabetPreimage(0, "a'")
expected := BuildAlphabetPreimage(0, "a")
retrieved, proof, data, err := ap.GetStepData(context.Background(), uint64(1))
require.NoError(t, err)
require.Equal(t, expected, retrieved)
......
......@@ -11,6 +11,7 @@ import (
"regexp"
"strconv"
"strings"
"time"
"github.com/ethereum-optimism/optimism/op-challenger/config"
oplog "github.com/ethereum-optimism/optimism/op-service/log"
......@@ -30,6 +31,7 @@ type cmdExecutor func(ctx context.Context, l log.Logger, binary string, args ...
type Executor struct {
logger log.Logger
metrics CannonMetricer
l1 string
l2 string
inputs LocalGameInputs
......@@ -40,13 +42,15 @@ type Executor struct {
l2Genesis string
absolutePreState string
snapshotFreq uint
infoFreq uint
selectSnapshot snapshotSelect
cmdExecutor cmdExecutor
}
func NewExecutor(logger log.Logger, cfg *config.Config, inputs LocalGameInputs) *Executor {
func NewExecutor(logger log.Logger, m CannonMetricer, cfg *config.Config, inputs LocalGameInputs) *Executor {
return &Executor{
logger: logger,
metrics: m,
l1: cfg.L1EthRpc,
l2: cfg.CannonL2,
inputs: inputs,
......@@ -57,6 +61,7 @@ func NewExecutor(logger log.Logger, cfg *config.Config, inputs LocalGameInputs)
l2Genesis: cfg.CannonL2GenesisPath,
absolutePreState: cfg.CannonAbsolutePreState,
snapshotFreq: cfg.CannonSnapshotFreq,
infoFreq: cfg.CannonInfoFreq,
selectSnapshot: findStartingSnapshot,
cmdExecutor: runCmd,
}
......@@ -76,6 +81,7 @@ func (e *Executor) GenerateProof(ctx context.Context, dir string, i uint64) erro
"--input", start,
"--output", lastGeneratedState,
"--meta", "",
"--info-at", "%" + strconv.FormatUint(uint64(e.infoFreq), 10),
"--proof-at", "=" + strconv.FormatUint(i, 10),
"--proof-fmt", filepath.Join(proofDir, "%d.json.gz"),
"--snapshot-at", "%" + strconv.FormatUint(uint64(e.snapshotFreq), 10),
......@@ -116,7 +122,13 @@ func (e *Executor) GenerateProof(ctx context.Context, dir string, i uint64) erro
return fmt.Errorf("could not create proofs directory %v: %w", proofDir, err)
}
e.logger.Info("Generating trace", "proof", i, "cmd", e.cannon, "args", strings.Join(args, ", "))
return e.cmdExecutor(ctx, e.logger.New("proof", i), e.cannon, args...)
execStart := time.Now()
err = e.cmdExecutor(ctx, e.logger.New("proof", i), e.cannon, args...)
if err != nil {
execDuration := time.Since(execStart).Seconds()
e.metrics.RecordCannonExecutionTime(execDuration)
}
return err
}
func runCmd(ctx context.Context, l log.Logger, binary string, args ...string) error {
......
......@@ -11,6 +11,7 @@ import (
"time"
"github.com/ethereum-optimism/optimism/op-challenger/config"
"github.com/ethereum-optimism/optimism/op-challenger/metrics"
"github.com/ethereum-optimism/optimism/op-node/testlog"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
......@@ -29,6 +30,7 @@ func TestGenerateProof(t *testing.T) {
cfg.CannonServer = "./bin/op-program"
cfg.CannonL2 = "http://localhost:9999"
cfg.CannonSnapshotFreq = 500
cfg.CannonInfoFreq = 900
inputs := LocalGameInputs{
L1Head: common.Hash{0x11},
......@@ -38,7 +40,7 @@ func TestGenerateProof(t *testing.T) {
L2BlockNumber: big.NewInt(3333),
}
captureExec := func(t *testing.T, cfg config.Config, proofAt uint64) (string, string, map[string]string) {
executor := NewExecutor(testlog.Logger(t, log.LvlInfo), &cfg, inputs)
executor := NewExecutor(testlog.Logger(t, log.LvlInfo), metrics.NoopMetrics, &cfg, inputs)
executor.selectSnapshot = func(logger log.Logger, dir string, absolutePreState string, i uint64) (string, error) {
return input, nil
}
......@@ -81,6 +83,7 @@ func TestGenerateProof(t *testing.T) {
require.Equal(t, "=150000000", args["--proof-at"])
require.Equal(t, "=150000001", args["--stop-at"])
require.Equal(t, "%500", args["--snapshot-at"])
require.Equal(t, "%900", args["--info-at"])
// Slight quirk of how we pair off args
// The server binary winds up as the key and the first arg --server as the value which has no value
// Then everything else pairs off correctly again
......
......@@ -15,9 +15,10 @@ import (
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/cannon/mipsevm"
)
const (
......@@ -25,7 +26,7 @@ const (
)
type proofData struct {
ClaimValue hexutil.Bytes `json:"post"`
ClaimValue common.Hash `json:"post"`
StateData hexutil.Bytes `json:"state-data"`
ProofData hexutil.Bytes `json:"proof-data"`
OracleKey hexutil.Bytes `json:"oracle-key,omitempty"`
......@@ -33,6 +34,10 @@ type proofData struct {
OracleOffset uint32 `json:"oracle-offset,omitempty"`
}
type CannonMetricer interface {
RecordCannonExecutionTime(t float64)
}
type ProofGenerator interface {
// GenerateProof executes cannon to generate a proof at the specified trace index in dataDir.
GenerateProof(ctx context.Context, dataDir string, proofAt uint64) error
......@@ -51,7 +56,7 @@ type CannonTraceProvider struct {
lastProof *proofData
}
func NewTraceProvider(ctx context.Context, logger log.Logger, cfg *config.Config, l1Client bind.ContractCaller, dir string, gameAddr common.Address) (*CannonTraceProvider, error) {
func NewTraceProvider(ctx context.Context, logger log.Logger, m CannonMetricer, cfg *config.Config, l1Client bind.ContractCaller, dir string, gameAddr common.Address) (*CannonTraceProvider, error) {
l2Client, err := ethclient.DialContext(ctx, cfg.CannonL2)
if err != nil {
return nil, fmt.Errorf("dial l2 client %v: %w", cfg.CannonL2, err)
......@@ -65,15 +70,15 @@ func NewTraceProvider(ctx context.Context, logger log.Logger, cfg *config.Config
if err != nil {
return nil, fmt.Errorf("fetch local game inputs: %w", err)
}
return NewTraceProviderFromInputs(logger, cfg, localInputs, dir), nil
return NewTraceProviderFromInputs(logger, m, cfg, localInputs, dir), nil
}
func NewTraceProviderFromInputs(logger log.Logger, cfg *config.Config, localInputs LocalGameInputs, dir string) *CannonTraceProvider {
func NewTraceProviderFromInputs(logger log.Logger, m CannonMetricer, cfg *config.Config, localInputs LocalGameInputs, dir string) *CannonTraceProvider {
return &CannonTraceProvider{
logger: logger,
dir: dir,
prestate: cfg.CannonAbsolutePreState,
generator: NewExecutor(logger, cfg, localInputs),
generator: NewExecutor(logger, m, cfg, localInputs),
}
}
......@@ -82,7 +87,7 @@ func (p *CannonTraceProvider) Get(ctx context.Context, i uint64) (common.Hash, e
if err != nil {
return common.Hash{}, err
}
value := common.BytesToHash(proof.ClaimValue)
value := proof.ClaimValue
if value == (common.Hash{}) {
return common.Hash{}, errors.New("proof missing post hash")
......@@ -118,6 +123,18 @@ func (p *CannonTraceProvider) AbsolutePreState(ctx context.Context) ([]byte, err
return state.EncodeWitness(), nil
}
func (p *CannonTraceProvider) AbsolutePreStateCommitment(ctx context.Context) (common.Hash, error) {
state, err := p.AbsolutePreState(ctx)
if err != nil {
return common.Hash{}, fmt.Errorf("cannot load absolute pre-state: %w", err)
}
hash, err := mipsevm.StateWitness(state).StateHash()
if err != nil {
return common.Hash{}, fmt.Errorf("cannot hash absolute pre-state: %w", err)
}
return hash, nil
}
// loadProof will attempt to load or generate the proof data at the specified index
// If the requested index is beyond the end of the actual trace it is extended with no-op instructions.
func (p *CannonTraceProvider) loadProof(ctx context.Context, i uint64) (*proofData, error) {
......@@ -147,9 +164,13 @@ func (p *CannonTraceProvider) loadProof(ctx context.Context, i uint64) (*proofDa
// Extend the trace out to the full length using a no-op instruction that doesn't change any state
// No execution is done, so no proof-data or oracle values are required.
witness := state.EncodeWitness()
witnessHash, err := mipsevm.StateWitness(witness).StateHash()
if err != nil {
return nil, fmt.Errorf("cannot hash witness: %w", err)
}
proof := &proofData{
ClaimValue: crypto.Keccak256(witness),
StateData: witness,
ClaimValue: witnessHash,
StateData: hexutil.Bytes(witness),
ProofData: []byte{},
OracleKey: nil,
OracleValue: nil,
......
......@@ -15,7 +15,6 @@ import (
"github.com/ethereum-optimism/optimism/op-node/testlog"
"github.com/ethereum-optimism/optimism/op-service/ioutil"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/log"
"github.com/stretchr/testify/require"
)
......@@ -43,7 +42,9 @@ func TestGet(t *testing.T) {
value, err := provider.Get(context.Background(), 7000)
require.NoError(t, err)
require.Contains(t, generator.generated, 7000, "should have tried to generate the proof")
require.Equal(t, crypto.Keccak256Hash(generator.finalState.EncodeWitness()), value)
stateHash, err := generator.finalState.EncodeWitness().StateHash()
require.NoError(t, err)
require.Equal(t, stateHash, value)
})
t.Run("MissingPostHash", func(t *testing.T) {
......@@ -86,7 +87,7 @@ func TestGetStepData(t *testing.T) {
Exited: true,
}
generator.proof = &proofData{
ClaimValue: common.Hash{0xaa}.Bytes(),
ClaimValue: common.Hash{0xaa},
StateData: []byte{0xbb},
ProofData: []byte{0xcc},
OracleKey: common.Hash{0xdd}.Bytes(),
......@@ -111,7 +112,7 @@ func TestGetStepData(t *testing.T) {
Exited: true,
}
generator.proof = &proofData{
ClaimValue: common.Hash{0xaa}.Bytes(),
ClaimValue: common.Hash{0xaa},
StateData: []byte{0xbb},
ProofData: []byte{0xcc},
OracleKey: common.Hash{0xdd}.Bytes(),
......@@ -185,7 +186,7 @@ func TestAbsolutePreState(t *testing.T) {
Step: 0,
Registers: [32]uint32{},
}
require.Equal(t, state.EncodeWitness(), preState)
require.Equal(t, []byte(state.EncodeWitness()), preState)
})
}
......
......@@ -3,7 +3,6 @@ package types
import (
"context"
"errors"
"fmt"
"math/big"
"github.com/ethereum/go-ethereum/common"
......@@ -13,36 +12,6 @@ var (
ErrGameDepthReached = errors.New("game depth reached")
)
type GameStatus uint8
const (
GameStatusInProgress GameStatus = iota
GameStatusChallengerWon
GameStatusDefenderWon
)
// String returns the string representation of the game status.
func (s GameStatus) String() string {
switch s {
case GameStatusInProgress:
return "In Progress"
case GameStatusChallengerWon:
return "Challenger Won"
case GameStatusDefenderWon:
return "Defender Won"
default:
return "Unknown"
}
}
// GameStatusFromUint8 returns a game status from the uint8 representation.
func GameStatusFromUint8(i uint8) (GameStatus, error) {
if i > 2 {
return GameStatus(i), fmt.Errorf("invalid game status: %d", i)
}
return GameStatus(i), nil
}
// PreimageOracleData encapsulates the preimage oracle data
// to load into the onchain oracle.
type PreimageOracleData struct {
......@@ -105,6 +74,9 @@ type TraceProvider interface {
// AbsolutePreState is the pre-image value of the trace that transitions to the trace value at index 0
AbsolutePreState(ctx context.Context) (preimage []byte, err error)
// AbsolutePreStateCommitment is the commitment of the pre-image value of the trace that transitions to the trace value at index 0
AbsolutePreStateCommitment(ctx context.Context) (hash common.Hash, err error)
}
// ClaimData is the core of a claim. It must be unique inside a specific game.
......
package types
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
var validGameStatuses = []GameStatus{
GameStatusInProgress,
GameStatusChallengerWon,
GameStatusDefenderWon,
}
func TestGameStatusFromUint8(t *testing.T) {
for _, status := range validGameStatuses {
t.Run(fmt.Sprintf("Valid Game Status %v", status), func(t *testing.T) {
parsed, err := GameStatusFromUint8(uint8(status))
require.NoError(t, err)
require.Equal(t, status, parsed)
})
}
t.Run("Invalid", func(t *testing.T) {
status, err := GameStatusFromUint8(3)
require.Error(t, err)
require.Equal(t, GameStatus(3), status)
})
}
func TestNewPreimageOracleData(t *testing.T) {
t.Run("LocalData", func(t *testing.T) {
data := NewPreimageOracleData([]byte{1, 2, 3}, []byte{4, 5, 6}, 7)
......
......@@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
"golang.org/x/exp/slices"
......@@ -17,7 +19,7 @@ type PlayerCreator func(address common.Address, dir string) (GamePlayer, error)
type gameState struct {
player GamePlayer
inflight bool
resolved bool
status types.GameStatus
}
// coordinator manages the set of current games, queues games to be played (on separate worker threads) and
......@@ -31,6 +33,7 @@ type coordinator struct {
resultQueue <-chan job
logger log.Logger
m SchedulerMetricer
createPlayer PlayerCreator
states map[common.Address]*gameState
disk DiskManager
......@@ -49,18 +52,36 @@ func (c *coordinator) schedule(ctx context.Context, games []common.Address) erro
}
}
var gamesInProgress int
var gamesChallengerWon int
var gamesDefenderWon int
var errs []error
var jobs []job
// Next collect all the jobs to schedule and ensure all games are recorded in the states map.
// Otherwise, results may start being processed before all games are recorded, resulting in existing
// data directories potentially being deleted for games that are required.
var jobs []job
for _, addr := range games {
if j, err := c.createJob(addr); err != nil {
errs = append(errs, err)
} else if j != nil {
jobs = append(jobs, *j)
c.m.RecordGameUpdateScheduled()
}
state, ok := c.states[addr]
if ok {
switch state.status {
case types.GameStatusInProgress:
gamesInProgress++
case types.GameStatusDefenderWon:
gamesDefenderWon++
case types.GameStatusChallengerWon:
gamesChallengerWon++
}
} else {
c.logger.Warn("Game not found in states map", "game", addr)
}
}
c.m.RecordGamesStatus(gamesInProgress, gamesChallengerWon, gamesDefenderWon)
// Finally, enqueue the jobs
for _, j := range jobs {
......@@ -114,15 +135,16 @@ func (c *coordinator) processResult(j job) error {
return fmt.Errorf("game %v received unexpected result: %w", j.addr, errUnknownGame)
}
state.inflight = false
state.resolved = j.resolved
state.status = j.status
c.deleteResolvedGameFiles()
c.m.RecordGameUpdateCompleted()
return nil
}
func (c *coordinator) deleteResolvedGameFiles() {
var keepGames []common.Address
for addr, state := range c.states {
if !state.resolved || state.inflight {
if state.status == types.GameStatusInProgress || state.inflight {
keepGames = append(keepGames, addr)
}
}
......@@ -131,9 +153,10 @@ func (c *coordinator) deleteResolvedGameFiles() {
}
}
func newCoordinator(logger log.Logger, jobQueue chan<- job, resultQueue <-chan job, createPlayer PlayerCreator, disk DiskManager) *coordinator {
func newCoordinator(logger log.Logger, m SchedulerMetricer, jobQueue chan<- job, resultQueue <-chan job, createPlayer PlayerCreator, disk DiskManager) *coordinator {
return &coordinator{
logger: logger,
m: m,
jobQueue: jobQueue,
resultQueue: resultQueue,
createPlayer: createPlayer,
......
......@@ -5,6 +5,8 @@ import (
"fmt"
"testing"
"github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-challenger/metrics"
"github.com/ethereum-optimism/optimism/op-node/testlog"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
......@@ -140,7 +142,7 @@ func TestDeleteDataForResolvedGames(t *testing.T) {
require.NoError(t, c.schedule(ctx, []common.Address{gameAddr3}))
require.Len(t, workQueue, 1)
j := <-workQueue
j.resolved = true
j.status = types.GameStatusDefenderWon
require.NoError(t, c.processResult(j))
// But ensure its data directory is marked as existing
disk.DirForGame(gameAddr3)
......@@ -155,7 +157,9 @@ func TestDeleteDataForResolvedGames(t *testing.T) {
// Game 3 hasn't yet progressed (update is still in flight)
for i := 0; i < len(gameAddrs)-1; i++ {
j := <-workQueue
j.resolved = j.addr == gameAddr2
if j.addr == gameAddr2 {
j.status = types.GameStatusDefenderWon
}
require.NoError(t, c.processResult(j))
}
......@@ -229,20 +233,20 @@ func setupCoordinatorTest(t *testing.T, bufferSize int) (*coordinator, <-chan jo
created: make(map[common.Address]*stubGame),
}
disk := &stubDiskManager{gameDirExists: make(map[common.Address]bool)}
c := newCoordinator(logger, workQueue, resultQueue, games.CreateGame, disk)
c := newCoordinator(logger, metrics.NoopMetrics, workQueue, resultQueue, games.CreateGame, disk)
return c, workQueue, resultQueue, games, disk
}
type stubGame struct {
addr common.Address
progressCount int
done bool
status types.GameStatus
dir string
}
func (g *stubGame) ProgressGame(_ context.Context) bool {
func (g *stubGame) ProgressGame(_ context.Context) types.GameStatus {
g.progressCount++
return g.done
return g.status
}
type createdGames struct {
......@@ -259,9 +263,13 @@ func (c *createdGames) CreateGame(addr common.Address, dir string) (GamePlayer,
if _, exists := c.created[addr]; exists {
c.t.Fatalf("game %v already exists", addr)
}
status := types.GameStatusInProgress
if addr == c.createCompleted {
status = types.GameStatusDefenderWon
}
game := &stubGame{
addr: addr,
done: addr == c.createCompleted,
status: status,
dir: dir,
}
c.created[addr] = game
......
......@@ -11,6 +11,12 @@ import (
var ErrBusy = errors.New("busy scheduling previous update")
type SchedulerMetricer interface {
RecordGamesStatus(inProgress, defenderWon, challengerWon int)
RecordGameUpdateScheduled()
RecordGameUpdateCompleted()
}
type Scheduler struct {
logger log.Logger
coordinator *coordinator
......@@ -22,7 +28,7 @@ type Scheduler struct {
cancel func()
}
func NewScheduler(logger log.Logger, disk DiskManager, maxConcurrency uint, createPlayer PlayerCreator) *Scheduler {
func NewScheduler(logger log.Logger, m SchedulerMetricer, disk DiskManager, maxConcurrency uint, createPlayer PlayerCreator) *Scheduler {
// Size job and results queues to be fairly small so backpressure is applied early
// but with enough capacity to keep the workers busy
jobQueue := make(chan job, maxConcurrency*2)
......@@ -34,7 +40,7 @@ func NewScheduler(logger log.Logger, disk DiskManager, maxConcurrency uint, crea
return &Scheduler{
logger: logger,
coordinator: newCoordinator(logger, jobQueue, resultQueue, createPlayer, disk),
coordinator: newCoordinator(logger, m, jobQueue, resultQueue, createPlayer, disk),
maxConcurrency: maxConcurrency,
scheduleQueue: scheduleQueue,
jobQueue: jobQueue,
......
......@@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/ethereum-optimism/optimism/op-challenger/metrics"
"github.com/ethereum-optimism/optimism/op-node/testlog"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
......@@ -18,7 +19,7 @@ func TestSchedulerProcessesGames(t *testing.T) {
}
removeExceptCalls := make(chan []common.Address)
disk := &trackingDiskManager{removeExceptCalls: removeExceptCalls}
s := NewScheduler(logger, disk, 2, createPlayer)
s := NewScheduler(logger, metrics.NoopMetrics, disk, 2, createPlayer)
s.Start(ctx)
gameAddr1 := common.Address{0xaa}
......@@ -46,7 +47,7 @@ func TestReturnBusyWhenScheduleQueueFull(t *testing.T) {
}
removeExceptCalls := make(chan []common.Address)
disk := &trackingDiskManager{removeExceptCalls: removeExceptCalls}
s := NewScheduler(logger, disk, 2, createPlayer)
s := NewScheduler(logger, metrics.NoopMetrics, disk, 2, createPlayer)
// Scheduler not started - first call fills the queue
require.NoError(t, s.Schedule([]common.Address{{0xaa}}))
......
......@@ -4,10 +4,12 @@ import (
"context"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum-optimism/optimism/op-challenger/game/types"
)
type GamePlayer interface {
ProgressGame(ctx context.Context) bool
ProgressGame(ctx context.Context) types.GameStatus
}
type DiskManager interface {
......@@ -18,5 +20,5 @@ type DiskManager interface {
type job struct {
addr common.Address
player GamePlayer
resolved bool
status types.GameStatus
}
......@@ -15,7 +15,7 @@ func progressGames(ctx context.Context, in <-chan job, out chan<- job, wg *sync.
case <-ctx.Done():
return
case j := <-in:
j.resolved = j.player.ProgressGame(ctx)
j.status = j.player.ProgressGame(ctx)
out <- j
}
}
......
......@@ -6,6 +6,8 @@ import (
"testing"
"time"
"github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/stretchr/testify/require"
)
......@@ -20,17 +22,17 @@ func TestWorkerShouldProcessJobsUntilContextDone(t *testing.T) {
go progressGames(ctx, in, out, &wg)
in <- job{
player: &stubPlayer{done: false},
player: &stubPlayer{status: types.GameStatusInProgress},
}
in <- job{
player: &stubPlayer{done: true},
player: &stubPlayer{status: types.GameStatusDefenderWon},
}
result1 := readWithTimeout(t, out)
result2 := readWithTimeout(t, out)
require.Equal(t, result1.resolved, false)
require.Equal(t, result2.resolved, true)
require.Equal(t, result1.status, types.GameStatusInProgress)
require.Equal(t, result2.status, types.GameStatusDefenderWon)
// Cancel the context which should exit the worker
cancel()
......@@ -38,11 +40,11 @@ func TestWorkerShouldProcessJobsUntilContextDone(t *testing.T) {
}
type stubPlayer struct {
done bool
status types.GameStatus
}
func (s *stubPlayer) ProgressGame(ctx context.Context) bool {
return s.done
func (s *stubPlayer) ProgressGame(ctx context.Context) types.GameStatus {
return s.status
}
func readWithTimeout[T any](t *testing.T, ch <-chan T) T {
......
......@@ -69,10 +69,11 @@ func NewService(ctx context.Context, logger log.Logger, cfg *config.Config) (*Se
disk := newDiskManager(cfg.Datadir)
sched := scheduler.NewScheduler(
logger,
m,
disk,
cfg.MaxConcurrency,
func(addr common.Address, dir string) (scheduler.GamePlayer, error) {
return fault.NewGamePlayer(ctx, logger, cfg, dir, addr, txMgr, client)
return fault.NewGamePlayer(ctx, logger, m, cfg, dir, addr, txMgr, client)
})
monitor := newGameMonitor(logger, cl, loader, sched, cfg.GameWindow, client.BlockNumber, cfg.GameAllowlist)
......
package types
import (
"fmt"
)
type GameStatus uint8
const (
GameStatusInProgress GameStatus = iota
GameStatusChallengerWon
GameStatusDefenderWon
)
// String returns the string representation of the game status.
func (s GameStatus) String() string {
switch s {
case GameStatusInProgress:
return "In Progress"
case GameStatusChallengerWon:
return "Challenger Won"
case GameStatusDefenderWon:
return "Defender Won"
default:
return "Unknown"
}
}
// GameStatusFromUint8 returns a game status from the uint8 representation.
func GameStatusFromUint8(i uint8) (GameStatus, error) {
if i > 2 {
return GameStatus(i), fmt.Errorf("invalid game status: %d", i)
}
return GameStatus(i), nil
}
package types
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
var validGameStatuses = []GameStatus{
GameStatusInProgress,
GameStatusChallengerWon,
GameStatusDefenderWon,
}
func TestGameStatusFromUint8(t *testing.T) {
for _, status := range validGameStatuses {
t.Run(fmt.Sprintf("Valid Game Status %v", status), func(t *testing.T) {
parsed, err := GameStatusFromUint8(uint8(status))
require.NoError(t, err)
require.Equal(t, status, parsed)
})
}
t.Run("Invalid", func(t *testing.T) {
status, err := GameStatusFromUint8(3)
require.Error(t, err)
require.Equal(t, GameStatus(3), status)
})
}
......@@ -20,6 +20,15 @@ type Metricer interface {
// Record Tx metrics
txmetrics.TxMetricer
RecordGameStep()
RecordGameMove()
RecordCannonExecutionTime(t float64)
RecordGamesStatus(inProgress, defenderWon, challengerWon int)
RecordGameUpdateScheduled()
RecordGameUpdateCompleted()
}
type Metrics struct {
......@@ -31,6 +40,14 @@ type Metrics struct {
info prometheus.GaugeVec
up prometheus.Gauge
moves prometheus.Counter
steps prometheus.Counter
cannonExecutionTime prometheus.Histogram
trackedGames prometheus.GaugeVec
inflightGames prometheus.Gauge
}
var _ Metricer = (*Metrics)(nil)
......@@ -58,6 +75,36 @@ func NewMetrics() *Metrics {
Name: "up",
Help: "1 if the op-challenger has finished starting up",
}),
moves: factory.NewCounter(prometheus.CounterOpts{
Namespace: Namespace,
Name: "moves",
Help: "Number of game moves made by the challenge agent",
}),
steps: factory.NewCounter(prometheus.CounterOpts{
Namespace: Namespace,
Name: "steps",
Help: "Number of game steps made by the challenge agent",
}),
cannonExecutionTime: factory.NewHistogram(prometheus.HistogramOpts{
Namespace: Namespace,
Name: "cannon_execution_time",
Help: "Time (in seconds) to execute cannon",
Buckets: append(
[]float64{1.0, 10.0},
prometheus.ExponentialBuckets(30.0, 2.0, 14)...),
}),
trackedGames: *factory.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Name: "tracked_games",
Help: "Number of games being tracked by the challenger",
}, []string{
"status",
}),
inflightGames: factory.NewGauge(prometheus.GaugeOpts{
Namespace: Namespace,
Name: "inflight_games",
Help: "Number of games being tracked by the challenger",
}),
}
}
......@@ -65,7 +112,12 @@ 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) {
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)
}
......@@ -84,3 +136,29 @@ func (m *Metrics) RecordUp() {
func (m *Metrics) Document() []opmetrics.DocumentedMetric {
return m.factory.Document()
}
func (m *Metrics) RecordGameMove() {
m.moves.Add(1)
}
func (m *Metrics) RecordGameStep() {
m.steps.Add(1)
}
func (m *Metrics) RecordCannonExecutionTime(t float64) {
m.cannonExecutionTime.Observe(t)
}
func (m *Metrics) RecordGamesStatus(inProgress, defenderWon, challengerWon int) {
m.trackedGames.WithLabelValues("in_progress").Set(float64(inProgress))
m.trackedGames.WithLabelValues("defender_won").Set(float64(defenderWon))
m.trackedGames.WithLabelValues("challenger_won").Set(float64(challengerWon))
}
func (m *Metrics) RecordGameUpdateScheduled() {
m.inflightGames.Add(1)
}
func (m *Metrics) RecordGameUpdateCompleted() {
m.inflightGames.Sub(1)
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
#!/bin/bash
set -euo pipefail
RPC=${1:?Must specify RPC URL}
GAME_ADDR=${2:?Must specify game address}
SIGNER_ARGS="${@:3}"
# Perform the move.
RESULT_DATA=$(cast send --rpc-url "${RPC}" ${SIGNER_ARGS} "${GAME_ADDR}" "resolve()" --json)
RESULT=$(echo "${RESULT_DATA}" | jq -r '.logs[0].topics[1]' | cast to-dec)
if [[ "${RESULT}" == "0" ]]
then
RESULT="In Progress"
elif [[ "${RESULT}" == "1" ]]
then
RESULT="Challenger Wins"
elif [[ "${RESULT}" == "2" ]]
then
RESULT="Defender Wins"
fi
echo "Result: $RESULT"
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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