From 6df470c8d543c93158add86dfaa146876a4e36a7 Mon Sep 17 00:00:00 2001 From: ClaytonNorthey92 Date: Tue, 24 Mar 2026 10:21:27 -0400 Subject: [PATCH] fix: update op-devstack when merging the last cherry-pick PR, some needed changes were missed from op-devstack. use `git checkout upstream/develop ...` for the files that were needed, to avoid another large cherry pick --- go.mod | 4 +- go.sum | 52 +- op-deployer/pkg/deployer/apply.go | 2 - op-devstack/dsl/bridge.go | 580 +++++++++++++++++ op-devstack/dsl/check.go | 86 +++ op-devstack/dsl/common.go | 36 ++ op-devstack/dsl/conductor.go | 107 ++++ op-devstack/dsl/contract/call.go | 77 +++ op-devstack/dsl/doc.go | 11 + op-devstack/dsl/ecotone_fees.go | 201 ++++++ op-devstack/dsl/el.go | 132 ++++ op-devstack/dsl/engine.go | 115 ++++ op-devstack/dsl/eoa.go | 19 +- op-devstack/dsl/faucet.go | 50 ++ op-devstack/dsl/fjord_fees.go | 369 +++++++++++ op-devstack/dsl/funder.go | 76 +++ op-devstack/dsl/hd_wallet.go | 74 +++ op-devstack/dsl/invalid_msg.go | 52 ++ op-devstack/dsl/key.go | 53 ++ op-devstack/dsl/l1_cl.go | 38 ++ op-devstack/dsl/l1_el.go | 125 ++++ op-devstack/dsl/l1_network.go | 81 +++ op-devstack/dsl/l2_batcher.go | 56 ++ op-devstack/dsl/l2_challenger.go | 26 + op-devstack/dsl/l2_cl.go | 518 +++++++++++++++ op-devstack/dsl/l2_el.go | 25 +- op-devstack/dsl/l2_network.go | 552 ++++++++++++++++ op-devstack/dsl/l2_op_rbuilder.go | 61 ++ op-devstack/dsl/l2_proposer.go | 26 + op-devstack/dsl/multi_client.go | 237 +++++++ op-devstack/dsl/multi_client_test.go | 92 +++ op-devstack/dsl/operator_fee.go | 260 ++++++++ op-devstack/dsl/opts.go | 8 + op-devstack/dsl/params.go | 5 + op-devstack/dsl/proofs/claim.go | 123 ++++ .../dsl/proofs/dispute_game_factory.go | 567 +++++++++++++++++ op-devstack/dsl/proofs/fault_dispute_game.go | 208 ++++++ op-devstack/dsl/proofs/game_helper.go | 394 ++++++++++++ .../dsl/proofs/super_fault_dispute_game.go | 33 + op-devstack/dsl/rollup_boost.go | 37 ++ op-devstack/dsl/safedb.go | 38 ++ op-devstack/dsl/sequencer.go | 71 +++ op-devstack/dsl/supernode.go | 167 +++++ op-devstack/dsl/supervisor.go | 228 +++++++ op-devstack/dsl/sync_tester.go | 48 ++ op-devstack/presets/flashblocks.go | 176 ++++++ op-devstack/presets/interop.go | 213 +++++++ op-devstack/presets/interop_from_runtime.go | 213 +++++++ op-devstack/presets/minimal.go | 72 +++ op-devstack/presets/minimal_from_runtime.go | 76 +++ .../presets/minimal_with_conductors.go | 25 + op-devstack/presets/mixed_frontends.go | 132 ++++ op-devstack/presets/networks.go | 328 ++++++++++ op-devstack/presets/option_validation.go | 161 +++++ op-devstack/presets/options.go | 283 +++++++++ op-devstack/presets/options_test.go | 141 +++++ op-devstack/presets/proof.go | 106 ++++ op-devstack/presets/rpc_frontends.go | 598 ++++++++++++++++++ op-devstack/presets/simple_with_synctester.go | 31 + .../presets/singlechain_from_runtime.go | 187 ++++++ op-devstack/presets/singlechain_multinode.go | 65 ++ .../presets/singlechain_twoverifiers.go | 27 + .../presets/superproofs_from_runtime.go | 156 +++++ op-devstack/presets/sysgo_runtime.go | 175 +++++ op-devstack/presets/timetravel.go | 5 + op-devstack/presets/twol2.go | 303 +++++++++ op-devstack/presets/twol2_follow_l2.go | 25 + op-devstack/presets/twol2_from_runtime.go | 209 ++++++ op-devstack/stack/common.go | 21 + op-devstack/stack/conductor.go | 13 + op-devstack/stack/context.go | 26 + op-devstack/stack/context_test.go | 32 + op-devstack/stack/el.go | 15 + op-devstack/stack/faucet.go | 12 + op-devstack/stack/l1_cl.go | 15 + op-devstack/stack/l1_el.go | 6 + op-devstack/stack/l1_network.go | 9 + op-devstack/stack/l2_batcher.go | 13 + op-devstack/stack/l2_challenger.go | 12 + op-devstack/stack/l2_cl.go | 27 + op-devstack/stack/l2_el.go | 13 + op-devstack/stack/l2_network.go | 41 ++ op-devstack/stack/l2_proposer.go | 9 + op-devstack/stack/lifecycle.go | 6 + op-devstack/stack/network.go | 22 + op-devstack/stack/op_rbuilder.go | 16 + op-devstack/stack/rollup_boost.go | 15 + op-devstack/stack/superchain.go | 10 + op-devstack/stack/supernode.go | 21 + op-devstack/stack/supervisor.go | 13 + op-devstack/stack/sync_tester.go | 14 + op-devstack/stack/test_sequencer.go | 15 + 92 files changed, 10203 insertions(+), 20 deletions(-) create mode 100644 op-devstack/dsl/bridge.go create mode 100644 op-devstack/dsl/check.go create mode 100644 op-devstack/dsl/common.go create mode 100644 op-devstack/dsl/conductor.go create mode 100644 op-devstack/dsl/contract/call.go create mode 100644 op-devstack/dsl/doc.go create mode 100644 op-devstack/dsl/ecotone_fees.go create mode 100644 op-devstack/dsl/el.go create mode 100644 op-devstack/dsl/engine.go create mode 100644 op-devstack/dsl/faucet.go create mode 100644 op-devstack/dsl/fjord_fees.go create mode 100644 op-devstack/dsl/funder.go create mode 100644 op-devstack/dsl/hd_wallet.go create mode 100644 op-devstack/dsl/invalid_msg.go create mode 100644 op-devstack/dsl/key.go create mode 100644 op-devstack/dsl/l1_cl.go create mode 100644 op-devstack/dsl/l1_el.go create mode 100644 op-devstack/dsl/l1_network.go create mode 100644 op-devstack/dsl/l2_batcher.go create mode 100644 op-devstack/dsl/l2_challenger.go create mode 100644 op-devstack/dsl/l2_cl.go create mode 100644 op-devstack/dsl/l2_network.go create mode 100644 op-devstack/dsl/l2_op_rbuilder.go create mode 100644 op-devstack/dsl/l2_proposer.go create mode 100644 op-devstack/dsl/multi_client.go create mode 100644 op-devstack/dsl/multi_client_test.go create mode 100644 op-devstack/dsl/operator_fee.go create mode 100644 op-devstack/dsl/opts.go create mode 100644 op-devstack/dsl/params.go create mode 100644 op-devstack/dsl/proofs/claim.go create mode 100644 op-devstack/dsl/proofs/dispute_game_factory.go create mode 100644 op-devstack/dsl/proofs/fault_dispute_game.go create mode 100644 op-devstack/dsl/proofs/game_helper.go create mode 100644 op-devstack/dsl/proofs/super_fault_dispute_game.go create mode 100644 op-devstack/dsl/rollup_boost.go create mode 100644 op-devstack/dsl/safedb.go create mode 100644 op-devstack/dsl/sequencer.go create mode 100644 op-devstack/dsl/supernode.go create mode 100644 op-devstack/dsl/supervisor.go create mode 100644 op-devstack/dsl/sync_tester.go create mode 100644 op-devstack/presets/flashblocks.go create mode 100644 op-devstack/presets/interop.go create mode 100644 op-devstack/presets/interop_from_runtime.go create mode 100644 op-devstack/presets/minimal.go create mode 100644 op-devstack/presets/minimal_from_runtime.go create mode 100644 op-devstack/presets/minimal_with_conductors.go create mode 100644 op-devstack/presets/mixed_frontends.go create mode 100644 op-devstack/presets/networks.go create mode 100644 op-devstack/presets/option_validation.go create mode 100644 op-devstack/presets/options.go create mode 100644 op-devstack/presets/options_test.go create mode 100644 op-devstack/presets/proof.go create mode 100644 op-devstack/presets/rpc_frontends.go create mode 100644 op-devstack/presets/simple_with_synctester.go create mode 100644 op-devstack/presets/singlechain_from_runtime.go create mode 100644 op-devstack/presets/singlechain_multinode.go create mode 100644 op-devstack/presets/singlechain_twoverifiers.go create mode 100644 op-devstack/presets/superproofs_from_runtime.go create mode 100644 op-devstack/presets/sysgo_runtime.go create mode 100644 op-devstack/presets/timetravel.go create mode 100644 op-devstack/presets/twol2.go create mode 100644 op-devstack/presets/twol2_follow_l2.go create mode 100644 op-devstack/presets/twol2_from_runtime.go create mode 100644 op-devstack/stack/common.go create mode 100644 op-devstack/stack/conductor.go create mode 100644 op-devstack/stack/context.go create mode 100644 op-devstack/stack/context_test.go create mode 100644 op-devstack/stack/el.go create mode 100644 op-devstack/stack/faucet.go create mode 100644 op-devstack/stack/l1_cl.go create mode 100644 op-devstack/stack/l1_el.go create mode 100644 op-devstack/stack/l1_network.go create mode 100644 op-devstack/stack/l2_batcher.go create mode 100644 op-devstack/stack/l2_challenger.go create mode 100644 op-devstack/stack/l2_cl.go create mode 100644 op-devstack/stack/l2_el.go create mode 100644 op-devstack/stack/l2_network.go create mode 100644 op-devstack/stack/l2_proposer.go create mode 100644 op-devstack/stack/lifecycle.go create mode 100644 op-devstack/stack/network.go create mode 100644 op-devstack/stack/op_rbuilder.go create mode 100644 op-devstack/stack/rollup_boost.go create mode 100644 op-devstack/stack/superchain.go create mode 100644 op-devstack/stack/supernode.go create mode 100644 op-devstack/stack/supervisor.go create mode 100644 op-devstack/stack/sync_tester.go create mode 100644 op-devstack/stack/test_sequencer.go diff --git a/go.mod b/go.mod index 48c915a9f03..4f58292a2da 100644 --- a/go.mod +++ b/go.mod @@ -58,7 +58,7 @@ require ( github.com/prometheus/client_model v0.6.2 github.com/protolambda/ctxlock v0.1.0 github.com/schollz/progressbar/v3 v3.18.0 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.0 github.com/urfave/cli/v2 v2.27.6 go.opentelemetry.io/otel v1.34.0 go.opentelemetry.io/otel/trace v1.34.0 @@ -243,6 +243,7 @@ require ( github.com/pion/turn/v2 v2.1.6 // indirect github.com/pion/webrtc/v3 v3.3.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/quic-go/qpack v0.4.0 // indirect @@ -255,6 +256,7 @@ require ( github.com/rs/xid v1.6.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/stretchr/objx v0.5.2 // indirect diff --git a/go.sum b/go.sum index b0211893cc1..e9aacea7e4a 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ git.sr.ht/~sbinet/gg v0.6.0 h1:RIzgkizAk+9r7uPzf/VfbJHBMKUr0F5hRFxTUGMnt38= git.sr.ht/~sbinet/gg v0.6.0/go.mod h1:uucygbfC9wVPQIfrmwM2et0imr8L7KQWywX0xpFMm94= github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M= github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= @@ -114,6 +116,8 @@ github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7 github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= @@ -166,6 +170,12 @@ github.com/consensys/gnark-crypto v0.18.0/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx github.com/containerd/cgroups v0.0.0-20201119153540-4cbc285b3327/go.mod h1:ZJeTFisyysqgcCdecO57Dj79RfL0LNeGiFUqLYQRYLE= github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM= github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= +github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao= +github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4= +github.com/containerd/errdefs v0.1.0 h1:m0wCRBiu1WJT/Fr+iOoQHMQS/eP5myQ8lCv4Dz5ZURM= +github.com/containerd/errdefs v0.1.0/go.mod h1:YgWiiHtLmSeBrvpw+UfPijzbLaB77mEG1WwJTDETIV0= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd/v22 v22.1.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= @@ -211,9 +221,15 @@ github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de h1:t0UHb5vdo github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= +github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= @@ -251,6 +267,8 @@ github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/ github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY= github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= @@ -345,8 +363,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= -github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -564,6 +582,9 @@ github.com/libp2p/go-yamux/v4 v4.0.1 h1:FfDR4S1wj6Bw2Pqbc8Uz7pCxeRBPbwsBbEdfwiCy github.com/libp2p/go-yamux/v4 v4.0.1/go.mod h1:NWjl8ZTLOGlozrXSOZ/HlfG++39iKNnM5wwmtQP1YB4= github.com/lmittmann/w3 v0.19.5 h1:WwVRyIwhRLfIahmpB1EglsB3o1XWsgydgrxIUp5upFQ= github.com/lmittmann/w3 v0.19.5/go.mod h1:pN97sGGYGvsbqOYj/ms3Pd+7k/aiK/9OpNcxMmmzSOI= +github.com/lufia/plan9stats v0.0.0-20231016141302-07b5767bb0ed h1:036IscGBfJsFIgJQzlui7nK1Ncm0tp2ktmPj8xO4N/0= +github.com/lufia/plan9stats v0.0.0-20231016141302-07b5767bb0ed/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/lunixbochs/vtclean v0.0.0-20160125035106-4fbf7632a2c6/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= @@ -622,10 +643,22 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.1 h1:ZhBBeX8tSlRpu/FFhXH4RC4OJzFlqsQhoHZAz4x7TIw= github.com/mitchellh/pointerstructure v1.2.1/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= @@ -687,6 +720,10 @@ github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAl github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-spec v1.2.0 h1:z97+pHb3uELt/yiAWD691HNHQIF07bE7dzrbT927iTk= github.com/opencontainers/runtime-spec v1.2.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= @@ -765,6 +802,8 @@ github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDj github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= @@ -829,6 +868,12 @@ github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8G github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shirou/gopsutil/v3 v3.24.1 h1:R3t6ondCEvmARp3wxODhXMTLC/klMa87h2PHUw5m7QI= +github.com/shirou/gopsutil/v3 v3.24.1/go.mod h1:UU7a2MSBQa+kW1uuDq8DeEBS8kmrnQwsv2b5O513rwU= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= @@ -925,6 +970,8 @@ go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= @@ -1082,6 +1129,7 @@ golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/op-deployer/pkg/deployer/apply.go b/op-deployer/pkg/deployer/apply.go index af88d5940b1..43d0e2c4484 100644 --- a/op-deployer/pkg/deployer/apply.go +++ b/op-deployer/pkg/deployer/apply.go @@ -23,8 +23,6 @@ import ( "github.com/ethereum-optimism/optimism/op-service/ctxinterrupt" oplog "github.com/ethereum-optimism/optimism/op-service/log" "github.com/ethereum-optimism/optimism/op-service/prestate" - "github.com/ethereum-optimism/optimism/op-validator/pkg/service" - "github.com/ethereum-optimism/optimism/op-validator/pkg/validations" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" diff --git a/op-devstack/dsl/bridge.go b/op-devstack/dsl/bridge.go new file mode 100644 index 00000000000..b1bd6a88596 --- /dev/null +++ b/op-devstack/dsl/bridge.go @@ -0,0 +1,580 @@ +package dsl + +import ( + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "github.com/ethereum-optimism/optimism/op-chain-ops/crossdomain" + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/ethereum-optimism/optimism/op-core/predeploys" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + nodebindings "github.com/ethereum-optimism/optimism/op-node/bindings" + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + "github.com/ethereum-optimism/optimism/op-node/withdrawals" + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/bigs" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/txintent/bindings" + "github.com/ethereum-optimism/optimism/op-service/txintent/contractio" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient/gethclient" + "github.com/holiman/uint256" +) + +// ProvenWithdrawalParameters is the set of parameters to pass to the ProveWithdrawalTransaction +// and FinalizeWithdrawalTransaction functions +type ProvenWithdrawalParameters struct { + Nonce *big.Int + Sender common.Address + Target common.Address + Value *big.Int + GasLimit *big.Int + DisputeGameAddress common.Address + DisputeGameIndex *big.Int + Data []byte + OutputRootProof bindings.OutputRootProof + WithdrawalProof [][]byte // List of trie nodes to prove L2 storage +} + +type StandardBridge struct { + commonImpl + l1PortalAddr common.Address + l1Portal bindings.OptimismPortal2 + l2tol1MessagePasser bindings.L2ToL1MessagePasser + disputeGameFactory bindings.DisputeGameFactory + rollupCfg *rollup.Config + + l1Client *L1ELNode + l2Client apis.EthClient + + // L1 bridge contract + l1StandardBridge bindings.L1StandardBridge +} + +func NewStandardBridge(t devtest.T, l2Network *L2Network, l1EL *L1ELNode) *StandardBridge { + l1Client := l1EL.EthClient() + l1PortalAddr := l2Network.DepositContractAddr() + l1Portal := bindings.NewBindings[bindings.OptimismPortal2]( + bindings.WithClient(l1Client), + bindings.WithTo(l1PortalAddr), + bindings.WithTest(t)) + l2Client := l2Network.PrimaryEL().EthClient() + l2tol1MessagePasser := bindings.NewBindings[bindings.L2ToL1MessagePasser]( + bindings.WithClient(l2Client), + bindings.WithTo(predeploys.L2ToL1MessagePasserAddr), + bindings.WithTest(t)) + + disputeGameFactory := bindings.NewBindings[bindings.DisputeGameFactory]( + bindings.WithClient(l1Client), + bindings.WithTo(l2Network.DisputeGameFactoryProxyAddr())) + + l1StandardBridge := bindings.NewBindings[bindings.L1StandardBridge]( + bindings.WithClient(l1Client), + bindings.WithTo(l2Network.Escape().Deployment().L1StandardBridgeProxyAddr()), + bindings.WithTest(t)) + + return &StandardBridge{ + commonImpl: commonFromT(t), + l1PortalAddr: l1PortalAddr, + l1Portal: l1Portal, + l2tol1MessagePasser: l2tol1MessagePasser, + disputeGameFactory: disputeGameFactory, + rollupCfg: l2Network.inner.RollupConfig(), + + l1Client: l1EL, + l2Client: l2Client, + l1StandardBridge: l1StandardBridge, + } +} + +func (b *StandardBridge) GameResolutionDelay() time.Duration { + gameType := b.RespectedGameType() + gameImplAddr, err := contractio.Read(b.disputeGameFactory.GameImpls(gameType), b.ctx) + b.require.NoErrorf(err, "failed to get implementation for game type %v", gameType) + game := bindings.NewBindings[bindings.FaultDisputeGame](bindings.WithClient(b.l1Client.EthClient()), bindings.WithTo(gameImplAddr), bindings.WithTest(b.t)) + clockDuration, err := contractio.Read(game.MaxClockDuration(), b.ctx) + b.require.NoErrorf(err, "failed to get max clock duration for game type %v", gameType) + return time.Duration(clockDuration) * time.Second +} + +func (b *StandardBridge) WithdrawalDelay() time.Duration { + delaySeconds, err := contractio.Read(b.l1Portal.ProofMaturityDelaySeconds(), b.ctx) + b.require.NoError(err, "Failed to read proof maturity delay") + return time.Duration(delaySeconds.Int64()) * time.Second +} + +func (b *StandardBridge) DisputeGameFinalityDelay() time.Duration { + delaySeconds, err := contractio.Read(b.l1Portal.DisputeGameFinalityDelaySeconds(), b.ctx) + b.require.NoError(err, "Failed to read dispute game finality delay") + return time.Duration(delaySeconds.Int64()) * time.Second +} + +func (b *StandardBridge) RespectedGameType() uint32 { + gameType, err := contractio.Read(b.l1Portal.RespectedGameType(), b.ctx) + b.require.NoError(err, "Failed to read respected game type") + return gameType +} + +func (b *StandardBridge) PortalVersion() string { + version, err := contractio.Read(b.l1Portal.Version(), b.ctx) + b.require.NoError(err, "Failed to read portal version") + return version +} + +func (b *StandardBridge) UsesSuperRoots() bool { + // Only interop contracts have SuperRootsActive functionality + version := b.PortalVersion() + if !strings.HasSuffix(version, "+interop") { + return false + } + + superRootsActive, err := contractio.Read(b.l1Portal.SuperRootsActive(), b.ctx) + b.require.NoError(err, "Failed to read super roots active") + return superRootsActive +} + +type Deposit struct { + bridge *StandardBridge + l1Receipt *types.Receipt +} + +func (d Deposit) GasCost() eth.ETH { + if d.bridge == nil { + panic("bridge reference not set on deposit") + } + return d.bridge.gasCost(d.l1Receipt, d.bridge.l1Client.EthClient()) +} + +func (b *StandardBridge) Deposit(amount eth.ETH, from *EOA) Deposit { + depositTx := from.Transfer(b.l1PortalAddr, amount) + l1DepositReceipt, err := depositTx.Included.Eval(b.ctx) + b.require.NoErrorf(err, "Failed to send deposit transaction from %v for %v", from, amount) + + // Wait for the deposit to be processed on the L2 + // Construct the L2 deposit tx to check the tx is included at L2 + idx := len(l1DepositReceipt.Logs) - 1 + l2DepositTx, err := derive.UnmarshalDepositLogEvent(l1DepositReceipt.Logs[idx]) + b.require.NoError(err, "Could not reconstruct L2 Deposit") + l2DepositTxHash := types.NewTx(l2DepositTx).Hash() + // Give time for L2CL to include the L2 deposit tx + var l2DepositReceipt *types.Receipt + b.require.Eventually(func() bool { + l2DepositReceipt, err = b.l2Client.TransactionReceipt(b.ctx, l2DepositTxHash) + return err == nil + }, 60*time.Second, 500*time.Millisecond, "L2 Deposit never found") + b.require.Equal(types.ReceiptStatusSuccessful, l2DepositReceipt.Status) + return Deposit{ + bridge: b, + l1Receipt: l1DepositReceipt, + } +} + +func (b *StandardBridge) InitiateWithdrawal(amount eth.ETH, from *EOA) *Withdrawal { + withdrawTx := from.Transfer(predeploys.L2ToL1MessagePasserAddr, amount) + withdrawRcpt, err := withdrawTx.Included.Eval(b.ctx) + b.require.NoErrorf(err, "Failed to initiate withdrawal from %v for %v", from, amount) + b.require.Equal(types.ReceiptStatusSuccessful, withdrawRcpt.Status, "initiating withdrawal failed") + return &Withdrawal{ + commonImpl: commonFromT(b.t), + bridge: b, + initReceipt: withdrawRcpt, + } +} + +// ERC20Deposit performs an ERC20 deposit from L1 to L2 +func (b *StandardBridge) ERC20Deposit(l1TokenAddr common.Address, l2TokenAddr common.Address, amount eth.ETH, from *EOA) *Deposit { + // Use the l1StandardBridge to deposit ERC20 tokens + depositCall := b.l1StandardBridge.DepositERC20To(l1TokenAddr, l2TokenAddr, from.Address(), amount, 200000, []byte{}) + depositReceipt, err := contractio.Write(depositCall, b.ctx, from.Plan()) + b.require.NoError(err, "Failed to send ERC20 deposit transaction") + b.require.Equal(types.ReceiptStatusSuccessful, depositReceipt.Status, "ERC20 deposit should succeed") + + // Wait for the deposit to be processed on the L2 + // Find the deposit log to get the L2 deposit transaction + var l2DepositTx *types.DepositTx + for _, log := range depositReceipt.Logs { + if l2DepositTx, err = derive.UnmarshalDepositLogEvent(log); err == nil { + break + } + } + b.require.NotNil(l2DepositTx, "Could not find L2 deposit transaction in logs") + + l2DepositTxHash := types.NewTx(l2DepositTx).Hash() + + // Give time for L2CL to include the L2 deposit tx + sequencingWindowDuration := time.Duration(b.rollupCfg.SeqWindowSize) * b.l1Client.EstimateBlockTime() + var l2DepositReceipt *types.Receipt + b.require.Eventually(func() bool { + l2DepositReceipt, err = b.l2Client.TransactionReceipt(b.ctx, l2DepositTxHash) + return err == nil + }, sequencingWindowDuration, 500*time.Millisecond, "L2 ERC20 deposit never found") + b.require.Equal(types.ReceiptStatusSuccessful, l2DepositReceipt.Status, "L2 ERC20 deposit should succeed") + + return &Deposit{ + bridge: b, + l1Receipt: depositReceipt, + } +} + +// CreateL2Token creates an L2 token using OptimismMintableERC20Factory and returns the token address +func (b *StandardBridge) CreateL2Token(l1TokenAddr common.Address, name string, symbol string, from *EOA) common.Address { + factoryContract := bindings.NewBindings[bindings.OptimismMintableERC20Factory]( + bindings.WithTest(b.t), + bindings.WithClient(b.l2Client), + bindings.WithTo(predeploys.OptimismMintableERC20FactoryAddr), + ) + + createCall := factoryContract.CreateOptimismMintableERC20(l1TokenAddr, name, symbol) + createReceipt, err := contractio.Write(createCall, b.ctx, from.Plan()) + b.require.NoError(err, "Failed to create L2 token") + b.require.Equal(types.ReceiptStatusSuccessful, createReceipt.Status, "L2 token creation should succeed") + + // Extract L2 token address from logs + l2TokenAddress := b.extractL2TokenFromLogs(createReceipt) + b.log.Info("Created L2 token", "l1Token", l1TokenAddr, "l2Token", l2TokenAddress, "name", name, "symbol", symbol) + return l2TokenAddress +} + +// extractL2TokenFromLogs extracts the L2 token address from OptimismMintableERC20Created event +func (b *StandardBridge) extractL2TokenFromLogs(receipt *types.Receipt) common.Address { + // Look for the OptimismMintableERC20Created event + for _, log := range receipt.Logs { + if log.Address == predeploys.OptimismMintableERC20FactoryAddr && len(log.Topics) > 2 { + // The token address is in the indexed topics + return common.HexToAddress(log.Topics[2].Hex()) + } + } + b.require.Fail("Failed to find L2 token address from events") + return common.Address{} // Never reached +} + +type disputeGame struct { + Index *big.Int + Address common.Address + L2BlockNumber uint64 + SequenceNumber uint64 + UsesSuperRoots bool +} + +// forGamePublished waits until a game is published on L1 for the given l2BlockNumber +// Note that the l2 block number is passed even for super games. Conversion to timestamp is done automatically +// when required by the respected game type +func (b *StandardBridge) forGamePublished(l2BlockNumber *big.Int) disputeGame { + respectedGameType := b.RespectedGameType() + l2SequenceNumber := bigs.Uint64Strict(l2BlockNumber) + superRootsActive := b.UsesSuperRoots() + if superRootsActive { + l2SequenceNumber = b.rollupCfg.TimestampForBlock(l2SequenceNumber) + } + + var game bindings.DisputeGame + var gameSeqNum uint64 + var gameIndex *big.Int + b.require.Eventuallyf(func() bool { + var err error + game, gameIndex, err = b.findLatestGame(respectedGameType) + if err != nil { + b.log.Warn("No game of required type found", "err", err) + return false + } + gameContract := bindings.NewBindings[bindings.FaultDisputeGame]( + bindings.WithClient(b.l1Client.EthClient()), + bindings.WithTo(game.Proxy), + bindings.WithTest(b.t)) + seqNum, err := contractio.Read(gameContract.L2SequenceNumber(), b.ctx) + b.require.NoError(err, "Failed to read sequence number") + gameSeqNum = bigs.Uint64Strict(seqNum) + b.log.Info("Found latest game", "index", gameIndex, "seqNum", gameSeqNum) + return gameSeqNum >= l2SequenceNumber + }, 90*time.Second, 100*time.Millisecond, "did not find a game of type %v at or after l2 sequence number %v", respectedGameType, l2SequenceNumber) + + gameBlockNum := gameSeqNum + if superRootsActive { + blockNum, err := b.rollupCfg.TargetBlockNumber(gameSeqNum) + b.require.NoError(err, "Failed to convert game timestamp to block number") + gameBlockNum = blockNum + } + return disputeGame{ + Index: gameIndex, + Address: game.Proxy, + L2BlockNumber: gameBlockNum, + SequenceNumber: gameSeqNum, + UsesSuperRoots: superRootsActive, + } +} + +// findLatestGame finds the latest game in the DisputeGameFactory contract. +// Ported from op-node/withdrawals/utils.go to fit in the op-devstack, using op-service ethclient +func (b *StandardBridge) findLatestGame(gameType uint32) (bindings.DisputeGame, *big.Int, error) { + gameCount, err := contractio.Read(b.disputeGameFactory.GameCount(), b.ctx) + b.require.NoError(err, "Failed to read game count") + if gameCount.Cmp(common.Big0) == 0 { + return bindings.DisputeGame{}, nil, errors.New("no games") + } + + gameIdx := new(big.Int).Sub(gameCount, common.Big1) + for gameIdx.Cmp(common.Big0) >= 0 { + latestGame, err := contractio.Read(b.disputeGameFactory.GameAtIndex(gameIdx), b.ctx) + b.require.NoErrorf(err, "Failed to find latest game for %v", gameType) + if latestGame.GameType != gameType { + // Wrong game type, continue searching backwards + gameIdx = new(big.Int).Sub(gameIdx, common.Big1) + continue + } + return latestGame, gameIdx, nil + } + return bindings.DisputeGame{}, nil, errors.New("no suitable games found") +} + +type Withdrawal struct { + commonImpl + bridge *StandardBridge + initReceipt *types.Receipt + + proveParams ProvenWithdrawalParameters + proveReceipt *types.Receipt + finalizeReceipt *types.Receipt +} + +func (w *Withdrawal) InitiateGasCost() eth.ETH { + return w.bridge.gasCost(w.initReceipt, w.bridge.l2Client) +} + +func (w *Withdrawal) ProveGasCost() eth.ETH { + w.require.NotNil(w.proveReceipt, "Must have proven withdrawal before calculating gas cost") + return w.bridge.gasCost(w.proveReceipt, w.bridge.l1Client.EthClient()) +} + +func (w *Withdrawal) FinalizeGasCost() eth.ETH { + w.require.NotNil(w.finalizeReceipt, "Must have finalized withdrawal before calculating gas cost") + return w.bridge.gasCost(w.finalizeReceipt, w.bridge.l1Client.EthClient()) +} + +func (w *Withdrawal) InitiateBlockHash() common.Hash { + return w.initReceipt.BlockHash +} + +func (w *Withdrawal) Prove(user *EOA) { + var params ProvenWithdrawalParameters + + w.t.Log("proveWithdrawal: proving withdrawal...") + params = w.proveWithdrawalParameters() + tx := bindings.WithdrawalTransaction{ + Nonce: params.Nonce, + Sender: params.Sender, + Target: params.Target, + Value: params.Value, + GasLimit: params.GasLimit, + Data: params.Data, + } + + call := w.bridge.l1Portal.ProveWithdrawalTransaction(tx, params.DisputeGameIndex, params.OutputRootProof, params.WithdrawalProof) + // Retry as withdrawals can't be proven in the same block as the game is created. + // estimateGas works against the current head so we may need to retry until it has progressed enough. + w.require.Eventually(func() bool { + proveReceipt, err := contractio.Write(call, w.ctx, user.Plan()) + if err != nil { + w.log.Error("Failed to send prove transaction", "err", err) + return false + } + w.require.Equal(types.ReceiptStatusSuccessful, proveReceipt.Status, "prove withdrawal was not successful") + w.require.Equal(2, len(proveReceipt.Logs)) // emit WithdrawalProven, WithdrawalProvenExtension1 + + w.proveParams = params + w.proveReceipt = proveReceipt + return true + }, 30*time.Second, 1*time.Second, "Sending prove transaction") +} + +// ProveWithdrawalParameters calls ProveWithdrawalParametersForBlock with the most recent L2 output after the latest game. +// Ported from op-node/withdrawals/utils.go to fit in the op-devstack +func (w *Withdrawal) proveWithdrawalParameters() ProvenWithdrawalParameters { + // Wait for a suitable game to be published + latestGame := w.bridge.forGamePublished(w.initReceipt.BlockNumber) + + // Fetch the block header from the L2 node + l2Header, err := w.bridge.l2Client.InfoByNumber(w.ctx, latestGame.L2BlockNumber) + w.require.NoErrorf(err, "failed to fetch block header %v", latestGame.L2BlockNumber) + + ev, err := withdrawals.ParseMessagePassed(w.initReceipt) + w.require.NoError(err, "failed to parse message passed receipt") + return w.proveWithdrawalParametersForEvent(ev, l2Header, latestGame) +} + +// proveWithdrawalParametersForEvent queries L1 to generate all withdrawal parameters and proof necessary to prove a withdrawal on L1. +// The l2Header provided is very important. It should be a block for which there is a submitted output in the L2 Output Oracle +// contract. If not, the withdrawal will fail as it the storage proof cannot be verified if there is no submitted state root. +// Ported from op-node/withdrawals/utils.go to fit in the op-devstack, using op-service ethclient +func (w *Withdrawal) proveWithdrawalParametersForEvent(ev *nodebindings.L2ToL1MessagePasserMessagePassed, l2Header eth.BlockInfo, disputeGame disputeGame) ProvenWithdrawalParameters { + // Generate then verify the withdrawal proof + withdrawalHash, err := withdrawals.WithdrawalHash(ev) + w.require.NoErrorf(err, "failed to calculate hash for withdrawal %v", ev) + w.require.Equal(withdrawalHash[:], ev.WithdrawalHash[:], "computed withdrawal hash incorrectly") + slot := withdrawals.StorageSlotOfWithdrawalHash(withdrawalHash) + + p, err := w.bridge.l2Client.GetProof(w.ctx, predeploys.L2ToL1MessagePasserAddr, []common.Hash{slot}, hexutil.Uint64(l2Header.NumberU64()).String()) + w.require.NoErrorf(err, "failed to fetch proof for withdrawal %v", ev) + w.require.Len(p.StorageProof, 1, "invalid amount of storage proofs") + + err = verifyProof(l2Header.Root(), p) + w.require.NoErrorf(err, "failed to verify proof for withdrawal") + + // Encode it as expected by the contract + trieNodes := make([][]byte, len(p.StorageProof[0].Proof)) + for i, s := range p.StorageProof[0].Proof { + trieNodes[i] = s + } + + return ProvenWithdrawalParameters{ + Nonce: ev.Nonce, + Sender: ev.Sender, + Target: ev.Target, + Value: ev.Value, + GasLimit: ev.GasLimit, + DisputeGameAddress: disputeGame.Address, + DisputeGameIndex: disputeGame.Index, + Data: ev.Data, + OutputRootProof: bindings.OutputRootProof{ + Version: [32]byte{}, // Empty for version 1 + StateRoot: l2Header.Root(), + MessagePasserStorageRoot: *l2Header.WithdrawalsRoot(), + LatestBlockhash: l2Header.Hash(), + }, + WithdrawalProof: trieNodes, + } +} + +// Ported from op-node/withdrawals/proof.go to fit in the op-devstack, using op-service proof types +func verifyProof(stateRoot common.Hash, proof *eth.AccountResult) error { + balance, overflow := uint256.FromBig(proof.Balance.ToInt()) + if overflow { + return fmt.Errorf("proof balance overflows uint256: %d", proof.Balance.ToInt()) + } + proofHex := []string{} + for _, p := range proof.AccountProof { + proofHex = append(proofHex, hex.EncodeToString(p)) + } + err := withdrawals.VerifyAccountProof( + stateRoot, + proof.Address, + types.StateAccount{ + Nonce: uint64(proof.Nonce), + Balance: balance, + Root: proof.StorageHash, + CodeHash: proof.CodeHash[:], + }, + proofHex, + ) + if err != nil { + return fmt.Errorf("failed to validate account: %w", err) + } + for i, storageProof := range proof.StorageProof { + proofHex := []string{} + for _, p := range storageProof.Proof { + proofHex = append(proofHex, hex.EncodeToString(p)) + } + convertedProof := gethclient.StorageResult{ + Key: storageProof.Key.String(), + Value: storageProof.Value.ToInt(), + Proof: proofHex, + } + err = withdrawals.VerifyStorageProof(proof.StorageHash, convertedProof) + if err != nil { + return fmt.Errorf("failed to validate storage proof %d: %w", i, err) + } + } + return nil +} + +func (w *Withdrawal) Finalize(user *EOA) { + wd := crossdomain.Withdrawal{ + Nonce: w.proveParams.Nonce, + Sender: &w.proveParams.Sender, + Target: &w.proveParams.Target, + Value: w.proveParams.Value, + GasLimit: w.proveParams.GasLimit, + Data: w.proveParams.Data, + } + + // Finalize withdrawal + w.log.Info("FinalizeWithdrawal: finalizing withdrawal...") + var finalizeReceipt *types.Receipt + var err error + // Retry as the air gap delay needs to have expired at the head block timestamp for estimateGas to work + w.require.Eventually(func() bool { + finalizeReceipt, err = contractio.Write(w.bridge.l1Portal.FinalizeWithdrawalTransaction(wd.WithdrawalTransaction()), w.ctx, user.Plan()) + if err != nil { + return false + } + w.finalizeReceipt = finalizeReceipt + return types.ReceiptStatusSuccessful == finalizeReceipt.Status + }, 60*time.Second, 100*time.Millisecond, "finalize withdrawal failed") +} + +func (w *Withdrawal) WaitForDisputeGameResolved() { + w.require.NotNil(w.proveReceipt, "Must have proven withdrawal first") + + gameContract := bindings.NewBindings[bindings.FaultDisputeGame]( + bindings.WithClient(w.bridge.l1Client.EthClient()), + bindings.WithTo(w.proveParams.DisputeGameAddress), + bindings.WithTest(w.t)) + w.require.Eventually(func() bool { + status, err := contractio.Read(gameContract.Status(), w.ctx) + w.require.NoError(err, "failed to get game status") + w.log.Info("Waiting for dispute game to resolve", "currentStatus", status) + return gameTypes.GameStatus(status) == gameTypes.GameStatusDefenderWon + }, 60*time.Second, 100*time.Millisecond, "wait for dispute game resolved") +} + +func (b *StandardBridge) gasCost(rcpt *types.Receipt, client apis.EthClient) eth.ETH { + var blockTimestamp *uint64 + if hasOperatorFee(rcpt) { + b.require.NotNil(client, "client is required to resolve operator fee timestamp") + blockTimestamp = b.receiptTimestamp(rcpt, client) + } + return gasCost(rcpt, b.rollupCfg, blockTimestamp) +} + +func hasOperatorFee(rcpt *types.Receipt) bool { + return rcpt.OperatorFeeConstant != nil && rcpt.OperatorFeeScalar != nil +} + +func (b *StandardBridge) receiptTimestamp(rcpt *types.Receipt, client apis.EthClient) *uint64 { + b.require.NotNil(rcpt.BlockNumber, "receipt missing block number") + blockInfo, err := client.InfoByNumber(b.ctx, bigs.Uint64Strict(rcpt.BlockNumber)) + b.require.NoError(err, "failed to fetch block info for receipt") + ts := blockInfo.Time() + return &ts +} + +func gasCost(rcpt *types.Receipt, rollupCfg *rollup.Config, blockTimestamp *uint64) eth.ETH { + cost := eth.WeiBig(new(big.Int).Mul(new(big.Int).SetUint64(rcpt.GasUsed), rcpt.EffectiveGasPrice)) + if rcpt.L1Fee != nil { + cost = cost.Add(eth.WeiBig(rcpt.L1Fee)) + } + if hasOperatorFee(rcpt) { + if rollupCfg == nil { + panic("rollup config is required to compute operator fee") + } + if blockTimestamp == nil { + panic("block timestamp is required to compute operator fee") + } + operatorCost := new(big.Int).SetUint64(rcpt.GasUsed) + operatorCost.Mul(operatorCost, new(big.Int).SetUint64(*rcpt.OperatorFeeScalar)) + if rollupCfg.IsJovian(*blockTimestamp) { + operatorCost.Mul(operatorCost, big.NewInt(100)) + } else { + operatorCost.Div(operatorCost, big.NewInt(1_000_000)) + } + operatorCost.Add(operatorCost, new(big.Int).SetUint64(*rcpt.OperatorFeeConstant)) + cost = cost.Add(eth.WeiBig(operatorCost)) + } + return cost +} diff --git a/op-devstack/dsl/check.go b/op-devstack/dsl/check.go new file mode 100644 index 00000000000..b56f30b8fa8 --- /dev/null +++ b/op-devstack/dsl/check.go @@ -0,0 +1,86 @@ +package dsl + +import ( + "context" + "fmt" + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/retry" + "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" + "github.com/ethereum/go-ethereum/log" + "golang.org/x/sync/errgroup" +) + +type CheckFunc func() error + +func CheckAll(t devtest.T, checks ...CheckFunc) { + var g errgroup.Group + for _, check := range checks { + check := check + g.Go(func() error { + return check() + }) + } + t.Require().NoError(g.Wait()) +} + +type SyncStatusProvider interface { + ChainSyncStatus(chainID eth.ChainID, lvl types.SafetyLevel) eth.BlockID + String() string +} + +var _ SyncStatusProvider = (*L2CLNode)(nil) +var _ SyncStatusProvider = (*Supervisor)(nil) + +// LaggedFn returns a lambda that checks the baseNode head with given safety level is lagged with the refNode chain sync status provider +// Composable with other lambdas to wait in parallel +func LaggedFn(baseNode, refNode SyncStatusProvider, log log.Logger, ctx context.Context, lvl types.SafetyLevel, chainID eth.ChainID, attempts int, allowMatch bool) CheckFunc { + return func() error { + base := baseNode.ChainSyncStatus(chainID, lvl) + ref := refNode.ChainSyncStatus(chainID, lvl) + logger := log.With("base_id", baseNode, "ref_id", refNode, "chain", chainID, "label", lvl) + logger.Info("Expecting node to lag with reference", "base", base.Number, "ref", ref.Number) + for range attempts { + base = baseNode.ChainSyncStatus(chainID, lvl) + ref = refNode.ChainSyncStatus(chainID, lvl) + cmp := base.Number > ref.Number + msg := "Base chain surpassed" + if !allowMatch { + cmp = base.Number >= ref.Number + msg += " or caught up" + } + if cmp { + logger.Warn(msg, "base", base.Number, "ref", ref.Number) + return fmt.Errorf("expected head to lag: %s", lvl) + } + logger.Info("Node sync status", "base", base.Number, "ref", ref.Number) + time.Sleep(2 * time.Second) + } + logger.Info("Node lagged as expected") + return nil + } +} + +// MatchedFn returns a lambda that checks the baseNode head with given safety level is matched with the refNode chain sync status provider +// Composable with other lambdas to wait in parallel +func MatchedFn(baseNode, refNode SyncStatusProvider, log log.Logger, ctx context.Context, lvl types.SafetyLevel, chainID eth.ChainID, attempts int) CheckFunc { + return func() error { + base := baseNode.ChainSyncStatus(chainID, lvl) + ref := refNode.ChainSyncStatus(chainID, lvl) + logger := log.With("base_id", baseNode, "ref_id", refNode, "chain", chainID, "label", lvl) + logger.Info("Expecting node to match with reference", "base", base.Number, "ref", ref.Number) + return retry.Do0(ctx, attempts, &retry.FixedStrategy{Dur: 2 * time.Second}, + func() error { + base = baseNode.ChainSyncStatus(chainID, lvl) + ref = refNode.ChainSyncStatus(chainID, lvl) + if ref.Hash == base.Hash && ref.Number == base.Number { + logger.Info("Node matched", "ref", ref.Number) + return nil + } + logger.Info("Node sync status", "base", base.Number, "ref", ref.Number) + return fmt.Errorf("expected head to match: %s", lvl) + }) + } +} diff --git a/op-devstack/dsl/common.go b/op-devstack/dsl/common.go new file mode 100644 index 00000000000..c9851e56909 --- /dev/null +++ b/op-devstack/dsl/common.go @@ -0,0 +1,36 @@ +package dsl + +import ( + "context" + + "github.com/ethereum/go-ethereum/log" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-service/testreq" +) + +// commonImpl provides a set of common values and methods inherited by all DSL structs. +// These should be kept very minimal. +// No public methods or fields should be exposed. +// This aims to make interfacing with the common devtest functionality less verbose. +// The test internal values should never change. +// Instead, a new component DSL binding may be initialized, for usage in a new (sub-)test-scope. +type commonImpl struct { + // Ctx is the context for test execution. + ctx context.Context + // log is the component-specific logger instance. + log log.Logger + // T is a minimal test interface for panic-checks / assertions. + t devtest.T + // Require is a helper around the above T, ready to assert against. + require *testreq.Assertions +} + +func commonFromT(t devtest.T) commonImpl { + return commonImpl{ + ctx: t.Ctx(), + log: t.Logger(), + t: t, + require: t.Require(), + } +} diff --git a/op-devstack/dsl/conductor.go b/op-devstack/dsl/conductor.go new file mode 100644 index 00000000000..fb94fc17cfb --- /dev/null +++ b/op-devstack/dsl/conductor.go @@ -0,0 +1,107 @@ +package dsl + +import ( + "context" + "time" + + "github.com/ethereum-optimism/optimism/op-conductor/consensus" + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-service/retry" +) + +type ConductorSet []*Conductor + +func NewConductorSet(inner []stack.Conductor) ConductorSet { + conductors := make([]*Conductor, len(inner)) + for i, c := range inner { + conductors[i] = NewConductor(c) + } + return conductors +} + +type Conductor struct { + commonImpl + inner stack.Conductor +} + +func NewConductor(inner stack.Conductor) *Conductor { + return &Conductor{ + commonImpl: commonFromT(inner.T()), + inner: inner, + } +} + +func (c *Conductor) String() string { + return c.inner.Name() +} + +func (c *Conductor) Escape() stack.Conductor { + return c.inner +} + +func (c *Conductor) FetchClusterMembership() *consensus.ClusterMembership { + c.log.Debug("Fetching cluster membership") + ctx, cancel := context.WithTimeout(c.ctx, DefaultTimeout) + defer cancel() + clusterMembership, err := retry.Do(ctx, 2, retry.Fixed(500*time.Millisecond), func() (*consensus.ClusterMembership, error) { + clusterMembership, err := c.inner.RpcAPI().ClusterMembership(c.ctx) + return clusterMembership, err + }) + c.require.NoError(err, "Failed to fetch cluster membership") + c.log.Info("Fetched cluster membership", + "clusterMembership", clusterMembership) + return clusterMembership +} + +func (c *Conductor) FetchLeader() *consensus.ServerInfo { + c.log.Debug("Fetching leader information") + ctx, cancel := context.WithTimeout(c.ctx, DefaultTimeout) + defer cancel() + leaderInfo, err := retry.Do[*consensus.ServerInfo](ctx, 2, retry.Fixed(500*time.Millisecond), func() (*consensus.ServerInfo, error) { + leaderInfo, err := c.inner.RpcAPI().LeaderWithID(c.ctx) + return leaderInfo, err + }) + c.require.NoError(err, "Failed to fetch leader information") + c.log.Info("Fetched leader information", + "leaderInfo", leaderInfo) + return leaderInfo +} + +func (c *Conductor) FetchSequencerHealthy() bool { + c.log.Debug("Fetching sequencer healthy status") + ctx, cancel := context.WithTimeout(c.ctx, DefaultTimeout) + defer cancel() + healthy, err := c.inner.RpcAPI().SequencerHealthy(ctx) + c.require.NoError(err, "Failed to fetch sequencer healthy status") + c.log.Info("Fetched sequencer healthy status", "healthy", healthy) + return healthy +} + +func (c *Conductor) FetchPaused() bool { + c.log.Debug("Fetching paused status") + ctx, cancel := context.WithTimeout(c.ctx, DefaultTimeout) + defer cancel() + paused, err := c.inner.RpcAPI().Paused(ctx) + c.require.NoError(err, "Failed to fetch paused status") + c.log.Info("Fetched paused status", "paused", paused) + return paused +} + +func (c *Conductor) IsLeader() bool { + c.log.Debug("Checking if conductor is leader") + ctx, cancel := context.WithTimeout(c.ctx, DefaultTimeout) + defer cancel() + leader, err := c.inner.RpcAPI().Leader(ctx) + c.require.NoError(err, "Failed to check if conductor is leader") + c.log.Info("Checked if conductor is leader", "leader", leader) + return leader +} + +func (c *Conductor) TransferLeadershipTo(targetLeaderInfo consensus.ServerInfo) { + c.log.Debug("Transferring leadership to target leader", "targetLeaderID", targetLeaderInfo.ID, "targetLeaderAddr", targetLeaderInfo.Addr) + ctx, cancel := context.WithTimeout(c.ctx, DefaultTimeout) + defer cancel() + err := c.inner.RpcAPI().TransferLeaderToServer(ctx, targetLeaderInfo.ID, targetLeaderInfo.Addr) + c.require.NoError(err, "Failed to transfer leadership to target leader", "targetLeaderID", targetLeaderInfo.ID) + c.log.Info("Transferred leadership to target leader", "targetLeaderID", targetLeaderInfo.ID) +} diff --git a/op-devstack/dsl/contract/call.go b/op-devstack/dsl/contract/call.go new file mode 100644 index 00000000000..a8bfbc8c181 --- /dev/null +++ b/op-devstack/dsl/contract/call.go @@ -0,0 +1,77 @@ +package contract + +import ( + "context" + "fmt" + "math/big" + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-service/errutil" + "github.com/ethereum-optimism/optimism/op-service/sources/batching" + "github.com/ethereum-optimism/optimism/op-service/txintent/bindings" + "github.com/ethereum-optimism/optimism/op-service/txintent/contractio" + "github.com/ethereum-optimism/optimism/op-service/txplan" + "github.com/ethereum/go-ethereum/core/types" +) + +const ( + // readTimeout bounds individual contract read calls to prevent tests from + // hanging when an in-memory geth node stalls under CI resource contention. + readTimeout = 60 * time.Second + // writeTimeout bounds contract write calls (transaction submission + mining). + writeTimeout = 5 * time.Minute +) + +// TestCallView is used in devstack for wrapping errors +type TestCallView[O any] interface { + Test() bindings.BaseTest +} + +// checkTestable checks whether the TypedCall can be used as a DSL using the testing context +func checkTestable[O any](call bindings.TypedCall[O]) { + callTest, ok := any(call).(TestCallView[O]) + if !ok || callTest.Test() == nil { + panic(fmt.Sprintf("call of type %T does not support testing", call)) + } +} + +// Read executes a new message call without creating a transaction on the blockchain. +// Each call is bounded by readTimeout to prevent hangs under CI resource contention. +func Read[O any](call bindings.TypedCall[O], opts ...txplan.Option) O { + checkTestable(call) + ctx, cancel := context.WithTimeout(call.Test().Ctx(), readTimeout) + defer cancel() + o, err := contractio.Read(call, ctx, opts...) + call.Test().Require().NoError(err) + return o +} + +// ReadArray retrieves all data from an array in batches. +// Each call is bounded by readTimeout to prevent hangs under CI resource contention. +func ReadArray[T any](countCall bindings.TypedCall[*big.Int], elemCall func(i *big.Int) bindings.TypedCall[T]) []T { + checkTestable(countCall) + test := countCall.Test() + ctx, cancel := context.WithTimeout(countCall.Test().Ctx(), readTimeout) + defer cancel() + + caller := countCall.Client().NewMultiCaller(batching.DefaultBatchSize) + + o, err := contractio.ReadArray(ctx, caller, countCall, elemCall) + test.Require().NoError(err) + return o +} + +// Write makes a user to write a tx by using the planned contract bindings. +// Each call is bounded by writeTimeout to prevent hangs under CI resource contention. +func Write[O any](user *dsl.EOA, call bindings.TypedCall[O], opts ...txplan.Option) *types.Receipt { + checkTestable(call) + ctx, cancel := context.WithTimeout(call.Test().Ctx(), writeTimeout) + defer cancel() + finalOpts := txplan.Combine(user.Plan(), txplan.Combine(opts...)) + o, err := contractio.Write(call, ctx, finalOpts) + call.Test().Require().NoError(err, "contract write failed: %v", errutil.TryAddRevertReason(err)) + return o +} + +var _ TestCallView[any] = (*bindings.TypedCall[any])(nil) diff --git a/op-devstack/dsl/doc.go b/op-devstack/dsl/doc.go new file mode 100644 index 00000000000..0bbb529ce9b --- /dev/null +++ b/op-devstack/dsl/doc.go @@ -0,0 +1,11 @@ +/* +Package dsl provides DSL (domain specific language) to interact with a devstack system. + +Each component in the devstack has a DSL wrapper. +The wrapper itself does not have any state, and may be recreated or shallow-copied. + +Each DSL wrapper provides an Escape method, in case the DSL is not sufficient for a given use-case. +The Escape method is a temporary compromise to allow more incremental development of and +migration to the DSL. It should be avoided whenever possible and will be removed in the future. +*/ +package dsl diff --git a/op-devstack/dsl/ecotone_fees.go b/op-devstack/dsl/ecotone_fees.go new file mode 100644 index 00000000000..898ac06787f --- /dev/null +++ b/op-devstack/dsl/ecotone_fees.go @@ -0,0 +1,201 @@ +package dsl + +import ( + "math/big" + + "github.com/ethereum-optimism/optimism/op-core/predeploys" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/bigs" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +type EcotoneFees struct { + commonImpl + l2Network *L2Network +} + +type EcotoneFeesValidationResult struct { + TransactionReceipt *types.Receipt + L1Fee *big.Int + L2Fee *big.Int + BaseFee *big.Int + PriorityFee *big.Int + TotalFee *big.Int + VaultBalances VaultBalances + WalletBalanceDiff *big.Int + TransferAmount *big.Int +} + +type VaultBalances struct { + BaseFeeVault *big.Int + L1FeeVault *big.Int + SequencerVault *big.Int + OperatorVault *big.Int +} + +func NewEcotoneFees(t devtest.T, l2Network *L2Network) *EcotoneFees { + return &EcotoneFees{ + commonImpl: commonFromT(t), + l2Network: l2Network, + } +} + +func (ef *EcotoneFees) ValidateTransaction(from *EOA, to *EOA, amount *big.Int) EcotoneFeesValidationResult { + client := ef.l2Network.PrimaryEL().EthClient() + + startBalance := from.GetBalance() + vaultsBefore := ef.getVaultBalances(client) + + tx := from.Transfer(to.Address(), eth.WeiBig(amount)) + receipt, err := tx.Included.Eval(ef.ctx) + ef.require.NoError(err) + ef.require.Equal(types.ReceiptStatusSuccessful, receipt.Status) + + // Get block info for base fee information + blockInfo, err := client.InfoByHash(ef.ctx, receipt.BlockHash) + ef.require.NoError(err) + + endBalance := from.GetBalance() + vaultsAfter := ef.getVaultBalances(client) + vaultIncreases := ef.calculateVaultIncreases(vaultsBefore, vaultsAfter) + + // In Ecotone, L1 fee includes both base fee and blob base fee components + l1Fee := vaultIncreases.L1FeeVault // Use actual vault increase as the source of truth + + // Calculate receipt-based fees for validation + receiptBaseFee := new(big.Int).Mul(blockInfo.BaseFee(), big.NewInt(int64(receipt.GasUsed))) + receiptL2Fee := new(big.Int).Mul(receipt.EffectiveGasPrice, big.NewInt(int64(receipt.GasUsed))) + + // Calculate L2 fees from vault increases + baseFee := vaultIncreases.BaseFeeVault // Use actual vault increase as the source of truth + priorityFee := vaultIncreases.SequencerVault // Use actual vault increase as the source of truth + l2Fee := new(big.Int).Add(baseFee, priorityFee) + + // Total fee is the sum of all vault increases (excluding OperatorVault which should be zero in Ecotone) + totalFee := new(big.Int).Add(vaultIncreases.BaseFeeVault, vaultIncreases.L1FeeVault) + totalFee.Add(totalFee, vaultIncreases.SequencerVault) + + walletBalanceDiff := new(big.Int).Sub(startBalance.ToBig(), endBalance.ToBig()) + walletBalanceDiff.Sub(walletBalanceDiff, amount) + + // Validate total balance first to ensure all fees are accounted for + ef.validateTotalBalance(walletBalanceDiff, totalFee, vaultIncreases) + + // Then validate individual fee components + ef.validateFeeDistribution(l1Fee, baseFee, priorityFee, vaultIncreases) + ef.validateEcotoneFeatures(receipt, l1Fee) + ef.validateReceiptFees(receipt, l1Fee, baseFee, l2Fee, receiptBaseFee, receiptL2Fee) + + return EcotoneFeesValidationResult{ + TransactionReceipt: receipt, + L1Fee: l1Fee, + L2Fee: l2Fee, + BaseFee: baseFee, + PriorityFee: priorityFee, + TotalFee: totalFee, + VaultBalances: vaultIncreases, + WalletBalanceDiff: walletBalanceDiff, + TransferAmount: amount, + } +} + +func (ef *EcotoneFees) getVaultBalances(client apis.EthClient) VaultBalances { + baseFee := ef.getBalance(client, predeploys.BaseFeeVaultAddr) + l1Fee := ef.getBalance(client, predeploys.L1FeeVaultAddr) + sequencer := ef.getBalance(client, predeploys.SequencerFeeVaultAddr) + operator := ef.getBalance(client, predeploys.OperatorFeeVaultAddr) + + return VaultBalances{ + BaseFeeVault: baseFee, + L1FeeVault: l1Fee, + SequencerVault: sequencer, + OperatorVault: operator, + } +} + +func (ef *EcotoneFees) getBalance(client apis.EthClient, addr common.Address) *big.Int { + balance, err := client.BalanceAt(ef.ctx, addr, nil) + ef.require.NoError(err) + return balance +} + +func (ef *EcotoneFees) calculateVaultIncreases(before, after VaultBalances) VaultBalances { + return VaultBalances{ + BaseFeeVault: new(big.Int).Sub(after.BaseFeeVault, before.BaseFeeVault), + L1FeeVault: new(big.Int).Sub(after.L1FeeVault, before.L1FeeVault), + SequencerVault: new(big.Int).Sub(after.SequencerVault, before.SequencerVault), + OperatorVault: new(big.Int).Sub(after.OperatorVault, before.OperatorVault), + } +} + +func (ef *EcotoneFees) validateFeeDistribution(l1Fee, baseFee, priorityFee *big.Int, vaults VaultBalances) { + ef.require.True(l1Fee.Sign() >= 0, "L1 fee must be non-negative") + ef.require.True(baseFee.Sign() > 0, "Base fee must be positive") + ef.require.True(priorityFee.Sign() >= 0, "Priority fee must be non-negative") + + ef.require.Equal(l1Fee, vaults.L1FeeVault, "L1 fee must match L1FeeVault increase") + ef.require.Equal(baseFee, vaults.BaseFeeVault, "Base fee must match BaseFeeVault increase") + ef.require.Equal(priorityFee, vaults.SequencerVault, "Priority fee must match SequencerFeeVault increase") + + // In Ecotone, operator fees should not exist (introduced in Isthmus) + ef.require.Equal(vaults.OperatorVault.Cmp(big.NewInt(0)), 0, + "Operator vault increase must be zero in Ecotone (operator fees introduced in Isthmus)") +} + +func (ef *EcotoneFees) validateTotalBalance(walletDiff *big.Int, totalFee *big.Int, vaults VaultBalances) { + // In Ecotone, only BaseFeeVault, L1FeeVault, and SequencerVault should have increases + totalVaultIncrease := new(big.Int).Add(vaults.BaseFeeVault, vaults.L1FeeVault) + totalVaultIncrease.Add(totalVaultIncrease, vaults.SequencerVault) + + ef.require.Equal(walletDiff, totalFee, "Wallet balance difference must equal total fees") + ef.require.Equal(totalVaultIncrease, totalFee, "Total vault increases must equal total fees") +} + +func (ef *EcotoneFees) validateEcotoneFeatures(receipt *types.Receipt, l1Fee *big.Int) { + ef.require.NotNil(receipt.L1Fee, "L1 fee should be present in Ecotone") + ef.require.True(l1Fee.Cmp(big.NewInt(0)) > 0, "L1 fee should be greater than 0 in Ecotone") + ef.require.Greater(receipt.GasUsed, uint64(20000), "Gas used should be reasonable for transfer") + ef.require.Less(receipt.GasUsed, uint64(50000), "Gas used should not be excessive") + ef.require.Greater(bigs.Uint64Strict(receipt.EffectiveGasPrice), uint64(0), "Effective gas price should be > 0") +} + +func (ef *EcotoneFees) validateReceiptFees(receipt *types.Receipt, l1Fee, vaultBaseFee, vaultL2Fee, receiptBaseFee, receiptL2Fee *big.Int) { + // Check that receipt's L1Fee matches the vault increase + if receipt.L1Fee != nil { + ef.require.Equal(receipt.L1Fee, l1Fee, "Receipt L1Fee must match L1FeeVault increase") + } + + // Sanity check: Receipt-calculated fees should match vault-based fees + ef.require.Equal(receiptBaseFee, vaultBaseFee, + "Receipt-calculated base fee (block.BaseFee * gasUsed) must match BaseFeeVault increase") + ef.require.Equal(receiptL2Fee, vaultL2Fee, + "Receipt-calculated L2 fee (effectiveGasPrice * gasUsed) must match L2 vault increases (BaseFee + SequencerFee)") + + // Validate receipt-based calculations are positive + ef.require.True(receiptBaseFee.Sign() > 0, "Receipt-based base fee must be positive") + ef.require.True(receiptL2Fee.Sign() > 0, "Receipt-based L2 fee must be positive") + + // The effective gas price should be consistent with the calculated L2 fee + ef.require.Equal(receiptL2Fee.Cmp(receiptBaseFee) >= 0, true, + "Receipt L2 fee (effectiveGasPrice * gasUsed) should be >= base fee") +} + +func (ef *EcotoneFees) LogResults(result EcotoneFeesValidationResult) { + ef.log.Info("Comprehensive Ecotone fees validation completed", + "gasUsed", result.TransactionReceipt.GasUsed, + "effectiveGasPrice", result.TransactionReceipt.EffectiveGasPrice, + "l1Fee", result.L1Fee, + "l2Fee", result.L2Fee, + "baseFee", result.BaseFee, + "priorityFee", result.PriorityFee, + "totalFee", result.TotalFee, + "baseFeeVault", result.VaultBalances.BaseFeeVault, + "l1FeeVault", result.VaultBalances.L1FeeVault, + "sequencerVault", result.VaultBalances.SequencerVault, + "operatorVault", result.VaultBalances.OperatorVault, + "walletBalanceDiff", result.WalletBalanceDiff, + "transferAmount", result.TransferAmount) +} diff --git a/op-devstack/dsl/el.go b/op-devstack/dsl/el.go new file mode 100644 index 00000000000..d0245b11ba0 --- /dev/null +++ b/op-devstack/dsl/el.go @@ -0,0 +1,132 @@ +package dsl + +import ( + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils/wait" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +type ELNode interface { + ChainID() eth.ChainID + stackEL() stack.ELNode +} + +// elNode implements DSL common between L1 and L2 EL nodes. +type elNode struct { + commonImpl + inner stack.ELNode +} + +var _ ELNode = (*elNode)(nil) + +func newELNode(common commonImpl, inner stack.ELNode) *elNode { + return &elNode{ + commonImpl: common, + inner: inner, + } +} + +func (el *elNode) ChainID() eth.ChainID { + return el.inner.ChainID() +} + +func (el *elNode) WaitForBlock() eth.BlockRef { + return el.waitForNextBlock(1) +} + +func (el *elNode) WaitForLabel(label eth.BlockLabel, predicate func(eth.BlockInfo) (bool, error)) eth.BlockInfo { + var block eth.BlockInfo + err := wait.For(el.ctx, 200*time.Millisecond, func() (bool, error) { + var err error + block, err = el.inner.EthClient().InfoByLabel(el.ctx, label) + if err != nil { + return false, err + } + ok, err := predicate(block) + if ok { + el.log.Info("Target block reached", "chain", el.ChainID(), "block", eth.ToBlockID(block)) + } else if err == nil { + el.log.Debug("Target block not reached yet", "chain", el.ChainID(), "block", eth.ToBlockID(block)) + } + return ok, err + }) + el.require.NoError(err, "Failed to find block") + return block +} + +func (el *elNode) WaitForLabelRef(label eth.BlockLabel, predicate func(eth.BlockInfo) (bool, error)) eth.BlockRef { + return eth.InfoToL1BlockRef(el.WaitForLabel(label, predicate)) +} + +func (el *elNode) WaitForUnsafe(predicate func(eth.BlockInfo) (bool, error)) eth.BlockInfo { + return el.WaitForLabel(eth.Unsafe, predicate) +} + +func (el *elNode) WaitForUnsafeRef(predicate func(eth.BlockInfo) (bool, error)) eth.BlockRef { + return eth.InfoToL1BlockRef(el.WaitForUnsafe(predicate)) +} + +func (el *elNode) WaitForBlockNumber(targetBlock uint64) eth.BlockInfo { + info := el.WaitForUnsafe(func(info eth.BlockInfo) (bool, error) { + return info.NumberU64() >= targetBlock, nil + }) + if info.NumberU64() == targetBlock { + return info + } + // we've gone too far + info, err := el.inner.EthClient().InfoByNumber(el.ctx, targetBlock) + el.require.NoError(err, "Expected to get info by number after waiting") + return info +} + +func (el *elNode) WaitForOnline() { + el.require.Eventually(func() bool { + el.log.Info("Waiting for online") + _, err := el.inner.EthClient().InfoByLabel(el.ctx, eth.Unsafe) + return err == nil + }, 10*time.Second, 500*time.Millisecond, "Expected to be online") +} + +func (el *elNode) IsCanonical(ref eth.BlockID) bool { + blk, err := el.inner.EthClient().BlockRefByNumber(el.t.Ctx(), ref.Number) + el.require.NoError(err) + + return blk.Hash == ref.Hash +} + +// waitForNextBlockWithTimeout waits until the specified block number is present +func (el *elNode) waitForNextBlock(blocksFromNow uint64) eth.BlockRef { + initial, err := el.inner.EthClient().InfoByLabel(el.ctx, eth.Unsafe) + el.require.NoError(err, "Expected to get latest block from execution client") + targetBlock := initial.NumberU64() + blocksFromNow + + return el.WaitForUnsafeRef(func(info eth.BlockInfo) (bool, error) { + return info.NumberU64() >= targetBlock, nil + }) +} + +// WaitForTime waits until the chain has reached or surpassed the given timestamp. +func (el *elNode) WaitForTime(timestamp uint64) eth.BlockRef { + return el.WaitForUnsafeRef(func(info eth.BlockInfo) (bool, error) { + return info.Time() >= timestamp, nil + }) +} + +func (el *elNode) stackEL() stack.ELNode { + return el.inner +} + +// WaitForFinalization waits for the current block height to be finalized. Note that it does not +// ensure that the finalized block is the same as the current unsafe block (i.e., it is not +// reorg-aware). +func (el *elNode) WaitForFinalization() eth.BlockRef { + // Get current block and wait for it to be finalized + currentBlock, err := el.inner.EthClient().InfoByLabel(el.ctx, eth.Unsafe) + el.require.NoError(err, "Expected to get current block from execution client") + + return el.WaitForLabelRef(eth.Finalized, func(info eth.BlockInfo) (bool, error) { + return info.NumberU64() >= currentBlock.NumberU64(), nil + }) +} diff --git a/op-devstack/dsl/engine.go b/op-devstack/dsl/engine.go new file mode 100644 index 00000000000..741c9f4e895 --- /dev/null +++ b/op-devstack/dsl/engine.go @@ -0,0 +1,115 @@ +package dsl + +import ( + "errors" + "fmt" + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/retry" +) + +type NewPayloadResult struct { + T devtest.T + Status *eth.PayloadStatusV1 + Err error +} + +func (r *NewPayloadResult) IsPayloadStatus(status eth.ExecutePayloadStatus) *NewPayloadResult { + r.T.Require().NotNil(r.Status, "payload status nil") + r.T.Require().Equal(status, r.Status.Status) + return r +} + +func (r *NewPayloadResult) IsSyncing() *NewPayloadResult { + r.IsPayloadStatus(eth.ExecutionSyncing) + r.T.Require().NoError(r.Err) + return r +} + +func (r *NewPayloadResult) IsValid() *NewPayloadResult { + r.IsPayloadStatus(eth.ExecutionValid) + r.T.Require().NoError(r.Err) + return r +} + +func (r *NewPayloadResult) IsInvalid() *NewPayloadResult { + r.IsPayloadStatus(eth.ExecutionInvalid) + r.T.Require().NoError(r.Err) + return r +} + +type ForkchoiceUpdateResult struct { + T devtest.T + Refresh func() + Result *eth.ForkchoiceUpdatedResult + ValidCnt int // count for VALID response + SyncingCnt int // count for SYNCING response + InvalidCnt int // count for INVALID response + RefreshCnt int + Err error +} + +func (r *ForkchoiceUpdateResult) IsForkchoiceUpdatedStatus(status eth.ExecutePayloadStatus) *ForkchoiceUpdateResult { + r.T.Require().NotNil(r.Result, "fcu result nil") + r.T.Require().Equal(status, r.Result.PayloadStatus.Status) + return r +} + +func (r *ForkchoiceUpdateResult) IsSyncing() *ForkchoiceUpdateResult { + r.IsForkchoiceUpdatedStatus(eth.ExecutionSyncing) + r.T.Require().NoError(r.Err) + return r +} + +func (r *ForkchoiceUpdateResult) IsValid() *ForkchoiceUpdateResult { + r.IsForkchoiceUpdatedStatus(eth.ExecutionValid) + r.T.Require().NoError(r.Err) + return r +} + +func (r *ForkchoiceUpdateResult) WaitUntilValid(attempts int) *ForkchoiceUpdateResult { + tryCnt := 0 + err := retry.Do0(r.T.Ctx(), attempts, &retry.FixedStrategy{Dur: 1 * time.Second}, + func() error { + r.Refresh() + tryCnt += 1 + if r.Err != nil { + return fmt.Errorf("forkchoice returned error: %w", r.Err) + } + if r.Result == nil { + return errors.New("forkchoice has empty result") + } + if r.Result.PayloadStatus.Status != eth.ExecutionValid { + r.T.Logger().Info("Wait for FCU to return valid", "status", r.Result.PayloadStatus.Status, "try_count", tryCnt) + return errors.New("still syncing") + } + return nil + }) + r.T.Require().NoError(err) + return r +} + +func (r *ForkchoiceUpdateResult) Retry(attempts int) *ForkchoiceUpdateResult { + tryCnt := 0 + err := retry.Do0(r.T.Ctx(), attempts, &retry.FixedStrategy{Dur: 500 * time.Millisecond}, + func() error { + r.Refresh() + tryCnt += 1 + if r.Err != nil { + return fmt.Errorf("forkchoice returned error: %w", r.Err) + } + if r.Result == nil { + return errors.New("forkchoice has empty result") + } + r.T.Logger().Info("Retrying FCU", "status", r.Result.PayloadStatus.Status, "try_count", tryCnt) + return errors.New("retry") + }) + r.T.Require().Error(err) // always return error for retrying + return r +} + +func (r *ForkchoiceUpdateResult) ResultAllSyncing() { + r.T.Require().Equal(r.RefreshCnt, r.SyncingCnt) +} diff --git a/op-devstack/dsl/eoa.go b/op-devstack/dsl/eoa.go index d23499e89fe..a2cc5d6e65d 100644 --- a/op-devstack/dsl/eoa.go +++ b/op-devstack/dsl/eoa.go @@ -496,33 +496,30 @@ func (u *EOA) PrepareSameTimestampInit( } } -// SubmitInit submits the init message without waiting for inclusion. -// Returns the planned tx which can be used to wait for inclusion later. +// SubmitInit returns a planned init transaction for same-timestamp testing. +// The test harness assigns deterministic nonces and includes the signed tx directly. func (p *SameTimestampPair) SubmitInit() *txplan.PlannedTx { tx := txintent.NewIntent[*txintent.InitTrigger, *txintent.InteropOutput](p.eoa.Plan()) tx.Content.Set(p.Trigger) - _, err := tx.PlannedTx.Submitted.Eval(p.eoa.ctx) - p.eoa.require.NoError(err, "failed to submit init message") return tx.PlannedTx } -// SubmitExecTo submits an exec message to the given EOA's chain, referencing this init. -// The exec is submitted without waiting for inclusion. -// Returns the planned tx which can be used to wait for inclusion later. +// SubmitExecTo returns a planned exec transaction to the given EOA's chain, +// referencing this init. The test harness assigns deterministic nonces and +// includes the signed tx directly. func (p *SameTimestampPair) SubmitExecTo(executor *EOA) *txplan.PlannedTx { tx := txintent.NewIntent[*txintent.ExecTrigger, *txintent.InteropOutput](executor.Plan()) tx.Content.Set(&txintent.ExecTrigger{ Executor: predeploys.CrossL2InboxAddr, Msg: p.Message, }) - _, err := tx.PlannedTx.Submitted.Eval(executor.ctx) - executor.require.NoError(err, "failed to submit exec message") return tx.PlannedTx } // SubmitInvalidExecTo submits an exec message with an invalid log index. // This creates an exec that references a non-existent log, which should be detected as invalid. -// Returns the planned tx which can be used to wait for inclusion later. +// Returns the planned tx; the test harness assigns deterministic nonces and +// includes the signed tx directly. func (p *SameTimestampPair) SubmitInvalidExecTo(executor *EOA) *txplan.PlannedTx { invalidMsg := MakeInvalidLogIndex(p.Message) @@ -531,7 +528,5 @@ func (p *SameTimestampPair) SubmitInvalidExecTo(executor *EOA) *txplan.PlannedTx Executor: predeploys.CrossL2InboxAddr, Msg: invalidMsg, }) - _, err := tx.PlannedTx.Submitted.Eval(executor.ctx) - executor.require.NoError(err, "failed to submit invalid exec message") return tx.PlannedTx } diff --git a/op-devstack/dsl/faucet.go b/op-devstack/dsl/faucet.go new file mode 100644 index 00000000000..6ace03b4668 --- /dev/null +++ b/op-devstack/dsl/faucet.go @@ -0,0 +1,50 @@ +package dsl + +import ( + "github.com/ethereum/go-ethereum/common" + + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/retry" +) + +// Faucet wraps a stack.Faucet interface for DSL operations. +// A Faucet is chain-specific. +// Note: Faucet wraps a stack component, to share faucet operations in kurtosis by hosting it as service, +// and prevent race-conditions with the account that sends out the faucet funds. +type Faucet struct { + commonImpl + inner stack.Faucet +} + +// NewFaucet creates a new Faucet DSL wrapper +func NewFaucet(inner stack.Faucet) *Faucet { + return &Faucet{ + commonImpl: commonFromT(inner.T()), + inner: inner, + } +} + +func (f *Faucet) String() string { + return f.inner.Name() +} + +// Escape returns the underlying stack.Faucet +func (f *Faucet) Escape() stack.Faucet { + return f.inner +} + +// Fund funds the given address with the given amount of ETH +func (f *Faucet) Fund(addr common.Address, amount eth.ETH) { + if amount.IsZero() { + return + } + err := retry.Do0(f.ctx, 3, retry.Exponential(), func() error { + err := f.inner.API().RequestETH(f.ctx, addr, amount) + if err != nil { + f.log.Warn("Failed to fund address", "addr", addr, "amount", amount, "err", err) + } + return err + }) + f.require.NoError(err, "must fund account %s with %s", addr, amount) +} diff --git a/op-devstack/dsl/fjord_fees.go b/op-devstack/dsl/fjord_fees.go new file mode 100644 index 00000000000..5e83055568c --- /dev/null +++ b/op-devstack/dsl/fjord_fees.go @@ -0,0 +1,369 @@ +package dsl + +import ( + "context" + "fmt" + "math/big" + + "github.com/ethereum-optimism/optimism/op-core/predeploys" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/bigs" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/txintent/bindings" + "github.com/ethereum-optimism/optimism/op-service/txintent/contractio" + "github.com/ethereum-optimism/optimism/op-service/txplan" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +type FjordFees struct { + commonImpl + l2Network *L2Network +} + +type FjordFeesValidationResult struct { + TransactionReceipt *types.Receipt + L1Fee *big.Int + L2Fee *big.Int + BaseFee *big.Int + PriorityFee *big.Int + TotalFee *big.Int + VaultBalances VaultBalances + WalletBalanceDiff *big.Int + TransferAmount *big.Int + FastLzSize uint64 + EstimatedBrotliSize *big.Int + OperatorFee *big.Int + CoinbaseDiff *big.Int +} + +func NewFjordFees(t devtest.T, l2Network *L2Network) *FjordFees { + return &FjordFees{ + commonImpl: commonFromT(t), + l2Network: l2Network, + } +} + +// ValidateTransaction validates the transaction and returns the validation result +func (ff *FjordFees) ValidateTransaction(from *EOA, to *EOA, amount *big.Int) FjordFeesValidationResult { + client := ff.l2Network.PrimaryEL().EthClient() + + startBalance := from.GetBalance() + vaultsBefore := ff.getVaultBalances(client) + coinbaseStartBalance := ff.getCoinbaseBalance(client) + + tx := from.Transfer(to.Address(), eth.WeiBig(amount)) + receipt, err := tx.Included.Eval(ff.ctx) + ff.require.NoError(err) + ff.require.Equal(types.ReceiptStatusSuccessful, receipt.Status) + + endBalance := from.GetBalance() + vaultsAfter := ff.getVaultBalances(client) + vaultIncreases := ff.calculateVaultIncreases(vaultsBefore, vaultsAfter) + coinbaseEndBalance := ff.getCoinbaseBalance(client) + coinbaseDiff := new(big.Int).Sub(coinbaseEndBalance, coinbaseStartBalance) + + l1Fee := big.NewInt(0) + if receipt.L1Fee != nil { + l1Fee = receipt.L1Fee + } + + block, err := client.InfoByHash(ff.ctx, receipt.BlockHash) + ff.require.NoError(err) + + baseFee := new(big.Int).Mul(block.BaseFee(), big.NewInt(int64(receipt.GasUsed))) + totalGasFee := new(big.Int).Mul(receipt.EffectiveGasPrice, big.NewInt(int64(receipt.GasUsed))) + priorityFee := new(big.Int).Sub(totalGasFee, baseFee) + + l2Fee := new(big.Int).Set(priorityFee) + + operatorFee := vaultIncreases.OperatorVault + + ff.validateVaultIncreaseFees(l2Fee, baseFee, priorityFee, l1Fee, operatorFee, coinbaseDiff, vaultsAfter, vaultsBefore) + + totalFee := new(big.Int).Add(l1Fee, l2Fee) + totalFee.Add(totalFee, baseFee) + totalFee.Add(totalFee, operatorFee) + + walletBalanceDiff := new(big.Int).Sub(startBalance.ToBig(), endBalance.ToBig()) + walletBalanceDiff.Sub(walletBalanceDiff, amount) + + fastLzSize, estimatedBrotliSize := ff.validateFjordFeatures(receipt, l1Fee) + ff.validateFeeDistribution(l1Fee, baseFee, priorityFee, operatorFee, vaultIncreases) + ff.validateTotalBalance(walletBalanceDiff, totalFee, vaultIncreases) + + return FjordFeesValidationResult{ + TransactionReceipt: receipt, + L1Fee: l1Fee, + L2Fee: l2Fee, + BaseFee: baseFee, + PriorityFee: priorityFee, + TotalFee: totalFee, + VaultBalances: vaultIncreases, + WalletBalanceDiff: walletBalanceDiff, + TransferAmount: amount, + FastLzSize: fastLzSize, + EstimatedBrotliSize: estimatedBrotliSize, + OperatorFee: operatorFee, + CoinbaseDiff: coinbaseDiff, + } +} + +// getVaultBalances gets the balances of the vaults +func (ff *FjordFees) getVaultBalances(client apis.EthClient) VaultBalances { + baseFee := ff.getBalance(client, predeploys.BaseFeeVaultAddr) + l1Fee := ff.getBalance(client, predeploys.L1FeeVaultAddr) + sequencer := ff.getBalance(client, predeploys.SequencerFeeVaultAddr) + operator := ff.getBalance(client, predeploys.OperatorFeeVaultAddr) + + return VaultBalances{ + BaseFeeVault: baseFee, + L1FeeVault: l1Fee, + SequencerVault: sequencer, + OperatorVault: operator, + } +} + +// getBalance gets the balance of an address +func (ff *FjordFees) getBalance(client apis.EthClient, addr common.Address) *big.Int { + balance, err := client.BalanceAt(ff.ctx, addr, nil) + ff.require.NoError(err) + return balance +} + +// calculateVaultIncreases calculates the increases in the vaults +func (ff *FjordFees) calculateVaultIncreases(before, after VaultBalances) VaultBalances { + return VaultBalances{ + BaseFeeVault: new(big.Int).Sub(after.BaseFeeVault, before.BaseFeeVault), + L1FeeVault: new(big.Int).Sub(after.L1FeeVault, before.L1FeeVault), + SequencerVault: new(big.Int).Sub(after.SequencerVault, before.SequencerVault), + OperatorVault: new(big.Int).Sub(after.OperatorVault, before.OperatorVault), + } +} + +// validateFjordFeatures validates that the features of the Fjord transaction are correct +func (ff *FjordFees) validateFjordFeatures(receipt *types.Receipt, l1Fee *big.Int) (uint64, *big.Int) { + ff.require.NotNil(receipt.L1Fee, "L1 fee should be present in Fjord") + ff.require.True(l1Fee.Cmp(big.NewInt(0)) > 0, "L1 fee should be greater than 0 in Fjord") + + client := ff.l2Network.PrimaryEL().EthClient() + + _, txs, err := client.InfoAndTxsByHash(ff.ctx, receipt.BlockHash) + ff.require.NoError(err) + + var signedTx *types.Transaction + for _, tx := range txs { + if tx.Hash() == receipt.TxHash { + signedTx = tx + break + } + } + ff.require.NotNil(signedTx, "should find the signed transaction") + + unsignedTx := types.NewTx(&types.DynamicFeeTx{ + Nonce: signedTx.Nonce(), + To: signedTx.To(), + Value: signedTx.Value(), + Gas: signedTx.Gas(), + GasFeeCap: signedTx.GasFeeCap(), + GasTipCap: signedTx.GasTipCap(), + Data: signedTx.Data(), + }) + + txUnsigned, err := unsignedTx.MarshalBinary() + ff.require.NoError(err) + txSigned, err := signedTx.MarshalBinary() + ff.require.NoError(err) + + fastLzSizeUnsigned := uint64(types.FlzCompressLen(txUnsigned) + 68) // overhead used by the original test + fastLzSizeSigned := uint64(types.FlzCompressLen(txSigned)) + + // Validate that FastLZ compression produces reasonable results + ff.require.Greater(fastLzSizeUnsigned, uint64(0), "FastLZ size should be positive") + ff.require.Greater(fastLzSizeSigned, uint64(0), "FastLZ size should be positive") + + txLenGPO := len(txUnsigned) + 68 + flzUpperBound := uint64(txLenGPO + txLenGPO/255 + 16) + ff.require.LessOrEqual(fastLzSizeUnsigned, flzUpperBound, "Compressed size should not exceed upper bound") + + signedUpperBound := uint64(len(txSigned) + len(txSigned)/255 + 16) + ff.require.LessOrEqual(fastLzSizeSigned, signedUpperBound, "Compressed size should not exceed upper bound") + + receiptL1Fee := receipt.L1Fee + if receiptL1Fee == nil { + ff.t.Logf("L1 fee is nil in receipt, skipping L1 fee validation") + return fastLzSizeSigned, nil + } + + expectedFee, err := CalculateFjordL1Cost(ff.ctx, client, signedTx.RollupCostData(), receipt.BlockHash) + ff.require.NoError(err, "should calculate L1 fee") + + ff.require.Equalf(expectedFee, receiptL1Fee, "Calculated L1 fee should match receipt L1 fee (expected=%s actual=%s)", expectedFee.String(), receiptL1Fee.String()) + + ff.require.Equalf(expectedFee, receipt.L1Fee, "L1 fee in receipt must be correct (expected=%s actual=%s)", expectedFee.String(), receipt.L1Fee.String()) + + return fastLzSizeSigned, expectedFee +} + +// validateFeeDistribution validates that the fees are distributed correctly to the vaults +func (ff *FjordFees) validateFeeDistribution(l1Fee, baseFee, priorityFee, operatorFee *big.Int, vaults VaultBalances) { + ff.require.True(l1Fee.Sign() >= 0, "L1 fee must be non-negative") + ff.require.True(baseFee.Sign() > 0, "Base fee must be positive") + ff.require.True(priorityFee.Sign() >= 0, "Priority fee must be non-negative") + ff.require.True(operatorFee.Sign() >= 0, "Operator fee must be non-negative") + + ff.require.Equal(l1Fee, vaults.L1FeeVault, "L1 fee must match L1FeeVault increase") + ff.require.Equal(baseFee, vaults.BaseFeeVault, "Base fee must match BaseFeeVault increase") + ff.require.Equal(priorityFee, vaults.SequencerVault, "Priority fee must match SequencerFeeVault increase") + ff.require.Equal(operatorFee, vaults.OperatorVault, "Operator fee must match OperatorFeeVault increase") +} + +// validateTotalBalance validates that the total balance of the wallet and the vaults is correct +func (ff *FjordFees) validateTotalBalance(walletDiff *big.Int, totalFee *big.Int, vaults VaultBalances) { + totalVaultIncrease := new(big.Int).Add(vaults.BaseFeeVault, vaults.L1FeeVault) + totalVaultIncrease.Add(totalVaultIncrease, vaults.SequencerVault) + totalVaultIncrease.Add(totalVaultIncrease, vaults.OperatorVault) + + ff.require.Equal(walletDiff, totalFee, "Wallet balance difference must equal total fees") + ff.require.Equal(totalVaultIncrease, totalFee, "Total vault increases must equal total fees") +} + +// getCoinbaseBalance gets the balance of the coinbase address (block miner/sequencer) +func (ff *FjordFees) getCoinbaseBalance(client apis.EthClient) *big.Int { + block, err := client.InfoByLabel(ff.ctx, "latest") + ff.require.NoError(err, "should get latest block") + + coinbase := block.Coinbase() + balance, err := client.BalanceAt(ff.ctx, coinbase, nil) + ff.require.NoError(err, "should get coinbase balance") + return balance +} + +// validateVaultIncreaseFees validates that the fees are distributed correctly to the vaults +func (ff *FjordFees) validateVaultIncreaseFees( + l2Fee, baseFee, priorityFee, l1Fee, operatorFee, coinbaseDiff *big.Int, + vaultsAfter, vaultsBefore VaultBalances) { + + ff.require.Equal(l2Fee, coinbaseDiff, "L2 fee must equal coinbase difference (coinbase is always sequencer fee vault)") + + vaultsIncrease := ff.calculateVaultIncreases(vaultsBefore, vaultsAfter) + ff.require.Equal(baseFee, vaultsIncrease.BaseFeeVault, "base fee must match BaseFeeVault increase") + + ff.require.Equal(priorityFee, vaultsIncrease.SequencerVault, "priority fee must match SequencerFeeVault increase") + + ff.require.Equal(l1Fee, vaultsIncrease.L1FeeVault, "L1 fee must match L1FeeVault increase") + + ff.require.Equal(operatorFee, vaultsIncrease.OperatorVault, "operator fee must match OperatorFeeVault increase") + + ff.t.Logf("Comprehensive fee validation passed:") + ff.t.Logf(" L2 Fee: %s (coinbase diff: %s)", l2Fee, coinbaseDiff) + ff.t.Logf(" Base Fee: %s (vault increase: %s)", baseFee, vaultsIncrease.BaseFeeVault) + ff.t.Logf(" Priority Fee: %s (vault increase: %s)", priorityFee, vaultsIncrease.SequencerVault) + ff.t.Logf(" L1 Fee: %s (vault increase: %s)", l1Fee, vaultsIncrease.L1FeeVault) + ff.t.Logf(" Operator Fee: %s (vault increase: %s)", operatorFee, vaultsIncrease.OperatorVault) +} + +// FindSignedTransactionFromReceipt finds the signed transaction from a receipt and block +func FindSignedTransactionFromReceipt(ctx context.Context, client apis.EthClient, receipt *types.Receipt) (*types.Transaction, error) { + _, txs, err := client.InfoAndTxsByHash(ctx, receipt.BlockHash) + if err != nil { + return nil, err + } + + for _, tx := range txs { + if tx.Hash() == receipt.TxHash { + return tx, nil + } + } + return nil, fmt.Errorf("signed transaction not found for hash %s", receipt.TxHash) +} + +// CreateUnsignedTransactionFromSigned creates an unsigned transaction from a signed one +func CreateUnsignedTransactionFromSigned(signedTx *types.Transaction) (*types.Transaction, error) { + return types.NewTx(&types.DynamicFeeTx{ + Nonce: signedTx.Nonce(), + To: signedTx.To(), + Value: signedTx.Value(), + Gas: signedTx.Gas(), + GasFeeCap: signedTx.GasFeeCap(), + GasTipCap: signedTx.GasTipCap(), + Data: signedTx.Data(), + }), nil +} + +// ReadGasPriceOracleL1FeeAt reads the L1 fee from GasPriceOracle for an unsigned transaction +// evaluated against a specific L2 block hash. +func ReadGasPriceOracleL1FeeAt(ctx context.Context, client apis.EthClient, gpo *bindings.GasPriceOracle, txUnsigned []byte, blockHash common.Hash) (*big.Int, error) { + overrideBlockOpt := func(ptx *txplan.PlannedTx) { + ptx.AgainstBlock.Fn(func(ctx context.Context) (eth.BlockInfo, error) { + return client.InfoByHash(ctx, blockHash) + }) + } + result, err := contractio.Read(gpo.GetL1Fee(txUnsigned), ctx, overrideBlockOpt) + if err != nil { + return nil, err + } + return result.ToBig(), nil +} + +// ReadGasPriceOracleL1FeeUpperBoundAt reads the L1 fee upper bound for a tx length pinned to a block hash. +func ReadGasPriceOracleL1FeeUpperBoundAt(ctx context.Context, client apis.EthClient, gpo *bindings.GasPriceOracle, txLen int, blockHash common.Hash) (*big.Int, error) { + overrideBlockOpt := func(ptx *txplan.PlannedTx) { + ptx.AgainstBlock.Fn(func(ctx context.Context) (eth.BlockInfo, error) { + return client.InfoByHash(ctx, blockHash) + }) + } + result, err := contractio.Read(gpo.GetL1FeeUpperBound(big.NewInt(int64(txLen))), ctx, overrideBlockOpt) + if err != nil { + return nil, err + } + return result.ToBig(), nil +} + +// ValidateL1FeeMatches checks that the calculated L1 fee matches the actual receipt L1 fee +func ValidateL1FeeMatches(t devtest.T, calculatedFee, receiptFee *big.Int) { + require := t.Require() + require.NotNil(receiptFee, "L1 fee should be present in receipt") + require.Equalf(bigs.Uint64Strict(calculatedFee), bigs.Uint64Strict(receiptFee), "L1 fee mismatch (expected=%v actual=%v)", calculatedFee, receiptFee) +} + +// CalculateFjordL1Cost calculates L1 cost using Fjord formula with block-specific L1 state +func CalculateFjordL1Cost(ctx context.Context, client apis.EthClient, rollupCostData types.RollupCostData, blockHash common.Hash) (*big.Int, error) { + l1Block := bindings.NewL1Block( + bindings.WithClient(client), + bindings.WithTo(predeploys.L1BlockAddr), + ) + + overrideBlockOpt := func(ptx *txplan.PlannedTx) { + ptx.AgainstBlock.Fn(func(ctx context.Context) (eth.BlockInfo, error) { + return client.InfoByHash(ctx, blockHash) + }) + } + + baseFeeScalar, err := contractio.Read(l1Block.BasefeeScalar(), ctx, overrideBlockOpt) + if err != nil { + return nil, err + } + l1BaseFee, err := contractio.Read(l1Block.Basefee(), ctx, overrideBlockOpt) + if err != nil { + return nil, err + } + blobBaseFeeScalar, err := contractio.Read(l1Block.BlobBaseFeeScalar(), ctx, overrideBlockOpt) + if err != nil { + return nil, err + } + blobBaseFee, err := contractio.Read(l1Block.BlobBaseFee(), ctx, overrideBlockOpt) + if err != nil { + return nil, err + } + + costFunc := types.NewL1CostFuncFjord( + l1BaseFee, + blobBaseFee, + new(big.Int).SetUint64(uint64(baseFeeScalar)), + new(big.Int).SetUint64(uint64(blobBaseFeeScalar))) + + fee, _ := costFunc(rollupCostData) + return fee, nil +} diff --git a/op-devstack/dsl/funder.go b/op-devstack/dsl/funder.go new file mode 100644 index 00000000000..da40b463b88 --- /dev/null +++ b/op-devstack/dsl/funder.go @@ -0,0 +1,76 @@ +package dsl + +import ( + "sync" + + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +type Funder struct { + commonImpl + wallet *HDWallet + faucet *Faucet + el ELNode +} + +func NewFunder(w *HDWallet, f *Faucet, el ELNode) *Funder { + f.t.Require().Equal(f.inner.ChainID(), el.ChainID(), "faucet and EL must be on same chain") + return &Funder{ + commonImpl: commonFromT(w.t), + wallet: w, + faucet: f, + el: el, + } +} + +func (f *Funder) NewFundedEOA(amount eth.ETH) *EOA { + eoa := f.wallet.NewEOA(f.el) + f.FundAtLeast(eoa, amount) + return eoa +} + +func (f *Funder) NewFundedEOAs(count int, amount eth.ETH) []*EOA { + eoas := func() []*EOA { + eoas := make([]*EOA, count) + var wg sync.WaitGroup + defer wg.Wait() + for idx := range len(eoas) { + wg.Add(1) + go func() { + defer wg.Done() + eoas[idx] = f.NewFundedEOA(amount) + }() + } + return eoas + }() + for _, eoa := range eoas { + // For a large count, the faucet may fail. + // This sanity check prevents surprising errors down the line. + f.require.NotNil(eoa) + } + return eoas +} + +func (f *Funder) Fund(wallet *EOA, amount eth.ETH) eth.ETH { + currentBalance := wallet.balance() + f.faucet.Fund(wallet.Address(), amount) + finalBalance := currentBalance.Add(amount) + wallet.WaitForBalance(finalBalance) + return finalBalance +} + +func (f *Funder) FundNoWait(wallet *EOA, amount eth.ETH) { + f.faucet.Fund(wallet.Address(), amount) +} + +func (f *Funder) FundAtLeast(wallet *EOA, amount eth.ETH) eth.ETH { + currentBalance := wallet.balance() + if currentBalance.Lt(amount) { + missing := amount.Sub(currentBalance) + f.faucet.Fund(wallet.Address(), missing) + finalBalance := currentBalance.Add(missing) + wallet.WaitForBalance(finalBalance) + return finalBalance + } + return currentBalance +} diff --git a/op-devstack/dsl/hd_wallet.go b/op-devstack/dsl/hd_wallet.go new file mode 100644 index 00000000000..4e8a39d5246 --- /dev/null +++ b/op-devstack/dsl/hd_wallet.go @@ -0,0 +1,74 @@ +package dsl + +import ( + "fmt" + "os" + "sync/atomic" + + hdwallet "github.com/ethereum-optimism/go-ethereum-hdwallet" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" +) + +const ( + SaltEnvVar = "DEVSTACK_KEYS_SALT" +) + +// HDWallet is a collection of deterministic accounts, +// generated from an underlying devkeys keyring, +// using the standard cross-chain user identities. +type HDWallet struct { + commonImpl + keys devkeys.Keys + nextUserIndex atomic.Uint64 + hdWalletName string +} + +func NewRandomHDWallet(t devtest.T, startIndex uint64) *HDWallet { + mnemonic, err := hdwallet.NewMnemonic(256) + require.NoError(t, err, "failed to generate mnemonic") + return NewHDWallet(t, mnemonic, startIndex) +} + +func NewHDWallet(t devtest.T, mnemonic string, startIndex uint64) *HDWallet { + hd, err := devkeys.NewSaltedDevKeys(mnemonic, os.Getenv(SaltEnvVar)) + t.Require().NoError(err, "must have valid mnemonic") + w := &HDWallet{ + commonImpl: commonFromT(t), + keys: hd, + // We don't want to leak the mnemonic so easily with a string method, + // but we do want to uniquely identify it to distinguish it from other wallets, + // so we just hash it. + hdWalletName: fmt.Sprintf("HDWallet(%s)", crypto.Keccak256Hash([]byte(mnemonic))), + } + w.nextUserIndex.Store(startIndex) + return w +} + +func (w *HDWallet) String() string { + return w.hdWalletName +} + +// NewKey creates a new chain-agnostic account identity +func (w *HDWallet) NewKey() *Key { + newNextIndex := w.nextUserIndex.Add(1) + thisIndex := newNextIndex - 1 + k := devkeys.UserKey(thisIndex) + priv, err := w.keys.Secret(k) + w.t.Require().NoError(err, "must generate user secret") + key := NewKey(w.t, priv) + // Log with address and HD path, + // so we can easily reproduce the private key outside the test + // (assuming access to the mnemonic). + w.t.Logger().Debug("Creating user Key", + "addr", key.Address(), "path", k.HDPath()) + return key +} + +// NewEOA creates a new Key and wraps it with an EL node into a new EOA +func (w *HDWallet) NewEOA(el ELNode) *EOA { + return w.NewKey().User(el) +} diff --git a/op-devstack/dsl/invalid_msg.go b/op-devstack/dsl/invalid_msg.go new file mode 100644 index 00000000000..783b9d300ac --- /dev/null +++ b/op-devstack/dsl/invalid_msg.go @@ -0,0 +1,52 @@ +package dsl + +import ( + "math/big" + + "github.com/ethereum-optimism/optimism/op-service/eth" + suptypes "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" + "github.com/ethereum/go-ethereum/common" +) + +// InvalidMsgFn is a function that takes a valid message and returns an invalid copy. +type InvalidMsgFn func(suptypes.Message) suptypes.Message + +// MakeInvalidBlockNumber returns a copy of the message with an incremented block number. +func MakeInvalidBlockNumber(msg suptypes.Message) suptypes.Message { + msg.Identifier.BlockNumber++ + return msg +} + +// MakeInvalidChainID returns a copy of the message with an incremented chain ID. +func MakeInvalidChainID(msg suptypes.Message) suptypes.Message { + chainIDBig := msg.Identifier.ChainID.ToBig() + msg.Identifier.ChainID = eth.ChainIDFromBig(chainIDBig.Add(chainIDBig, big.NewInt(1))) + return msg +} + +// MakeInvalidLogIndex returns a copy of the message with an incremented log index. +func MakeInvalidLogIndex(msg suptypes.Message) suptypes.Message { + msg.Identifier.LogIndex++ + return msg +} + +// MakeInvalidOrigin returns a copy of the message with an incremented origin address. +func MakeInvalidOrigin(msg suptypes.Message) suptypes.Message { + originBig := msg.Identifier.Origin.Big() + msg.Identifier.Origin = common.BigToAddress(originBig.Add(originBig, big.NewInt(1))) + return msg +} + +// MakeInvalidTimestamp returns a copy of the message with an incremented timestamp. +func MakeInvalidTimestamp(msg suptypes.Message) suptypes.Message { + msg.Identifier.Timestamp++ + return msg +} + +// MakeInvalidPayloadHash returns a copy of the message with an incremented payload hash. +func MakeInvalidPayloadHash(msg suptypes.Message) suptypes.Message { + hash := msg.PayloadHash.Big() + hash.Add(hash, big.NewInt(1)) + msg.PayloadHash = common.BigToHash(hash) + return msg +} diff --git a/op-devstack/dsl/key.go b/op-devstack/dsl/key.go new file mode 100644 index 00000000000..94b0706b770 --- /dev/null +++ b/op-devstack/dsl/key.go @@ -0,0 +1,53 @@ +package dsl + +import ( + "crypto/ecdsa" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-service/txplan" +) + +// Key is an ethereum private key. +// This is a key with an address identity. +// The Key may be used on different chains: it is chain-agnostic. +type Key struct { + t devtest.T + priv *ecdsa.PrivateKey + addr common.Address +} + +func NewKey(t devtest.T, priv *ecdsa.PrivateKey) *Key { + t.Require().NotNil(priv.PublicKey, "private key PublicKey attribute must be initialized") + addr := crypto.PubkeyToAddress(priv.PublicKey) + return &Key{ + t: t, + priv: priv, + addr: addr, + } +} + +func (a *Key) Priv() *ecdsa.PrivateKey { + return a.priv +} + +func (a *Key) String() string { + return fmt.Sprintf("EOA(%s)", a.addr) +} + +func (a *Key) Address() common.Address { + return a.addr +} + +// Plan returns the tx-plan option to use this Key for signing of a transaction. +func (a *Key) Plan() txplan.Option { + return txplan.WithPrivateKey(a.priv) +} + +// EOA combines this Key with an EL node into a single-chain EOA. +func (a *Key) User(el ELNode) *EOA { + return NewEOA(a, el) +} diff --git a/op-devstack/dsl/l1_cl.go b/op-devstack/dsl/l1_cl.go new file mode 100644 index 00000000000..13ccbbb4fc8 --- /dev/null +++ b/op-devstack/dsl/l1_cl.go @@ -0,0 +1,38 @@ +package dsl + +import "github.com/ethereum-optimism/optimism/op-devstack/stack" + +// L1CLNode wraps a stack.L1CLNode interface for DSL operations +type L1CLNode struct { + commonImpl + inner stack.L1CLNode +} + +// NewL1CLNode creates a new L1CLNode DSL wrapper +func NewL1CLNode(inner stack.L1CLNode) *L1CLNode { + return &L1CLNode{ + commonImpl: commonFromT(inner.T()), + inner: inner, + } +} + +func (cl *L1CLNode) String() string { + return cl.inner.Name() +} + +// Escape returns the underlying stack.L1CLNode +func (cl *L1CLNode) Escape() stack.L1CLNode { + return cl.inner +} + +func (cl *L1CLNode) Start() { + lifecycle, ok := cl.inner.(stack.Lifecycle) + cl.require.Truef(ok, "L1CL node %s is not lifecycle-controllable", cl.inner.Name()) + lifecycle.Start() +} + +func (cl *L1CLNode) Stop() { + lifecycle, ok := cl.inner.(stack.Lifecycle) + cl.require.Truef(ok, "L1CL node %s is not lifecycle-controllable", cl.inner.Name()) + lifecycle.Stop() +} diff --git a/op-devstack/dsl/l1_el.go b/op-devstack/dsl/l1_el.go new file mode 100644 index 00000000000..9cff65d4517 --- /dev/null +++ b/op-devstack/dsl/l1_el.go @@ -0,0 +1,125 @@ +package dsl + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/retry" +) + +// L1ELNode wraps a stack.L1ELNode interface for DSL operations +type L1ELNode struct { + *elNode + inner stack.L1ELNode +} + +// NewL1ELNode creates a new L1ELNode DSL wrapper +func NewL1ELNode(inner stack.L1ELNode) *L1ELNode { + return &L1ELNode{ + elNode: newELNode(commonFromT(inner.T()), inner), + inner: inner, + } +} + +func (el *L1ELNode) String() string { + return el.inner.Name() +} + +// Escape returns the underlying stack.L1ELNode +func (el *L1ELNode) Escape() stack.L1ELNode { + return el.inner +} + +func (el *L1ELNode) EthClient() apis.EthClient { + return el.inner.EthClient() +} + +// EstimateBlockTime estimates the L1 block based on the last 1000 blocks +// (or since genesis, if insufficient blocks). +func (el *L1ELNode) EstimateBlockTime() time.Duration { + var latest eth.BlockRef + for { + var err error + latest, err = el.inner.EthClient().BlockRefByLabel(el.t.Ctx(), eth.Unsafe) + el.require.NoError(err) + if latest.Number > 0 { + break + } + select { + case <-time.After(time.Millisecond * 500): + case <-el.ctx.Done(): + el.require.Fail("context was canceled before L1 block time could be estimated") + } + } + lowerNum := uint64(0) + if latest.Number > 1000 { + lowerNum = latest.Number - 1000 + } + lowerBlock, err := el.inner.EthClient().BlockRefByNumber(el.t.Ctx(), lowerNum) + el.require.NoError(err) + deltaTime := latest.Time - lowerBlock.Time + deltaNum := latest.Number - lowerBlock.Number + return time.Duration(deltaTime) * time.Second / time.Duration(deltaNum) +} + +func (el *L1ELNode) BlockRefByLabel(label eth.BlockLabel) eth.L1BlockRef { + ctx, cancel := context.WithTimeout(el.ctx, DefaultTimeout) + defer cancel() + block, err := el.inner.EthClient().BlockRefByLabel(ctx, label) + el.require.NoError(err, "block not found using block label") + return block +} + +func (el *L1ELNode) BlockRefByNumber(number uint64) eth.L1BlockRef { + ctx, cancel := context.WithTimeout(el.ctx, DefaultTimeout) + defer cancel() + block, err := el.inner.EthClient().BlockRefByNumber(ctx, number) + el.require.NoError(err, "block not found using block number %d", number) + return block +} + +// ReorgTriggeredFn returns a lambda that checks that a L1 reorg occurred on the expected block +// Composable with other lambdas to wait in parallel +func (el *L1ELNode) ReorgTriggeredFn(target eth.L1BlockRef, attempts int) CheckFunc { + return func() error { + el.log.Info("expecting chain to reorg on block ref", "name", el.inner.Name(), "chain", el.inner.ChainID(), "target", target) + return retry.Do0(el.ctx, attempts, &retry.FixedStrategy{Dur: 7 * time.Second}, + func() error { + reorged, err := el.inner.EthClient().BlockRefByNumber(el.ctx, target.Number) + if err != nil { + if strings.Contains(err.Error(), "not found") { // reorg is happening wait a bit longer + el.log.Info("chain still hasn't been reorged", "chain", el.inner.ChainID(), "error", err) + return err + } + return err + } + + if target.Hash == reorged.Hash { // want not equal + el.log.Info("chain still hasn't been reorged", "chain", el.inner.ChainID(), "ref", reorged) + return fmt.Errorf("expected head to reorg %s, but got %s", target, reorged) + } + + if target.ParentHash != reorged.ParentHash { + return fmt.Errorf("expected parent of target to be the same as the parent of the reorged head, but they are different") + } + + el.log.Info("reorg on divergence block", "chain", el.inner.ChainID(), "pre_blockref", target) + el.log.Info("reorg on divergence block", "chain", el.inner.ChainID(), "post_blockref", reorged) + + return nil + }) + } +} + +func (el *L1ELNode) ReorgTriggered(target eth.L1BlockRef, attempts int) { + el.require.NoError(el.ReorgTriggeredFn(target, attempts)()) +} + +func (el *L1ELNode) TransactionTimeout() time.Duration { + return el.inner.TransactionTimeout() +} diff --git a/op-devstack/dsl/l1_network.go b/op-devstack/dsl/l1_network.go new file mode 100644 index 00000000000..09085238985 --- /dev/null +++ b/op-devstack/dsl/l1_network.go @@ -0,0 +1,81 @@ +package dsl + +import ( + "fmt" + + "github.com/davecgh/go-spew/spew" + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +// L1Network wraps a stack.L1Network interface for DSL operations +type L1Network struct { + commonImpl + inner stack.L1Network + primaryEL *L1ELNode + primaryCL *L1CLNode +} + +// NewL1Network creates a new L1Network DSL wrapper +func NewL1Network(inner stack.L1Network, primaryEL *L1ELNode, primaryCL *L1CLNode) *L1Network { + return &L1Network{ + commonImpl: commonFromT(inner.T()), + inner: inner, + primaryEL: primaryEL, + primaryCL: primaryCL, + } +} + +func (n *L1Network) String() string { + return n.inner.Name() +} + +func (n *L1Network) ChainID() eth.ChainID { + return n.inner.ChainID() +} + +// Escape returns the underlying stack.L1Network +func (n *L1Network) Escape() stack.L1Network { + return n.inner +} + +func (n *L1Network) PrimaryEL() *L1ELNode { + n.require.NotNil(n.primaryEL, "l1 network %s is missing a primary EL node", n.String()) + return n.primaryEL +} + +func (n *L1Network) PrimaryCL() *L1CLNode { + n.require.NotNil(n.primaryCL, "l1 network %s is missing a primary CL node", n.String()) + return n.primaryCL +} + +func (n *L1Network) WaitForBlock() eth.BlockRef { + return n.PrimaryEL().WaitForBlock() +} + +// PrintChain is used for testing/debugging, it prints the blockchain hashes and parent hashes to logs, which is useful when developing reorg tests +func (n *L1Network) PrintChain() { + l1_el := n.PrimaryEL().Escape() + + unsafeHeadRef, err := l1_el.EthClient().InfoByLabel(n.ctx, "latest") + n.require.NoError(err, "Expected to get latest block from L1 execution client") + + var entries []string + for i := unsafeHeadRef.NumberU64(); i > 0; i-- { + ref, txs, err := l1_el.EthClient().InfoAndTxsByNumber(n.ctx, i) + n.require.NoError(err, "Expected to get block ref by number") + + entries = append(entries, fmt.Sprintf("Time: %d Block: %s Txs: %d Parent: %s", ref.Time(), eth.InfoToL1BlockRef(ref), len(txs), ref.ParentHash())) + } + + n.log.Info("Printing block hashes and parent hashes", "network", n.String(), "chain", n.ChainID()) + spew.Dump(entries) +} + +func (n *L1Network) WaitForFinalization() eth.BlockRef { + return n.PrimaryEL().WaitForFinalization() +} + +func (n *L1Network) WaitForOnline() { + n.PrimaryEL().WaitForOnline() +} diff --git a/op-devstack/dsl/l2_batcher.go b/op-devstack/dsl/l2_batcher.go new file mode 100644 index 00000000000..62b292b8ad6 --- /dev/null +++ b/op-devstack/dsl/l2_batcher.go @@ -0,0 +1,56 @@ +package dsl + +import ( + "fmt" + "strings" + + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/retry" + "github.com/stretchr/testify/require" +) + +// L2Batcher wraps a stack.L2Batcher interface for DSL operations +type L2Batcher struct { + commonImpl + inner stack.L2Batcher +} + +// NewL2Batcher creates a new L2Batcher DSL wrapper +func NewL2Batcher(inner stack.L2Batcher) *L2Batcher { + return &L2Batcher{ + commonImpl: commonFromT(inner.T()), + inner: inner, + } +} + +func (b *L2Batcher) String() string { + return b.inner.Name() +} + +// Escape returns the underlying stack.L2Batcher +func (b *L2Batcher) Escape() stack.L2Batcher { + return b.inner +} + +func (b *L2Batcher) ActivityAPI() apis.BatcherActivity { + return b.inner.ActivityAPI() +} + +func (b *L2Batcher) Stop() { + err := retry.Do0(b.ctx, 3, retry.Exponential(), func() error { + err := b.Escape().ActivityAPI().StopBatcher(b.ctx) + if err != nil && strings.Contains(err.Error(), "batcher is not running") { + return nil + } + return err + }) + require.NoError(b.t, err, fmt.Sprintf("Expected to be able to call StopBatcher API on chain %s, but got error", b.inner.ChainID())) +} + +func (b *L2Batcher) Start() { + err := retry.Do0(b.ctx, 3, retry.Exponential(), func() error { + return b.inner.ActivityAPI().StartBatcher(b.ctx) + }) + require.NoError(b.t, err, fmt.Sprintf("Expected to be able to call StartBatcher API on chain %s, but got error", b.inner.ChainID())) +} diff --git a/op-devstack/dsl/l2_challenger.go b/op-devstack/dsl/l2_challenger.go new file mode 100644 index 00000000000..cafa071cedd --- /dev/null +++ b/op-devstack/dsl/l2_challenger.go @@ -0,0 +1,26 @@ +package dsl + +import "github.com/ethereum-optimism/optimism/op-devstack/stack" + +// L2Challenger wraps a stack.L2Challenger interface for DSL operations +type L2Challenger struct { + commonImpl + inner stack.L2Challenger +} + +// NewL2Challenger creates a new L2Challenger DSL wrapper +func NewL2Challenger(inner stack.L2Challenger) *L2Challenger { + return &L2Challenger{ + commonImpl: commonFromT(inner.T()), + inner: inner, + } +} + +func (c *L2Challenger) String() string { + return c.inner.Name() +} + +// Escape returns the underlying stack.L2Challenger +func (c *L2Challenger) Escape() stack.L2Challenger { + return c.inner +} diff --git a/op-devstack/dsl/l2_cl.go b/op-devstack/dsl/l2_cl.go new file mode 100644 index 00000000000..d3facd2c0b4 --- /dev/null +++ b/op-devstack/dsl/l2_cl.go @@ -0,0 +1,518 @@ +package dsl + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils/wait" + "github.com/ethereum-optimism/optimism/op-node/node/safedb" + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/retry" + "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" + "github.com/ethereum/go-ethereum/common" +) + +// L2CLNode wraps a stack.L2CLNode interface for DSL operations +type L2CLNode struct { + commonImpl + inner stack.L2CLNode + managedPeers map[string]*L2CLNode +} + +// NewL2CLNode creates a new L2CLNode DSL wrapper +func NewL2CLNode(inner stack.L2CLNode) *L2CLNode { + return &L2CLNode{ + commonImpl: commonFromT(inner.T()), + inner: inner, + managedPeers: make(map[string]*L2CLNode), + } +} + +func (cl *L2CLNode) Name() string { + return cl.inner.Name() +} + +func (cl *L2CLNode) String() string { + return cl.inner.Name() +} + +// Escape returns the underlying stack.L2CLNode +func (cl *L2CLNode) Escape() stack.L2CLNode { + return cl.inner +} + +func (cl *L2CLNode) SafeL2BlockRef() eth.L2BlockRef { + return cl.HeadBlockRef(types.CrossSafe) +} + +func (cl *L2CLNode) Start() { + lifecycle, ok := cl.inner.(stack.Lifecycle) + cl.require.Truef(ok, "L2CL node %s is not lifecycle-controllable", cl.inner.Name()) + lifecycle.Start() + cl.restoreManagedPeers() +} + +func (cl *L2CLNode) Stop() { + lifecycle, ok := cl.inner.(stack.Lifecycle) + cl.require.Truef(ok, "L2CL node %s is not lifecycle-controllable", cl.inner.Name()) + lifecycle.Stop() +} + +func (cl *L2CLNode) ManagePeer(peer *L2CLNode) { + cl.managedPeers[peer.Name()] = peer + peer.managedPeers[cl.Name()] = cl +} + +func (cl *L2CLNode) restoreManagedPeers() { + for _, peer := range cl.managedPeers { + cl.connectPeerRaw(peer) + peer.connectPeerRaw(cl) + } +} + +func (cl *L2CLNode) StartSequencer() { + unsafe := cl.HeadBlockRef(types.LocalUnsafe) + cl.log.Info("Continue sequencing with consensus node (op-node)", "chain", cl.ChainID(), "unsafe", unsafe) + + err := cl.inner.RollupAPI().StartSequencer(cl.ctx, unsafe.Hash) + cl.require.NoError(err, fmt.Sprintf("Expected to be able to start sequencer on chain %d", cl.ChainID())) + + // wait for the sequencer to become active + var active bool + err = wait.For(cl.ctx, 1*time.Second, func() (bool, error) { + active, err = cl.inner.RollupAPI().SequencerActive(cl.ctx) + return active, err + }) + cl.require.NoError(err, fmt.Sprintf("Expected to be able to call SequencerActive API on chain %d, and wait for an active state for sequencer, but got error", cl.ChainID())) + + cl.log.Info("Rollup node sequencer status", "chain", cl.ChainID(), "active", active) +} + +func (cl *L2CLNode) StopSequencer() common.Hash { + unsafeHead, err := cl.inner.RollupAPI().StopSequencer(cl.ctx) + cl.require.NoError(err, "Expected to be able to call StopSequencer API, but got error") + + // wait for the sequencer to become inactive + var active bool + err = wait.For(cl.ctx, 1*time.Second, func() (bool, error) { + active, err = cl.inner.RollupAPI().SequencerActive(cl.ctx) + return !active, err + }) + cl.require.NoError(err, fmt.Sprintf("Expected to be able to call SequencerActive API on chain %d, and wait for inactive state for sequencer, but got error", cl.ChainID())) + + cl.log.Info("Rollup node sequencer status", "chain", cl.ChainID(), "active", active, "unsafeHead", unsafeHead) + return unsafeHead +} + +func (cl *L2CLNode) SetSequencerRecoverMode(b bool) error { + return cl.inner.RollupAPI().SetRecoverMode(cl.ctx, b) +} + +func (cl *L2CLNode) SyncStatus() *eth.SyncStatus { + ctx, cancel := context.WithTimeout(cl.ctx, DefaultTimeout) + defer cancel() + syncStatus, err := cl.inner.RollupAPI().SyncStatus(ctx) + cl.require.NoError(err) + return syncStatus +} + +// HeadBlockRef fetches L2CL sync status and returns block ref with given safety level +func (cl *L2CLNode) HeadBlockRef(lvl types.SafetyLevel) eth.L2BlockRef { + syncStatus := cl.SyncStatus() + var blockRef eth.L2BlockRef + switch lvl { + case types.Finalized: + blockRef = syncStatus.FinalizedL2 + case types.CrossSafe: + blockRef = syncStatus.SafeL2 + case types.LocalSafe: + blockRef = syncStatus.LocalSafeL2 + case types.CrossUnsafe: + blockRef = syncStatus.CrossUnsafeL2 + case types.LocalUnsafe: + blockRef = syncStatus.UnsafeL2 + default: + cl.require.NoError(errors.New("invalid safety level")) + } + return blockRef +} + +func (cl *L2CLNode) ChainID() eth.ChainID { + return cl.inner.ChainID() +} + +func (cl *L2CLNode) AwaitMinL1Processed(minL1 uint64) { + ctx, cancel := context.WithTimeout(cl.ctx, DefaultTimeout) + defer cancel() + // Wait for CurrentL1 to be at least one block _past_ minL1 since CurrentL1 may not yet be fully processed. + err := wait.For(ctx, 1*time.Second, func() (bool, error) { + return cl.SyncStatus().CurrentL1.Number > minL1, nil + }) + cl.require.NoErrorf(err, "CurrentL1 did not reach %v", minL1+1) +} + +// AdvancedFn returns a lambda that checks the L2CL chain head with given safety level advanced more than delta block number +// Composable with other lambdas to wait in parallel +func (cl *L2CLNode) AdvancedFn(lvl types.SafetyLevel, delta uint64, attempts int) CheckFunc { + return func() error { + initial := cl.HeadBlockRef(lvl) + target := initial.Number + delta + cl.log.Info("Expecting chain to advance", "name", cl.inner.Name(), "chain", cl.ChainID(), "label", lvl, "delta", delta) + return cl.ReachedFn(lvl, target, attempts)() + } +} + +func (cl *L2CLNode) NotAdvancedFn(lvl types.SafetyLevel, attempts int) CheckFunc { + return func() error { + initial := cl.HeadBlockRef(lvl) + logger := cl.log.With("name", cl.inner.Name(), "chain", cl.ChainID(), "label", lvl, "target", initial.Number) + logger.Info("Expecting chain not to advance") + for range attempts { + time.Sleep(2 * time.Second) + head := cl.HeadBlockRef(lvl) + logger.Info("Chain sync status", "current", head.Number) + if head.Hash == initial.Hash { + continue + } + return fmt.Errorf("expected head not to advance: %s", lvl) + } + logger.Info("Chain not advanced") + return nil + } +} + +// awaitSafeHeadsStalled waits until every node's safe head has stopped advancing +// for at least 10 seconds. +func (cl *L2CLNode) WaitForStall(lvl types.SafetyLevel) { + var last eth.BlockID + var stableSince time.Time + cl.require.Eventuallyf(func() bool { + cur := cl.HeadBlockRef(lvl).ID() + if cur == last { + if stableSince.IsZero() { + stableSince = time.Now() + } + return time.Since(stableSince) >= 10*time.Second + } + last = cur + stableSince = time.Time{} + return false + }, 2*time.Minute, 2*time.Second, "expected %v head to stall", lvl) +} + +// ReachedFn returns a lambda that checks the L2CL chain head with given safety level reaches the target block number +// Composable with other lambdas to wait in parallel +func (cl *L2CLNode) ReachedFn(lvl types.SafetyLevel, target uint64, attempts int) CheckFunc { + return func() error { + logger := cl.log.With("name", cl.inner.Name(), "chain", cl.ChainID(), "label", lvl, "target", target) + logger.Info("Expecting chain to reach") + return retry.Do0(cl.ctx, attempts, &retry.FixedStrategy{Dur: 2 * time.Second}, + func() error { + head := cl.HeadBlockRef(lvl) + if head.Number >= target { + logger.Info("Chain advanced", "target", target) + return nil + } + logger.Info("Chain sync status", "current", head.Number) + return fmt.Errorf("expected head to advance: %s", lvl) + }) + } +} + +// ReachedRefFn is same as Reached, but has an additional check to ensure that the block referenced is not reorged +// Composable with other lambdas to wait in parallel +func (cl *L2CLNode) ReachedRefFn(lvl types.SafetyLevel, target eth.BlockID, attempts int) CheckFunc { + return func() error { + err := cl.ReachedFn(lvl, target.Number, attempts)() + if err != nil { + return err + } + + ethclient := cl.inner.ELClient() + result, err := ethclient.BlockRefByNumber(cl.ctx, target.Number) + if err != nil { + return err + } + if result.Hash != target.Hash { + return fmt.Errorf("expected block ref to be the same as target %s, got but %s", target.Hash, result.Hash) + } + return nil + } +} + +// RewindedFn returns a lambda that checks the L2CL chain head with given safety level rewinded more than the delta block number +// Composable with other lambdas to wait in parallel +func (cl *L2CLNode) RewindedFn(lvl types.SafetyLevel, delta uint64, attempts int) CheckFunc { + return func() error { + initial := cl.HeadBlockRef(lvl) + cl.require.GreaterOrEqual(initial.Number, delta, "cannot rewind before genesis") + target := initial.Number - delta + logger := cl.log.With("name", cl.inner.Name(), "chain", cl.ChainID(), "label", lvl) + logger.Info("Expecting chain to rewind", "target", target, "delta", delta) + // check rewind more aggressively, in shorter interval + return retry.Do0(cl.ctx, attempts, &retry.FixedStrategy{Dur: 250 * time.Millisecond}, + func() error { + head := cl.HeadBlockRef(lvl) + if head.Number <= target { + logger.Info("Chain rewinded", "target", target) + return nil + } + logger.Info("Chain sync status", "target", target, "current", head.Number) + return fmt.Errorf("expected head to rewind: %s", lvl) + }) + } +} + +func (cl *L2CLNode) Advanced(lvl types.SafetyLevel, delta uint64, attempts int) { + cl.require.NoError(cl.AdvancedFn(lvl, delta, attempts)()) +} + +func (cl *L2CLNode) AdvancedUnsafe(delta uint64, attempts int) { + cl.Advanced(types.LocalUnsafe, delta, attempts) +} + +func (cl *L2CLNode) NotAdvanced(lvl types.SafetyLevel, attempts int) { + cl.require.NoError(cl.NotAdvancedFn(lvl, attempts)()) +} + +func (cl *L2CLNode) NotAdvancedUnsafe(attempts int) { + cl.NotAdvanced(types.LocalUnsafe, attempts) +} + +func (cl *L2CLNode) Reached(lvl types.SafetyLevel, target uint64, attempts int) { + cl.require.NoError(cl.ReachedFn(lvl, target, attempts)()) +} + +func (cl *L2CLNode) ReachedUnsafe(target uint64, attempts int) { + cl.Reached(types.LocalUnsafe, target, attempts) +} + +func (cl *L2CLNode) ReachedRef(lvl types.SafetyLevel, target eth.BlockID, attempts int) { + cl.require.NoError(cl.ReachedRefFn(lvl, target, attempts)()) +} + +func (cl *L2CLNode) Rewinded(lvl types.SafetyLevel, delta uint64, attempts int) { + cl.require.NoError(cl.RewindedFn(lvl, delta, attempts)()) +} + +// ChainSyncStatus satisfies that the L2CLNode can provide sync status per chain +func (cl *L2CLNode) ChainSyncStatus(chainID eth.ChainID, lvl types.SafetyLevel) eth.BlockID { + cl.require.Equal(chainID, cl.inner.ChainID(), "chain ID mismatch") + return cl.HeadBlockRef(lvl).ID() +} + +func (cl *L2CLNode) safeHeadAtL1Block(l1BlockNum uint64) *eth.SafeHeadResponse { + resp, err := cl.inner.RollupAPI().SafeHeadAtL1Block(cl.ctx, l1BlockNum) + if errors.Is(err, safedb.ErrNotFound) { + return nil + } + cl.require.NoErrorf(err, "failed to get safe head at l1 block %v", l1BlockNum) + return resp +} + +// LaggedFn returns a lambda that checks the L2CL chain head with given safety level is lagged with the reference chain sync status provider +// Composable with other lambdas to wait in parallel +func (cl *L2CLNode) LaggedFn(refNode SyncStatusProvider, lvl types.SafetyLevel, attempts int, allowMatch bool) CheckFunc { + return LaggedFn(cl, refNode, cl.log, cl.ctx, lvl, cl.ChainID(), attempts, allowMatch) +} + +// MatchedFn returns a lambda that checks the L2CLNode head with given safety level is matched with the refNode chain sync status provider +// Composable with other lambdas to wait in parallel +func (cl *L2CLNode) MatchedFn(refNode SyncStatusProvider, lvl types.SafetyLevel, attempts int) CheckFunc { + return MatchedFn(cl, refNode, cl.log, cl.ctx, lvl, cl.ChainID(), attempts) +} + +func (cl *L2CLNode) Lagged(refNode SyncStatusProvider, lvl types.SafetyLevel, attempts int, allowMatch bool) { + cl.require.NoError(cl.LaggedFn(refNode, lvl, attempts, allowMatch)()) +} + +func (cl *L2CLNode) Matched(refNode SyncStatusProvider, lvl types.SafetyLevel, attempts int) { + cl.require.NoError(cl.MatchedFn(refNode, lvl, attempts)()) +} + +func (cl *L2CLNode) MatchedUnsafe(refNode SyncStatusProvider, attempts int) { + cl.Matched(refNode, types.LocalUnsafe, attempts) +} + +func (cl *L2CLNode) PeerInfo() *apis.PeerInfo { + peerInfo, err := retry.Do(cl.ctx, 3, retry.Exponential(), func() (*apis.PeerInfo, error) { + return cl.inner.P2PAPI().Self(cl.ctx) + }) + cl.require.NoError(err, "failed to get peer info") + return peerInfo +} + +func (cl *L2CLNode) Peers() *apis.PeerDump { + peerDump, err := retry.Do(cl.ctx, 3, retry.Exponential(), func() (*apis.PeerDump, error) { + return cl.inner.P2PAPI().Peers(cl.ctx, true) + }) + cl.require.NoError(err, "failed to get peers") + return peerDump +} + +func (cl *L2CLNode) DisconnectPeer(peer *L2CLNode) { + delete(cl.managedPeers, peer.Name()) + delete(peer.managedPeers, cl.Name()) + cl.disconnectPeerRaw(peer) +} + +func (cl *L2CLNode) disconnectPeerRaw(peer *L2CLNode) { + peerInfo := peer.PeerInfo() + err := retry.Do0(cl.ctx, 3, retry.Exponential(), func() error { + return cl.inner.P2PAPI().DisconnectPeer(cl.ctx, peerInfo.PeerID) + }) + cl.require.NoError(err, "failed to disconnect peer") +} + +func (cl *L2CLNode) ConnectPeer(peer *L2CLNode) { + cl.managedPeers[peer.Name()] = peer + peer.managedPeers[cl.Name()] = cl + cl.connectPeerRaw(peer) +} + +func (cl *L2CLNode) connectPeerRaw(peer *L2CLNode) { + peerInfo := peer.PeerInfo() + cl.require.NotZero(len(peerInfo.Addresses), "failed to get peer address") + // graceful backoff for p2p connection, to avoid dial backoff or connection refused error + strategy := &retry.ExponentialStrategy{Min: 10 * time.Second, Max: 30 * time.Second, MaxJitter: 250 * time.Millisecond} + err := retry.Do0(cl.ctx, 5, strategy, func() error { + return cl.inner.P2PAPI().ConnectPeer(cl.ctx, peerInfo.Addresses[0]) + }) + cl.require.NoError(err, "failed to connect peer") +} + +func (cl *L2CLNode) IsP2PConnected(peer *L2CLNode) { + myInfo := cl.PeerInfo() + strategy := &retry.ExponentialStrategy{Min: 10 * time.Second, Max: 30 * time.Second, MaxJitter: 250 * time.Millisecond} + err := retry.Do0(cl.ctx, 5, strategy, func() error { + for _, p := range peer.Peers().Peers { + if p.PeerID == myInfo.PeerID { + return nil + } + } + return errors.New("peer not connected yet") + }) + cl.require.NoError(err, "peer not connected") +} + +func (cl *L2CLNode) WaitForPeerDisconnected(peer *L2CLNode) { + myInfo := cl.PeerInfo() + strategy := &retry.ExponentialStrategy{Min: 10 * time.Second, Max: 30 * time.Second, MaxJitter: 250 * time.Millisecond} + err := retry.Do0(cl.ctx, 5, strategy, func() error { + for _, p := range peer.Peers().Peers { + if p.PeerID == myInfo.PeerID { + return errors.New("peer still connected") + } + } + return nil + }) + cl.require.NoError(err, "peer not disconnected") +} + +type safeHeadDbMatchOpts struct { + minRequiredL2Block *uint64 +} + +func WithMinRequiredL2Block(blockNum uint64) func(opts *safeHeadDbMatchOpts) { + return func(opts *safeHeadDbMatchOpts) { + opts.minRequiredL2Block = &blockNum + } +} + +func (cl *L2CLNode) VerifySafeHeadDatabaseMatches(sourceOfTruth *L2CLNode, args ...func(opts *safeHeadDbMatchOpts)) { + opts := applyOpts(safeHeadDbMatchOpts{}, args...) + l1Block := cl.SyncStatus().CurrentL1.Number + cl.log.Info("Verifying safe head database matches", "maxL1Block", l1Block) + cl.AwaitMinL1Processed(l1Block) // Ensure this block is fully processed before checking safe head db + sourceOfTruth.AwaitMinL1Processed(l1Block) + checkSafeHeadConsistent(cl.t, l1Block, cl, sourceOfTruth, opts.minRequiredL2Block) +} + +func (cl *L2CLNode) WaitForNonZeroUnsafeTime(ctx context.Context) *eth.SyncStatus { + require := cl.require + + var ss *eth.SyncStatus + err := retry.Do0(ctx, 10, retry.Fixed(2*time.Second), func() error { + ss = cl.SyncStatus() + require.NotNil(ss, "L2CL should have sync status") + if ss.UnsafeL2.Time == 0 { + return fmt.Errorf("L2CL unsafe time is still zero") + } + return nil + }) + require.NoError(err, "L2CL unsafe time should be set within retry limit") + require.NotZero(ss.UnsafeL2.Time, "L2CL unsafe time should not be zero") + + return ss +} + +func (cl *L2CLNode) SignalTarget(refNode *L2ELNode, targetNum uint64) { + cl.log.Info("Signaling L2CL", "target", targetNum, "refNode", refNode) + payload := refNode.PayloadByNumber(targetNum) + cl.PostUnsafePayload(payload) +} + +func (cl *L2CLNode) PostUnsafePayload(payload *eth.ExecutionPayloadEnvelope) { + cl.log.Info("PostUnsafePayload", "target", payload.ExecutionPayload.BlockNumber) + err := retry.Do0(cl.ctx, 3, retry.Fixed(2*time.Second), func() error { + return cl.inner.RollupAPI().PostUnsafePayload(cl.ctx, payload) + }) + cl.require.NoErrorf(err, "failed to post unsafe payload via admin API: target %d", payload.ExecutionPayload.BlockNumber) +} + +func (cl *L2CLNode) Reset(lvl types.SafetyLevel, target eth.L2BlockRef) { + cl.require.NoError(retry.Do0(cl.ctx, 5, &retry.FixedStrategy{Dur: 2 * time.Second}, + func() error { + res := cl.HeadBlockRef(lvl) + cl.log.Info("Chain sync Status", lvl, res) + if res.Hash == target.Hash { + return nil + } + return errors.New("waiting to reset") + })) +} + +func (cl *L2CLNode) AppendUnsafePayloadUntilTip(verEL, seqEL *L2ELNode, maxAttempts int) { + trial := 0 + cl.require.NoError( + retry.Do0(cl.ctx, 200, &retry.FixedStrategy{Dur: 250 * time.Millisecond}, func() error { + verUnsafe := verEL.BlockRefByLabel(eth.Unsafe) + seqUnsafe := seqEL.BlockRefByLabel(eth.Unsafe) + gap := seqUnsafe.Number - verUnsafe.Number + cl.log.Info("Filling in the gap by appending unsafe payload", "gap", gap, "ver", verUnsafe, "seq", seqUnsafe, "trial", trial) + if gap == 0 { + return nil + } + trial += 1 + cl.SignalTarget(seqEL, verUnsafe.Number+1) + return fmt.Errorf("unsafe gap with size %d still exists", gap) + })) +} + +func (cl *L2CLNode) UnsafeHead() *BlockRefResult { + return &BlockRefResult{T: cl.t, BlockRef: cl.HeadBlockRef(types.LocalUnsafe)} +} + +func (cl *L2CLNode) SafeHead() *BlockRefResult { + return &BlockRefResult{T: cl.t, BlockRef: cl.HeadBlockRef(types.CrossSafe)} +} + +func (cl *L2CLNode) CurrentL1MatchedFn(refNode *L2CLNode, attempts int) CheckFunc { + return func() error { + return retry.Do0(cl.ctx, attempts, &retry.FixedStrategy{Dur: 1 * time.Second}, + func() error { + currentL1 := cl.SyncStatus().CurrentL1 + ref := refNode.SyncStatus().CurrentL1 + if currentL1 == ref { + cl.log.Info("CurrentL1 reached", "currentL1", currentL1) + return nil + } + cl.log.Info("Chain sync status", "currentL1", currentL1.Number, "ref", ref) + return fmt.Errorf("expected currentL1 to match") + }) + } +} diff --git a/op-devstack/dsl/l2_el.go b/op-devstack/dsl/l2_el.go index dedbfc9a105..41452a72ce7 100644 --- a/op-devstack/dsl/l2_el.go +++ b/op-devstack/dsl/l2_el.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum-optimism/optimism/op-devstack/stack" "github.com/ethereum-optimism/optimism/op-devstack/sysgo" "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/bigs" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/retry" suptypes "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" @@ -445,6 +446,10 @@ func (el *L2ELNode) FinalizedHead() *BlockRefResult { return &BlockRefResult{T: el.t, BlockRef: el.BlockRefByLabel(eth.Finalized)} } +func (el *L2ELNode) AssertExecMessageNotInBlock(execMessage *ExecMessage) { + el.AssertTxNotInBlock(bigs.Uint64Strict(execMessage.BlockNumber()), execMessage.TxHash()) +} + // AssertTxNotInBlock asserts that a transaction with the given hash does not exist in the block at the given number. func (el *L2ELNode) AssertTxNotInBlock(blockNumber uint64, txHash common.Hash) { ctx, cancel := context.WithTimeout(el.ctx, DefaultTimeout) @@ -453,13 +458,27 @@ func (el *L2ELNode) AssertTxNotInBlock(blockNumber uint64, txHash common.Hash) { _, txs, err := el.inner.EthClient().InfoAndTxsByNumber(ctx, blockNumber) el.require.NoError(err, "failed to fetch block %d", blockNumber) + for _, tx := range txs { + el.require.NotEqualf(tx.Hash(), txHash, "transaction should not exist in block", "Found tx %v in block %v", tx.Hash(), blockNumber) + } + el.log.Info("confirmed transaction not in block", "blockNumber", blockNumber, "txHash", txHash) +} + +// AssertTxNotInBlock asserts that a transaction with the given hash does not exist in the block at the given number. +func (el *L2ELNode) AssertTxInBlock(blockNumber uint64, txHash common.Hash) { + ctx, cancel := context.WithTimeout(el.ctx, DefaultTimeout) + defer cancel() + + _, txs, err := el.inner.EthClient().InfoAndTxsByNumber(ctx, blockNumber) + el.require.NoError(err, "failed to fetch block %d", blockNumber) + for _, tx := range txs { if tx.Hash() == txHash { - el.require.Failf("transaction should not exist in block", - "tx_hash=%s found in block %d", txHash, blockNumber) + el.log.Info("confirmed transaction in block", "blockNumber", blockNumber, "txHash", txHash) + return } } - el.log.Info("confirmed transaction not in block", "blockNumber", blockNumber, "txHash", txHash) + el.require.Fail("transaction should exist in block", "blockNumber", blockNumber, "txHash", txHash) } type BlockRefResult struct { diff --git a/op-devstack/dsl/l2_network.go b/op-devstack/dsl/l2_network.go new file mode 100644 index 00000000000..bed0d91f790 --- /dev/null +++ b/op-devstack/dsl/l2_network.go @@ -0,0 +1,552 @@ +package dsl + +import ( + "bytes" + "fmt" + "io" + "math" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/ethereum-optimism/optimism/op-core/forks" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils/wait" + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/retry" + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" +) + +// L2Network wraps a stack.L2Network interface for DSL operations +type L2Network struct { + commonImpl + inner stack.L2Network + primaryEL *L2ELNode + primaryCL *L2CLNode + primaryL1 *L1ELNode + archiveEL *L2ELNode + publicRPC *L2ELNode +} + +// NewL2Network creates a new L2Network DSL wrapper +func NewL2Network(inner stack.L2Network, primaryEL *L2ELNode, primaryCL *L2CLNode, primaryL1 *L1ELNode, archiveEL *L2ELNode, publicRPC *L2ELNode) *L2Network { + if archiveEL == nil { + archiveEL = primaryEL + } + if publicRPC == nil { + publicRPC = primaryEL + } + return &L2Network{ + commonImpl: commonFromT(inner.T()), + inner: inner, + primaryEL: primaryEL, + primaryCL: primaryCL, + primaryL1: primaryL1, + archiveEL: archiveEL, + publicRPC: publicRPC, + } +} + +func (n *L2Network) String() string { + return n.inner.Name() +} + +func (n *L2Network) ChainID() eth.ChainID { + return n.inner.ChainID() +} + +// TimestampForBlockNum returns the timestamp for the given L2 block number. +func (n *L2Network) TimestampForBlockNum(blockNum uint64) uint64 { + return n.inner.RollupConfig().TimestampForBlock(blockNum) +} + +// Escape returns the underlying stack.L2Network +func (n *L2Network) Escape() stack.L2Network { + return n.inner +} + +func (n *L2Network) PrimaryEL() *L2ELNode { + n.require.NotNil(n.primaryEL, "l2 network %s is missing a primary EL node", n.String()) + return n.primaryEL +} + +func (n *L2Network) ArchiveEL() *L2ELNode { + n.require.NotNil(n.archiveEL, "l2 network %s is missing an archive EL node", n.String()) + return n.archiveEL +} + +func (n *L2Network) PrimaryCL() *L2CLNode { + n.require.NotNil(n.primaryCL, "l2 network %s is missing a primary CL node", n.String()) + return n.primaryCL +} + +func (n *L2Network) PrimaryL1EL() *L1ELNode { + n.require.NotNil(n.primaryL1, "l2 network %s is missing a primary L1 EL node", n.String()) + return n.primaryL1 +} + +func (n *L2Network) L2ELNodes() []*L2ELNode { + innerNodes := n.inner.L2ELNodes() + nodes := make([]*L2ELNode, len(innerNodes)) + for i, inner := range innerNodes { + nodes[i] = NewL2ELNode(inner) + } + return nodes +} + +func (n *L2Network) CatchUpTo(o *L2Network) { + this := n.PrimaryEL().Escape() + other := o.PrimaryEL().Escape() + + err := wait.For(n.ctx, 5*time.Second, func() (bool, error) { + a, err := this.EthClient().InfoByLabel(n.ctx, "latest") + if err != nil { + return false, err + } + + b, err := other.EthClient().InfoByLabel(n.ctx, "latest") + if err != nil { + return false, err + } + + eps := 6.0 // 6 seconds + if math.Abs(float64(a.Time()-b.Time())) > eps { + n.log.Warn("L2 networks too far off each other", n.String(), a.Time(), o.String(), b.Time()) + return false, nil + } + + return true, nil + }) + n.require.NoError(err, "Expected to get latest block from L2 execution clients") +} + +func (n *L2Network) WaitForBlock() eth.BlockRef { + return n.PrimaryEL().WaitForBlock() +} + +func (n *L2Network) PublicRPC() *L2ELNode { + n.require.NotNil(n.publicRPC, "l2 network %s is missing a public RPC node", n.String()) + return n.publicRPC +} + +// PrintChain is used for testing/debugging, it prints the blockchain hashes and parent hashes to logs, which is useful when developing reorg tests +func (n *L2Network) PrintChain() { + l2_el := n.PrimaryEL().Escape() + l2_cl := n.PrimaryCL().Escape() + l1_el := n.PrimaryL1EL().Escape() + + biAddr := n.inner.RollupConfig().BatchInboxAddress + dgfAddr := n.inner.Deployment().DisputeGameFactoryProxyAddr() + + var entries []string + var totalL2Txs int + err := retry.Do0(n.ctx, 3, &retry.FixedStrategy{Dur: 200 * time.Millisecond}, func() error { + entries = []string{} + totalL2Txs = 0 + + ref := n.unsafeHeadRef() + + for i := ref.Number; i > 0; i-- { + ref, err := l2_el.L2EthClient().L2BlockRefByNumber(n.ctx, i) + if err != nil { + return err + } + + _, l2Txs, err := l2_el.EthClient().InfoAndTxsByHash(n.ctx, ref.Hash) + if err != nil { + return err + } + + _, txs, err := l1_el.EthClient().InfoAndTxsByHash(n.ctx, ref.L1Origin.Hash) + if err != nil { + return err + } + + var batchTxs, dgfTxs int + for _, tx := range txs { + to := tx.To() + if to != nil && *to == biAddr { + batchTxs++ + } + if to != nil && *to == dgfAddr { + dgfTxs++ + } + } + + entries = append(entries, fmt.Sprintf("Time: %d Block: %s Parent: %s L1 Origin: %s Txs (L2: %d; Batch: %d; DGF: %d)", ref.Time, ref, ref.ParentID(), ref.L1Origin, len(l2Txs), batchTxs, dgfTxs)) + totalL2Txs += len(l2Txs) + } + + return nil + }) + n.require.NoError(err, "could not PrintChain after many attempts") + + syncStatus, err := l2_cl.RollupAPI().SyncStatus(n.ctx) + n.require.NoError(err, "Expected to get sync status") + + entries = append(entries, "") + entries = append(entries, fmt.Sprintf("Total L2 Txs: %d", totalL2Txs)) + entries = append(entries, "") + entries = append(entries, "Supervisor Sync view") + entries = append(entries, "") + entries = append(entries, fmt.Sprintf("Current L1: %s", syncStatus.CurrentL1)) + entries = append(entries, fmt.Sprintf("Head L1: %s", syncStatus.HeadL1)) + entries = append(entries, fmt.Sprintf("Safe L1: %s", syncStatus.SafeL1)) + entries = append(entries, fmt.Sprintf("Unsafe L2: %s", syncStatus.UnsafeL2)) + entries = append(entries, fmt.Sprintf("Local-Safe L2: %s", syncStatus.LocalSafeL2)) + entries = append(entries, fmt.Sprintf("Cross-Unsafe L2: %s", syncStatus.CrossUnsafeL2)) + entries = append(entries, fmt.Sprintf("Cross-Safe L2: %s", syncStatus.SafeL2)) + + n.log.Info("Printing block hashes and parent hashes", "network", n.String(), "chain", n.ChainID()) + spew.Dump(entries) +} + +func (n *L2Network) unsafeHeadRef() eth.L2BlockRef { + l2_el := n.PrimaryEL().Escape() + + unsafeHead, err := l2_el.EthClient().InfoByLabel(n.ctx, eth.Unsafe) + n.require.NoError(err, "Expected to get latest block from L2 execution client") + + unsafeHeadRef, err := l2_el.L2EthClient().L2BlockRefByHash(n.ctx, unsafeHead.Hash()) + n.require.NoError(err, "Expected to get block ref by hash") + + return unsafeHeadRef +} + +// IsActivated checks if a given fork has been activated +func (n *L2Network) IsActivated(timestamp uint64) bool { + blockNum, err := n.Escape().RollupConfig().TargetBlockNumber(timestamp) + n.require.NoError(err) + + head, err := n.PrimaryEL().EthClient().BlockRefByLabel(n.ctx, eth.Unsafe) + n.require.NoError(err) + + return head.Number >= blockNum +} + +func (n *L2Network) IsForkActive(fork forks.Name) bool { + timestamp := n.PrimaryEL().BlockRefByLabel(eth.Unsafe).Time + return n.IsForkActiveAt(fork, timestamp) +} + +func (n *L2Network) IsForkActiveAt(forkName forks.Name, timestamp uint64) bool { + return n.Escape().RollupConfig().IsForkActive(forkName, timestamp) +} + +// LatestBlockBeforeTimestamp finds the latest block before fork activation +func (n *L2Network) LatestBlockBeforeTimestamp(t devtest.T, timestamp uint64) eth.BlockRef { + require := t.Require() + + t.Gate().Greater(timestamp, uint64(0), "Must not start fork at genesis") + + blockNum, err := n.Escape().RollupConfig().TargetBlockNumber(timestamp) + require.NoError(err) + + head, err := n.PrimaryEL().EthClient().BlockRefByLabel(t.Ctx(), eth.Unsafe) + require.NoError(err) + + t.Logger().Info("Preparing", + "head", head, "head_time", head.Time, + "target_num", blockNum, "target_time", timestamp) + + if head.Number < blockNum { + t.Logger().Info("No block with given timestamp yet, checking head block instead") + return head + } else { + t.Logger().Info("Reached block already, proceeding with last block before timestamp") + v, err := n.PrimaryEL().EthClient().BlockRefByNumber(t.Ctx(), blockNum-1) + require.NoError(err) + return v + } +} + +// AwaitActivation awaits the fork activation time, and returns the activation block +func (n *L2Network) AwaitActivation(t devtest.T, forkName rollup.ForkName) eth.BlockID { + require := t.Require() + + rollupCfg := n.Escape().RollupConfig() + maybeActivationTime := rollupCfg.ActivationTime(forkName) + require.NotNil(maybeActivationTime, "Required fork is not scheduled for activation") + activationTime := *maybeActivationTime + if activationTime == 0 { + block, err := n.PrimaryEL().EthClient().BlockRefByNumber(t.Ctx(), 0) + require.NoError(err, "Fork activated at genesis, but failed to get genesis block") + return block.ID() + } + blockNum, err := rollupCfg.TargetBlockNumber(activationTime) + require.NoError(err) + activationBlock := eth.ToBlockID(n.PrimaryEL().WaitForBlockNumber(blockNum)) + t.Logger().Info("Activation block", "block", activationBlock) + return activationBlock + +} + +func (n *L2Network) DisputeGameFactoryProxyAddr() common.Address { + return n.inner.Deployment().DisputeGameFactoryProxyAddr() +} + +func (n *L2Network) DepositContractAddr() common.Address { + return n.inner.RollupConfig().DepositContractAddress +} + +func (n *L2Network) DeriveData(blocks int) (channels []derive.ChannelID, channelFrames map[derive.ChannelID][]derive.Frame, l2Txs map[common.Address][]*ethtypes.Transaction) { + l := n.log + ctx := n.ctx + + channelFrames = make(map[derive.ChannelID][]derive.Frame) + channels = make([]derive.ChannelID, 0) + l2Txs = make(map[common.Address][]*ethtypes.Transaction) + + rollupCfg := n.inner.RollupConfig() + batchInboxAddr := rollupCfg.BatchInboxAddress + + l1EC := n.PrimaryL1EL().EthClient() + + // Get current L1 block number before starting to monitor + startBlockRef, err := l1EC.BlockRefByLabel(ctx, eth.Unsafe) + n.require.NoError(err, "Failed to get start block number") + + seenChannels := make(map[derive.ChannelID]bool) + lastBlockRef := startBlockRef + + // Monitor L1 blocks for batch transactions + for range blocks { + n.PrimaryL1EL().WaitForBlock() + + // Get current block number + currentBlockRef, err := l1EC.BlockRefByLabel(ctx, eth.Unsafe) + n.require.NoError(err, "Failed to get current block number") + blockNum := currentBlockRef.Number + lastBlockRef = currentBlockRef + + _, txs, err := l1EC.InfoAndTxsByNumber(ctx, blockNum) + n.require.NoError(err, "Failed to get block %d", blockNum) + + // Process transactions in this block + for _, tx := range txs { + // Check if transaction is targeted to BatchInbox + if tx.To() != nil && *tx.To() == batchInboxAddr { + // Get transaction sender + chainID := n.inner.L1().ChainID() + chainIDBig := chainID.ToBig() + signer := ethtypes.LatestSignerForChainID(chainIDBig) + sender, err := signer.Sender(tx) + n.require.NoError(err, "Failed to get transaction sender") + + l.Debug("Found batch transaction", + "txHash", tx.Hash(), + "block", blockNum, + "sender", sender) + + var datas [][]byte + if tx.Type() != ethtypes.BlobTxType { + // Regular transaction - data is in tx.Data() + datas = append(datas, tx.Data()) + } else { + // Blob transaction - need to fetch blobs from beacon + // For now, log that we found a blob tx but skip detailed parsing + // as it requires beacon API access + l.Error("Found blob transaction (skipping blob fetch for now)", + "txHash", tx.Hash(), + "blobHashes", tx.BlobHashes()) + continue + } + + // Parse frames from transaction data + for _, data := range datas { + frames, err := derive.ParseFrames(data) + if err != nil { + l.Warn("Failed to parse frames from transaction", + "txHash", tx.Hash(), + "error", err) + n.require.NoError(err) + } + + l.Debug("Parsed frames from transaction", + "txHash", tx.Hash(), + "frameCount", len(frames)) + + // Process each frame + for _, frame := range frames { + channelID := frame.ID + if !seenChannels[channelID] { + seenChannels[channelID] = true + l.Debug("Found new channel", + "channelID", channelID.String(), + "txHash", tx.Hash(), + "block", blockNum) + channels = append(channels, channelID) + } + channelFrames[channelID] = append(channelFrames[channelID], frame) + l.Debug("Frame added to channel", + "channelID", channelID.String(), + "frameNumber", frame.FrameNumber, + "dataLength", len(frame.Data), + "isLast", frame.IsLast, + "txHash", tx.Hash()) + } + } + } + } + } + + // Reassemble channels and extract batches + for channelID, frames := range channelFrames { + l.Debug("Processing channel", + "channelID", channelID.String(), + "frameCount", len(frames)) + + // Sort frames by frame number + sortedFrames := make([]derive.Frame, len(frames)) + copy(sortedFrames, frames) + for i := 0; i < len(sortedFrames); i++ { + for j := i + 1; j < len(sortedFrames); j++ { + if sortedFrames[i].FrameNumber > sortedFrames[j].FrameNumber { + sortedFrames[i], sortedFrames[j] = sortedFrames[j], sortedFrames[i] + } + } + } + + // Create a channel and add frames to it + // We need an L1 block ref for the channel - use the last processed block as the origin + originBlock := lastBlockRef + ch := derive.NewChannel(channelID, originBlock, false) + + for _, frame := range sortedFrames { + err := ch.AddFrame(frame, originBlock) + if err != nil { + l.Warn("Failed to add frame to channel", + "channelID", channelID.String(), + "frameNumber", frame.FrameNumber, + "error", err) + continue + } + } + + l.Debug("Channel is ready, extracting batches", + "channelID", channelID.String(), + "size", ch.Size()) + + channelReader := ch.Reader() + channelData, err := io.ReadAll(channelReader) + if err != nil { + l.Warn("Failed to read channel data", + "channelID", channelID.String(), + "error", err) + continue + } + + l.Debug("Read channel data", + "channelID", channelID.String(), + "dataLength", len(channelData)) + + spec := rollup.NewChainSpec(rollupCfg) + maxRLPBytes := spec.MaxRLPBytesPerChannel(originBlock.Time) + isFjord := rollupCfg.IsFjord(originBlock.Time) + batchReader, err := derive.BatchReader(bytes.NewReader(channelData), maxRLPBytes, isFjord) + if err != nil { + l.Warn("Failed to create batch reader", + "channelID", channelID.String(), + "error", err) + continue + } + + // Read all batches from the channel + batchCount := 0 + for { + batchData, err := batchReader() + if err == io.EOF { + break + } + if err != nil { + l.Warn("Failed to read batch from channel", + "channelID", channelID.String(), + "batchCount", batchCount, + "error", err) + break + } + + batchCount++ + batchType := batchData.GetBatchType() + + l.Debug("Found batch in channel", + "channelID", channelID.String(), + "batchNumber", batchCount, + "batchType", batchType, + "compressionAlgo", batchData.ComprAlgo) + + // Decode the batch based on type + if batchType == derive.SingularBatchType { + singularBatch, err := derive.GetSingularBatch(batchData) + if err != nil { + l.Warn("Failed to decode singular batch", + "channelID", channelID.String(), + "batchNumber", batchCount, + "error", err) + n.require.NoError(err) + } + + for _, txData := range singularBatch.Transactions { + var tx ethtypes.Transaction + n.require.NoError(tx.UnmarshalBinary(txData)) + + signer := ethtypes.LatestSignerForChainID(rollupCfg.L2ChainID) + fromAddr, err := signer.Sender(&tx) + n.require.NoError(err) + + l2Txs[fromAddr] = append(l2Txs[fromAddr], &tx) + } + + } else if batchType == derive.SpanBatchType { + spanBatch, err := derive.DeriveSpanBatch( + batchData, + rollupCfg.BlockTime, + rollupCfg.Genesis.L2Time, + rollupCfg.L2ChainID, + ) + if err != nil { + l.Warn("Failed to decode span batch", + "channelID", channelID.String(), + "batchNumber", batchCount, + "error", err) + continue + } + + for blockIdx, batchElement := range spanBatch.Batches { + l.Debug("L2 block in span batch", + "channelID", channelID.String(), + "batchNumber", batchCount, + "blockIndex", blockIdx, + "epochNum", batchElement.EpochNum, + "timestamp", batchElement.Timestamp, + "txCount", len(batchElement.Transactions)) + + for _, txData := range batchElement.Transactions { + var tx ethtypes.Transaction + n.require.NoError(tx.UnmarshalBinary(txData)) + + signer := ethtypes.LatestSignerForChainID(rollupCfg.L2ChainID) + fromAddr, err := signer.Sender(&tx) + n.require.NoError(err) + + l2Txs[fromAddr] = append(l2Txs[fromAddr], &tx) + } + } + } else { + l.Warn("Unknown batch type", + "channelID", channelID.String(), + "batchNumber", batchCount, + "batchType", batchType) + } + } + + l.Debug("Finished processing channel", + "channelID", channelID.String(), + "totalBatches", batchCount) + } + + return channels, channelFrames, l2Txs +} diff --git a/op-devstack/dsl/l2_op_rbuilder.go b/op-devstack/dsl/l2_op_rbuilder.go new file mode 100644 index 00000000000..5e9aa6c2d32 --- /dev/null +++ b/op-devstack/dsl/l2_op_rbuilder.go @@ -0,0 +1,61 @@ +package dsl + +import ( + opclient "github.com/ethereum-optimism/optimism/op-service/client" + + "github.com/ethereum-optimism/optimism/op-devstack/stack" +) + +type OPRBuilderNodeSet []*OPRBuilderNode + +func NewOPRBuilderNodeSet(inner []stack.OPRBuilderNode) OPRBuilderNodeSet { + oprbuilders := make([]*OPRBuilderNode, len(inner)) + for i, c := range inner { + oprbuilders[i] = NewOPRBuilderNode(c) + } + return oprbuilders +} + +type OPRBuilderNode struct { + commonImpl + inner stack.OPRBuilderNode + wsClient *opclient.WSClient +} + +func NewOPRBuilderNode(inner stack.OPRBuilderNode) *OPRBuilderNode { + return &OPRBuilderNode{ + commonImpl: commonFromT(inner.T()), + inner: inner, + wsClient: inner.FlashblocksClient(), + } +} + +func (c *OPRBuilderNode) String() string { + return c.inner.Name() +} + +func (c *OPRBuilderNode) Escape() stack.OPRBuilderNode { + return c.inner +} + +func (c *OPRBuilderNode) FlashblocksClient() *opclient.WSClient { + return c.wsClient +} + +func (el *OPRBuilderNode) Stop() { + el.log.Info("Stopping", "name", el.inner.Name()) + lifecycle, ok := el.inner.(stack.Lifecycle) + el.require.Truef(ok, "op-rbuilder node %s is not lifecycle-controllable", el.inner.Name()) + lifecycle.Stop() +} + +func (el *OPRBuilderNode) Start() { + lifecycle, ok := el.inner.(stack.Lifecycle) + el.require.Truef(ok, "op-rbuilder node %s is not lifecycle-controllable", el.inner.Name()) + lifecycle.Start() +} + +func (el *OPRBuilderNode) UpdateRuleSet(rulesYaml string) { + el.log.Info("Updating rule", "content", rulesYaml) + el.require.NoError(el.inner.UpdateRuleSet(rulesYaml), "failed to update rule: %s", rulesYaml) +} diff --git a/op-devstack/dsl/l2_proposer.go b/op-devstack/dsl/l2_proposer.go new file mode 100644 index 00000000000..679659db794 --- /dev/null +++ b/op-devstack/dsl/l2_proposer.go @@ -0,0 +1,26 @@ +package dsl + +import "github.com/ethereum-optimism/optimism/op-devstack/stack" + +// L2Proposer wraps a stack.L2Proposer interface for DSL operations +type L2Proposer struct { + commonImpl + inner stack.L2Proposer +} + +// NewL2Proposer creates a new L2Proposer DSL wrapper +func NewL2Proposer(inner stack.L2Proposer) *L2Proposer { + return &L2Proposer{ + commonImpl: commonFromT(inner.T()), + inner: inner, + } +} + +func (p *L2Proposer) String() string { + return p.inner.Name() +} + +// Escape returns the underlying stack.L2Proposer +func (p *L2Proposer) Escape() stack.L2Proposer { + return p.inner +} diff --git a/op-devstack/dsl/multi_client.go b/op-devstack/dsl/multi_client.go new file mode 100644 index 00000000000..0bae50cc0bc --- /dev/null +++ b/op-devstack/dsl/multi_client.go @@ -0,0 +1,237 @@ +package dsl + +import ( + "context" + "errors" + "fmt" + "regexp" + "sync" + "time" + + "math/big" + + "github.com/ethereum-optimism/optimism/op-service/bigs" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/retry" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" +) + +// HeaderProvider interface for multi-client operations +type HeaderProvider interface { + InfoByNumber(ctx context.Context, number uint64) (eth.BlockInfo, error) + InfoByLabel(ctx context.Context, label eth.BlockLabel) (eth.BlockInfo, error) + InfoByHash(ctx context.Context, hash common.Hash) (eth.BlockInfo, error) +} + +// CheckForChainFork checks that the L2 chain has not forked now, and returns a +// function that check again (to be called at the end of the test). An error is +// returned from this function (and the returned function) if a chain fork has +// been detected. +func CheckForChainFork(ctx context.Context, networks []*L2Network, logger log.Logger) (func(bool) error, error) { + var allClients []HeaderProvider + for _, network := range networks { + clients, err := getEthClientsFromL2Network(network) + if err != nil { + return nil, fmt.Errorf("failed to get eth clients from network %s: %w", network.String(), err) + } + allClients = append(allClients, clients...) + } + + return checkForChainFork(ctx, allClients, logger) +} + +// getEthClientsFromL2Network extracts HeaderProvider clients from an L2Network +func getEthClientsFromL2Network(network *L2Network) ([]HeaderProvider, error) { + stackNetwork := network.Escape() + hps := make([]HeaderProvider, 0, len(stackNetwork.L2ELNodes())) + for _, n := range stackNetwork.L2ELNodes() { + ethClient := n.L2EthClient() + if !regexp.MustCompile(`snapsync-\d+$`).MatchString(n.Name()) { + hps = append(hps, ethClient) + } + } + return hps, nil +} + +func checkForChainFork(ctx context.Context, clients []HeaderProvider, logger log.Logger) (func(bool) error, error) { + l2MultiClient := NewMultiClient(clients) + + // Setup chain fork detection + logger.Info("Running fork detection precheck") + l2StartInfo, err := l2MultiClient.InfoByLabel(ctx, eth.Unsafe) + if err != nil { + return nil, fmt.Errorf("fork detection precheck failed: %w", err) + } + + return func(failed bool) error { + logger.Info("Running fork detection postcheck") + l2EndInfo, err := l2MultiClient.InfoByLabel(ctx, eth.Unsafe) + if err != nil { + return fmt.Errorf("fork detection postcheck failed: %w", err) + } + if l2EndInfo.NumberU64() <= l2StartInfo.NumberU64() { + if !failed { + return fmt.Errorf("L2 chain has not progressed: start=%d, end=%d", l2StartInfo.NumberU64(), l2EndInfo.NumberU64()) + } else { + logger.Debug("L2 chain has not progressed, but the test failed so we will not error again") + } + } + return nil + }, nil +} + +// MultiClient is a simple client that checks hash consistency between underlying clients +type MultiClient struct { + clients []HeaderProvider + retryStrategy retry.Strategy + maxAttempts int +} + +// NewMultiClient creates a new MultiClient with the specified underlying clients +func NewMultiClient(clients []HeaderProvider) *MultiClient { + return &MultiClient{ + clients: clients, + maxAttempts: 3, + retryStrategy: retry.Fixed(500 * time.Millisecond), + } +} + +// InfoByNumber returns block info from the first client while verifying hash consistency +func (mc *MultiClient) InfoByNumber(ctx context.Context, number uint64) (eth.BlockInfo, error) { + if len(mc.clients) == 0 { + return nil, errors.New("no clients configured") + } + + // Single client optimization + info, err := mc.clients[0].InfoByNumber(ctx, number) + if err != nil || len(mc.clients) == 1 { + return info, err + } + + // Fetch with consistency check + err = mc.verifyFollowersWithRetry(ctx, big.NewInt(int64(number)), info.Hash()) + return info, err +} + +// InfoByLabel returns block info from the first client while verifying hash consistency +func (mc *MultiClient) InfoByLabel(ctx context.Context, label eth.BlockLabel) (eth.BlockInfo, error) { + if len(mc.clients) == 0 { + return nil, errors.New("no clients configured") + } + + info, err := mc.clients[0].InfoByLabel(ctx, label) + if err != nil { + return nil, err + } + if info == nil { + return nil, fmt.Errorf("no block info found for label %v", label) + } + if len(mc.clients) == 1 { + return info, nil + } + + // Verify consistency with retry for followers + err = mc.verifyFollowersWithRetry(ctx, big.NewInt(int64(info.NumberU64())), info.Hash()) + + return info, err +} + +// InfoByHash returns block info from the first client while verifying hash consistency +func (mc *MultiClient) InfoByHash(ctx context.Context, hash common.Hash) (eth.BlockInfo, error) { + if len(mc.clients) == 0 { + return nil, errors.New("no clients configured") + } + + info, err := mc.clients[0].InfoByHash(ctx, hash) + if err != nil { + return nil, err + } + if info == nil { + return nil, fmt.Errorf("no block info found for hash %v", hash) + } + if len(mc.clients) == 1 { + return info, nil + } + + // Verify consistency with retry for followers + err = mc.verifyFollowersWithRetry(ctx, big.NewInt(int64(info.NumberU64())), info.Hash()) + + return info, err +} + +// verifyFollowersWithRetry checks hash consistency with retries in case of temporary sync issues +func (mc *MultiClient) verifyFollowersWithRetry( + ctx context.Context, + blockNum *big.Int, + primaryHash common.Hash, +) error { + var wg sync.WaitGroup + errs := make(chan error) + + // Track which clients still need verification + for clientIndex, c := range mc.clients[1:] { + actualIndex := clientIndex + 1 + client := c // copy so the goroutine closure has a stable reference + wg.Add(1) + go func() { + defer wg.Done() + hash, err := retry.Do(ctx, mc.maxAttempts, mc.retryStrategy, func() (common.Hash, error) { + info, err := client.InfoByNumber(ctx, bigs.Uint64Strict(blockNum)) + if err != nil { + return common.Hash{}, err + } + return info.Hash(), nil + }) + if err != nil { + errs <- err + return + } + // Detect chain split + if hash != primaryHash { + errs <- formatChainSplitError(blockNum, primaryHash, actualIndex, hash) + return + } + }() + } + + go func() { + wg.Wait() + close(errs) + }() + + allErrs := []error{} + for err := range errs { + allErrs = append(allErrs, err) + } + + if len(allErrs) > 0 { + return errors.Join(allErrs...) + } + + return nil +} + +// formatChainSplitError creates a descriptive error when a chain split is detected +func formatChainSplitError(blockNum *big.Int, primaryHash common.Hash, clientIdx int, hash common.Hash) error { + return fmt.Errorf("chain split detected at block #%s: primary=%s, client%d=%s", + blockNum, primaryHash.Hex()[:10], clientIdx, hash.Hex()[:10]) +} + +// MultiClientForL2Network creates a MultiClient from an L2Network +func MultiClientForL2Network(network *L2Network) (*MultiClient, error) { + clients := make([]HeaderProvider, 0) + for _, node := range network.Escape().L2ELNodes() { + clients = append(clients, node.EthClient()) + } + return NewMultiClient(clients), nil +} + +// MultiClientForL1Network creates a MultiClient from an L1Network +func MultiClientForL1Network(network *L1Network) (*MultiClient, error) { + clients := make([]HeaderProvider, 0) + for _, node := range network.Escape().L1ELNodes() { + clients = append(clients, node.EthClient()) + } + return NewMultiClient(clients), nil +} diff --git a/op-devstack/dsl/multi_client_test.go b/op-devstack/dsl/multi_client_test.go new file mode 100644 index 00000000000..ecccd3d37ee --- /dev/null +++ b/op-devstack/dsl/multi_client_test.go @@ -0,0 +1,92 @@ +package dsl + +import ( + "context" + "testing" + + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/ethereum-optimism/optimism/op-service/testutils" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +type mockHeaderProvider struct { + latestBlockNum int + blocksByNum map[int]*testutils.MockBlockInfo +} + +func (m mockHeaderProvider) InfoByNumber(ctx context.Context, number uint64) (eth.BlockInfo, error) { + var idx int + if number == 0 { + idx = m.latestBlockNum + } else { + idx = int(number) + } + block, exists := m.blocksByNum[idx] + if !exists { + return nil, nil + } + return block, nil +} + +func (m mockHeaderProvider) InfoByLabel(ctx context.Context, label eth.BlockLabel) (eth.BlockInfo, error) { + return m.InfoByNumber(ctx, uint64(m.latestBlockNum)) +} + +func (m mockHeaderProvider) InfoByHash(ctx context.Context, hash common.Hash) (eth.BlockInfo, error) { + return m.InfoByNumber(ctx, uint64(m.latestBlockNum)) +} + +func TestDetectsFork(t *testing.T) { + leader := mockHeaderProvider{latestBlockNum: 0, blocksByNum: map[int]*testutils.MockBlockInfo{ + 0: {InfoHash: common.HexToHash("0x0"), InfoNum: 0}, + 1: {InfoHash: common.HexToHash("0x1"), InfoNum: 1}, + }} + + followerA := mockHeaderProvider{latestBlockNum: 0, blocksByNum: map[int]*testutils.MockBlockInfo{ + 0: {InfoHash: common.HexToHash("0x0"), InfoNum: 0}, // in sync with leader + 1: {InfoHash: common.HexToHash("0xb"), InfoNum: 1}, // forks off from leader + }} + + followerB := mockHeaderProvider{latestBlockNum: 0, blocksByNum: map[int]*testutils.MockBlockInfo{ + 0: {InfoHash: common.HexToHash("0x0"), InfoNum: 0}, // forks off from leader + 1: {InfoHash: common.HexToHash("0xb"), InfoNum: 1}, // forks off from leader + }} + + // First scenario: leader and follower are in sync initially, but then split + secondCheck, firstErr := checkForChainFork(context.Background(), []HeaderProvider{&leader, &followerA}, testlog.Logger(t, log.LevelDebug)) + require.NoError(t, firstErr) + leader.latestBlockNum = 1 // advance the chain head + followerA.latestBlockNum = 1 // advance the chain head + require.Error(t, secondCheck(false), "expected chain split error") + + // Second scenario: leader and follower are forked immediately + _, firstErr = checkForChainFork(context.Background(), []HeaderProvider{&leader, &followerB}, testlog.Logger(t, log.LevelDebug)) + require.Error(t, firstErr, "expected chain split error") +} + +func TestDetectsHealthy(t *testing.T) { + leader := mockHeaderProvider{latestBlockNum: 0, blocksByNum: map[int]*testutils.MockBlockInfo{ + 0: {InfoHash: common.HexToHash("0x0"), InfoNum: 0}, + 1: {InfoHash: common.HexToHash("0x1"), InfoNum: 1}, + }} + + followerA := mockHeaderProvider{latestBlockNum: 0, blocksByNum: map[int]*testutils.MockBlockInfo{ + 0: {InfoHash: common.HexToHash("0x0"), InfoNum: 0}, // in sync with leader + 1: {InfoHash: common.HexToHash("0x1"), InfoNum: 1}, // in sync with leader + }} + + followerB := mockHeaderProvider{latestBlockNum: 0, blocksByNum: map[int]*testutils.MockBlockInfo{ + 0: {InfoHash: common.HexToHash("0x0"), InfoNum: 0}, // in sync with leader + 1: {InfoHash: common.HexToHash("0x1"), InfoNum: 1}, // in sync with leader + }} + + secondCheck, firstErr := checkForChainFork(context.Background(), []HeaderProvider{&leader, &followerA, &followerB}, testlog.Logger(t, log.LevelDebug)) + require.NoError(t, firstErr) + leader.latestBlockNum = 1 // advance the chain head + followerA.latestBlockNum = 1 // advance the chain head + followerB.latestBlockNum = 1 // advance the chain head + require.NoError(t, secondCheck(false), "did not expect chain split error") +} diff --git a/op-devstack/dsl/operator_fee.go b/op-devstack/dsl/operator_fee.go new file mode 100644 index 00000000000..6d217b8cab1 --- /dev/null +++ b/op-devstack/dsl/operator_fee.go @@ -0,0 +1,260 @@ +package dsl + +import ( + "math/big" + "time" + + "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" + "github.com/ethereum-optimism/optimism/op-core/forks" + "github.com/ethereum-optimism/optimism/op-core/predeploys" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/txintent/bindings" + "github.com/ethereum-optimism/optimism/op-service/txintent/contractio" + "github.com/ethereum/go-ethereum/core/types" +) + +type OperatorFee struct { + commonImpl + + l1Client *L1ELNode + l2Network *L2Network + systemConfig bindings.SystemConfig + l1Block bindings.L1Block + gasPriceOracle bindings.GasPriceOracle + + originalScalar uint32 + originalConstant uint64 +} + +type OperatorFeeValidationResult struct { + TransactionReceipt *types.Receipt + ExpectedOperatorFee *big.Int + ActualTotalFee *big.Int + VaultBalanceIncrease *big.Int +} + +func NewOperatorFee(t devtest.T, l2Network *L2Network, l1EL *L1ELNode) *OperatorFee { + systemConfig := bindings.NewBindings[bindings.SystemConfig]( + bindings.WithClient(l1EL.EthClient()), + bindings.WithTo(l2Network.Escape().Deployment().SystemConfigProxyAddr()), + bindings.WithTest(t)) + + l1Block := bindings.NewBindings[bindings.L1Block]( + bindings.WithClient(l2Network.PrimaryEL().EthClient()), + bindings.WithTo(predeploys.L1BlockAddr), + bindings.WithTest(t)) + + gasPriceOracle := bindings.NewBindings[bindings.GasPriceOracle]( + bindings.WithClient(l2Network.PrimaryEL().EthClient()), + bindings.WithTo(predeploys.GasPriceOracleAddr), + bindings.WithTest(t)) + + originalScalar, err := contractio.Read(systemConfig.OperatorFeeScalar(), t.Ctx()) + t.Require().NoError(err) + originalConstant, err := contractio.Read(systemConfig.OperatorFeeConstant(), t.Ctx()) + t.Require().NoError(err) + + return &OperatorFee{ + commonImpl: commonFromT(t), + l1Client: l1EL, + l2Network: l2Network, + systemConfig: systemConfig, + l1Block: l1Block, + gasPriceOracle: gasPriceOracle, + originalScalar: originalScalar, + originalConstant: originalConstant, + } +} + +func (of *OperatorFee) CheckCompatibility() bool { + _, err := contractio.Read(of.systemConfig.OperatorFeeScalar(), of.ctx) + if err != nil { + of.t.Skipf("Operator fee methods not available in devstack: %v", err) + return false + } + return true +} + +func (of *OperatorFee) GetSystemOwner() *EOA { + systemOwnerKey := devkeys.SystemConfigOwner.Key(of.l2Network.ChainID().ToBig()) + return NewKey(of.t, of.l2Network.Escape().Keys().Secret(systemOwnerKey)).User(of.l1Client) +} + +func (of *OperatorFee) SetOperatorFee(scalar uint32, constant uint64) { + systemOwner := of.GetSystemOwner() + + _, err := contractio.Write( + of.systemConfig.SetOperatorFeeScalars(scalar, constant), + of.ctx, + systemOwner.Plan()) + of.require.NoError(err) + + of.t.Logf("Set operator fee on L1: scalar=%d, constant=%d", scalar, constant) +} + +func (of *OperatorFee) WaitForL2SyncWithCurrentL1State() { + // Read current L1 values + l1Scalar, err := contractio.Read(of.systemConfig.OperatorFeeScalar(), of.ctx) + of.require.NoError(err) + l1Constant, err := contractio.Read(of.systemConfig.OperatorFeeConstant(), of.ctx) + of.require.NoError(err) + + // Wait for L2 to sync with current L1 values + of.WaitForL2Sync(l1Scalar, l1Constant) +} + +func (of *OperatorFee) WaitForL2Sync(expectedScalar uint32, expectedConstant uint64) { + of.require.Eventually(func() bool { + scalar, err := contractio.Read(of.l1Block.OperatorFeeScalar(), of.ctx) + if err != nil { + return false + } + constant, err := contractio.Read(of.l1Block.OperatorFeeConstant(), of.ctx) + if err != nil { + return false + } + + return scalar == expectedScalar && constant == expectedConstant + }, 2*time.Minute, 5*time.Second, "L2 operator fee parameters did not sync within 2 minutes") +} + +func (of *OperatorFee) VerifyL2Config(expectedScalar uint32, expectedConstant uint64) { + scalar, err := contractio.Read(of.l1Block.OperatorFeeScalar(), of.ctx) + of.require.NoError(err) + of.require.Equal(expectedScalar, scalar) + + constant, err := contractio.Read(of.l1Block.OperatorFeeConstant(), of.ctx) + of.require.NoError(err) + of.require.Equal(expectedConstant, constant) +} + +func (of *OperatorFee) ValidateTransactionFees(from *EOA, to *EOA, amount *big.Int, expectedScalar uint32, expectedConstant uint64) OperatorFeeValidationResult { + // Ensure there is at least one user transaction, to trigger flow of operator fees to vault. + tx := from.Transfer(to.Address(), eth.WeiBig(amount)) + receipt, err := tx.Included.Eval(of.ctx) + of.require.NoError(err) + of.require.Equal(types.ReceiptStatusSuccessful, receipt.Status) + + blockHash := receipt.BlockHash + info, txs, err := from.el.stackEL().EthClient().InfoAndTxsByHash(of.ctx, blockHash) + of.require.NoError(err) + + // Infer active fork from block info + isJovian := of.l2Network.IsForkActiveAt(forks.Jovian, info.Time()) + + // Verify GPO upgraded when jovian is active + // We have nothing to assert when jovian is inactive because an isthmus L2 can + // run against isthmus L1 contracts or jovian L1 contracts. + if isJovian { + isJovianinGPO, err := contractio.Read(of.gasPriceOracle.IsJovian(), of.ctx) + of.require.NoError(err) + of.require.True(isJovianinGPO) + } + + // Get updated balance in operator fee vault to compute delta + vaultAfter, err := from.el.stackEL().EthClient().BalanceAt(of.ctx, predeploys.OperatorFeeVaultAddr, receipt.BlockNumber) + of.require.NoError(err) + vaultBefore, err := from.el.stackEL().EthClient().BalanceAt(of.ctx, predeploys.OperatorFeeVaultAddr, big.NewInt(0).Sub(receipt.BlockNumber, big.NewInt(1))) + of.require.NoError(err) + vaultIncrease := new(big.Int).Sub(vaultAfter, vaultBefore) + + // Loop through transactions in block to compute expected operator fee vault increase + expectedOperatorFeeVaultIncrease := big.NewInt(0) + if !(expectedScalar == 0 && expectedConstant == 0) { + // The test submits one user transaction but we loop over all user transactions + // to make the test robust to any other traffic on the chain. + for _, tx := range txs { + if tx.Type() == types.DepositTxType { + continue + } + receipt, err := from.el.stackEL().EthClient().TransactionReceipt(of.ctx, tx.Hash()) + of.require.NoError(err) + + operatorFee := new(big.Int).Mul(big.NewInt(int64(receipt.GasUsed)), big.NewInt(int64(expectedScalar))) + if isJovian { + // Jovian formula: (gasUsed * operatorFeeScalar * 100) + operatorFeeConstant + operatorFee.Mul(operatorFee, big.NewInt(100)) + } else { + // Isthmus formula: (gasUsed * operatorFeeScalar / 1e6) + operatorFeeConstant + operatorFee.Div(operatorFee, big.NewInt(1000000)) + } + operatorFee.Add(operatorFee, big.NewInt(int64(expectedConstant))) + expectedOperatorFeeVaultIncrease = + expectedOperatorFeeVaultIncrease.Add(expectedOperatorFeeVaultIncrease, operatorFee) + } + } + + // Use Cmp for big.Int comparison to avoid representation issues + of.require.Equal(0, expectedOperatorFeeVaultIncrease.Cmp(vaultIncrease), + "operator fee vault balance mismatch: expected %s, got %s", + expectedOperatorFeeVaultIncrease.String(), vaultIncrease.String()) + + actualTotalFee := new(big.Int).Mul(receipt.EffectiveGasPrice, big.NewInt(int64(receipt.GasUsed))) + if receipt.L1Fee != nil { + actualTotalFee.Add(actualTotalFee, receipt.L1Fee) + } + + if expectedScalar != 0 || expectedConstant != 0 { + of.require.NotNil(receipt.OperatorFeeScalar) + of.require.NotNil(receipt.OperatorFeeConstant) + + of.require.Equal(expectedScalar, uint32(*receipt.OperatorFeeScalar)) + of.require.Equal(expectedConstant, *receipt.OperatorFeeConstant) + } + + return OperatorFeeValidationResult{ + TransactionReceipt: receipt, + ExpectedOperatorFee: expectedOperatorFeeVaultIncrease, + ActualTotalFee: actualTotalFee, + VaultBalanceIncrease: vaultIncrease, + } +} + +func (of *OperatorFee) RestoreOriginalConfig() { + of.SetOperatorFee(of.originalScalar, of.originalConstant) +} + +func RunOperatorFeeTest(t devtest.T, l2Chain *L2Network, l1EL *L1ELNode, funderL1, funderL2 *Funder) { + fundAmount := eth.OneTenthEther + alice := funderL2.NewFundedEOA(fundAmount) + alice.WaitForBalance(fundAmount) + bob := funderL2.NewFundedEOA(eth.ZeroWei) + + operatorFee := NewOperatorFee(t, l2Chain, l1EL) + operatorFee.CheckCompatibility() + systemOwner := operatorFee.GetSystemOwner() + funderL1.FundAtLeast(systemOwner, fundAmount) + + // First, ensure L2 is synced with current L1 state before starting tests + t.Log("Ensuring L2 is synced with current L1 state...") + operatorFee.WaitForL2SyncWithCurrentL1State() + + testCases := []struct { + name string + scalar uint32 + constant uint64 + }{ + {"ZeroFees", 0, 0}, + {"NonZeroFees", 300, 400}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t devtest.T) { + operatorFee.SetOperatorFee(tc.scalar, tc.constant) + operatorFee.WaitForL2Sync(tc.scalar, tc.constant) + operatorFee.VerifyL2Config(tc.scalar, tc.constant) + + result := operatorFee.ValidateTransactionFees(alice, bob, big.NewInt(1000), tc.scalar, tc.constant) + + t.Log("Test completed successfully:", + "testCase", tc.name, + "gasUsed", result.TransactionReceipt.GasUsed, + "actualTotalFee", result.ActualTotalFee.String(), + "expectedOperatorFee", result.ExpectedOperatorFee.String(), + "vaultBalanceIncrease", result.VaultBalanceIncrease.String()) + }) + } + + operatorFee.RestoreOriginalConfig() +} diff --git a/op-devstack/dsl/opts.go b/op-devstack/dsl/opts.go new file mode 100644 index 00000000000..308e6aa450d --- /dev/null +++ b/op-devstack/dsl/opts.go @@ -0,0 +1,8 @@ +package dsl + +func applyOpts[C any](defaultConfig C, opts ...func(config *C)) C { + for _, opt := range opts { + opt(&defaultConfig) + } + return defaultConfig +} diff --git a/op-devstack/dsl/params.go b/op-devstack/dsl/params.go new file mode 100644 index 00000000000..099e016656f --- /dev/null +++ b/op-devstack/dsl/params.go @@ -0,0 +1,5 @@ +package dsl + +import "time" + +const DefaultTimeout = 30 * time.Second diff --git a/op-devstack/dsl/proofs/claim.go b/op-devstack/dsl/proofs/claim.go new file mode 100644 index 00000000000..3842d750682 --- /dev/null +++ b/op-devstack/dsl/proofs/claim.go @@ -0,0 +1,123 @@ +package proofs + +import ( + "context" + "fmt" + "math/big" + "slices" + "time" + + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types" + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils/wait" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-service/txintent/bindings" +) + +const defaultTimeout = 20 * time.Minute + +type Claim struct { + t devtest.T + require *require.Assertions + Index uint64 + claim bindings.Claim + game *FaultDisputeGame +} + +func newClaim(t devtest.T, require *require.Assertions, claimIndex uint64, claim bindings.Claim, game *FaultDisputeGame) *Claim { + return &Claim{ + t: t, + require: require, + Index: claimIndex, + claim: claim, + game: game, + } +} + +func (c *Claim) String() string { + pos := c.claim.Position + return fmt.Sprintf("%v - Position: %v, Depth: %v, IndexAtDepth: %v ClaimHash: %v, Countered By: %v, ParentIndex: %v Claimant: %v Bond: %v\n", + c.Index, pos.ToGIndex(), pos.Depth(), pos.IndexAtDepth(), c.claim.Value.Hex(), c.claim.CounteredBy, c.claim.ParentContractIndex, c.claim.Claimant, c.claim.Bond) +} + +func (c *Claim) Value() common.Hash { + return c.claim.Value +} + +func (c *Claim) Bond() *big.Int { + return c.claim.Bond +} + +func (c *Claim) Position() types.Position { + return c.claim.Position +} + +func (c *Claim) Claimant() common.Address { + return c.claim.Claimant +} + +func (c *Claim) Depth() types.Depth { + return c.claim.Depth() +} + +func (c *Claim) asChallengerClaim() types.Claim { + return types.Claim{ + ClaimData: types.ClaimData{ + Value: c.claim.Value, + Bond: c.claim.Bond, + Position: c.claim.Position, + }, + CounteredBy: c.claim.CounteredBy, + Claimant: c.claim.Claimant, + Clock: c.claim.Clock, + ContractIndex: int(c.Index), + ParentContractIndex: int(c.claim.ParentContractIndex), + } +} + +// WaitForCounterClaim waits for the claim to be countered by another claim being posted. +// Return the new claim that counters this claim. +func (c *Claim) WaitForCounterClaim(ignoreClaims ...*Claim) *Claim { + counterIdx, counterClaim := c.game.waitForClaim(defaultTimeout, fmt.Sprintf("failed to find claim with parent idx %v", c.Index), func(claimIdx uint64, claim bindings.Claim) bool { + return uint64(claim.ParentContractIndex) == c.Index && !containsClaim(claimIdx, ignoreClaims) + }) + return newClaim(c.t, c.require, counterIdx, counterClaim, c.game) +} + +// WaitForCountered waits until the claim is countered either by a child claim or by a step call. +func (c *Claim) WaitForCountered() { + timedCtx, cancel := context.WithTimeout(c.t.Ctx(), defaultTimeout) + defer cancel() + err := wait.For(timedCtx, time.Second, func() (bool, error) { + claim := c.game.claimAtIndex(c.Index) + return claim.CounteredBy != common.Address{}, nil + }) + if err != nil { // Avoid waiting time capturing game data when there's no error + c.require.NoErrorf(err, "Claim %v was not countered\n%v", c.Index, c.game.GameData()) + } +} + +func (c *Claim) VerifyNoCounterClaim() { + for i, claim := range c.game.allClaims() { + c.require.NotEqualValuesf(c.Index, claim.ParentContractIndex, "Found unexpected counter-claim at index %v: %v", i, claim) + } +} + +func (c *Claim) Attack(eoa *dsl.EOA, newClaim common.Hash) *Claim { + c.game.Attack(eoa, c.Index, newClaim) + return c.WaitForCounterClaim() +} + +func (c *Claim) Defend(eoa *dsl.EOA, newClaim common.Hash) *Claim { + c.game.Defend(eoa, c.Index, newClaim) + return c.WaitForCounterClaim() +} + +func containsClaim(claimIdx uint64, haystack []*Claim) bool { + return slices.ContainsFunc(haystack, func(candidate *Claim) bool { + return candidate.Index == claimIdx + }) +} diff --git a/op-devstack/dsl/proofs/dispute_game_factory.go b/op-devstack/dsl/proofs/dispute_game_factory.go new file mode 100644 index 00000000000..3ff363bffde --- /dev/null +++ b/op-devstack/dsl/proofs/dispute_game_factory.go @@ -0,0 +1,567 @@ +package proofs + +import ( + "context" + "encoding/binary" + "math/big" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "time" + + "github.com/ethereum-optimism/optimism/cannon/mipsevm" + challengerConfig "github.com/ethereum-optimism/optimism/op-challenger/config" + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/cannon" + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/outputs" + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/prestates" + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/super" + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/utils" + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/vm" + 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/eth" + safetyTypes "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" + + challengerTypes "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/dsl/contract" + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/bigs" + "github.com/ethereum-optimism/optimism/op-service/txintent/bindings" + "github.com/ethereum-optimism/optimism/op-service/txplan" +) + +type DisputeGameFactory struct { + t devtest.T + require *require.Assertions + log log.Logger + l1Network *dsl.L1Network + ethClient apis.EthClient + dgf *bindings.DisputeGameFactory + addr common.Address + l2CL *dsl.L2CLNode + l2EL *dsl.L2ELNode + superNode *dsl.Supernode + gameHelper *GameHelper + challengerCfg *challengerConfig.Config + + honestTraces map[common.Address]challengerTypes.TraceAccessor +} + +func NewDisputeGameFactory( + t devtest.T, + l1Network *dsl.L1Network, + ethClient apis.EthClient, + dgfAddr common.Address, + l2CL *dsl.L2CLNode, + l2EL *dsl.L2ELNode, + superNode *dsl.Supernode, + challengerCfg *challengerConfig.Config, +) *DisputeGameFactory { + dgf := bindings.NewDisputeGameFactory(bindings.WithClient(ethClient), bindings.WithTo(dgfAddr), bindings.WithTest(t)) + + return &DisputeGameFactory{ + t: t, + require: require.New(t), + log: t.Logger(), + l1Network: l1Network, + dgf: dgf, + addr: dgfAddr, + l2CL: l2CL, + l2EL: l2EL, + superNode: superNode, + ethClient: ethClient, + challengerCfg: challengerCfg, + + honestTraces: make(map[common.Address]challengerTypes.TraceAccessor), + } +} + +type GameCfg struct { + allowFuture bool + allowUnsafe bool + l2SequenceNumber uint64 + l2SequenceNumberSet bool + rootClaimSet bool + rootClaim common.Hash + superOutputRoots []eth.Bytes32 +} +type GameOpt interface { + Apply(cfg *GameCfg) +} +type gameOptFn func(c *GameCfg) + +func (g gameOptFn) Apply(cfg *GameCfg) { + g(cfg) +} + +func WithUnsafeProposal() GameOpt { + return gameOptFn(func(c *GameCfg) { + c.allowUnsafe = true + }) +} + +func WithFutureProposal() GameOpt { + return gameOptFn(func(c *GameCfg) { + c.allowFuture = true + }) +} + +func WithRootClaim(claim common.Hash) GameOpt { + return gameOptFn(func(c *GameCfg) { + c.rootClaim = claim + c.rootClaimSet = true + }) +} + +func WithL2SequenceNumber(seqNum uint64) GameOpt { + return gameOptFn(func(c *GameCfg) { + c.l2SequenceNumber = seqNum + c.l2SequenceNumberSet = true + }) +} + +// WithSuperRootFrom sets the output roots to use in a super root game. +// The length of outputRoots must match the number of chains in the super root. +func WithSuperRootFrom(outputRoots ...eth.Bytes32) GameOpt { + return gameOptFn(func(c *GameCfg) { + c.superOutputRoots = outputRoots + }) +} + +func NewGameCfg(opts ...GameOpt) *GameCfg { + cfg := &GameCfg{} + for _, opt := range opts { + opt.Apply(cfg) + } + return cfg +} + +func (f *DisputeGameFactory) Address() common.Address { + return f.addr +} + +func (f *DisputeGameFactory) getGameHelper(eoa *dsl.EOA) *GameHelper { + if f.gameHelper != nil { + return f.gameHelper + } + gs := DeployGameHelper(f.t, eoa, f.honestTraceForGame) + f.gameHelper = gs + return gs +} + +func (f *DisputeGameFactory) GameCount() int64 { + return contract.Read(f.dgf.GameCount()).Int64() +} + +func (f *DisputeGameFactory) GameAtIndex(idx int64) *FaultDisputeGame { + gameInfo := contract.Read(f.dgf.GameAtIndex(big.NewInt(idx))) + game := bindings.NewFaultDisputeGame(bindings.WithClient(f.ethClient), bindings.WithTo(gameInfo.Proxy), bindings.WithTest(f.t)) + return NewFaultDisputeGame(f.t, f.require, gameInfo.Proxy, f.getGameHelper, f.honestTraceForGame, game) +} + +func (f *DisputeGameFactory) GameImpl(gameType gameTypes.GameType) *FaultDisputeGame { + implAddr := contract.Read(f.dgf.GameImpls(uint32(gameType))) + game := bindings.NewFaultDisputeGame(bindings.WithClient(f.ethClient), bindings.WithTo(implAddr), bindings.WithTest(f.t)) + return NewFaultDisputeGame(f.t, f.require, implAddr, f.getGameHelper, f.honestTraceForGame, game) +} + +func (f *DisputeGameFactory) GameArgs(gameType gameTypes.GameType) []byte { + return contract.Read(f.dgf.GameArgs(uint32(gameType))) +} + +func (f *DisputeGameFactory) WaitForGame() *FaultDisputeGame { + initialCount := f.GameCount() + f.t.Require().Eventually(func() bool { + gameCount := f.GameCount() + check := gameCount > initialCount + f.t.Logf("waiting for new game. current=%d new=%d", initialCount, gameCount) + return check + }, time.Minute*10, time.Second*5) + + return f.GameAtIndex(initialCount) +} + +func (f *DisputeGameFactory) StartSuperCannonKonaGame(eoa *dsl.EOA, opts ...GameOpt) *SuperFaultDisputeGame { + f.require.NotNil(f.superNode, "super node is required to start super games") + + return f.startSuperGameOfType(eoa, gameTypes.SuperCannonKonaGameType, opts...) +} + +func (f *DisputeGameFactory) startSuperGameOfType(eoa *dsl.EOA, gameType gameTypes.GameType, opts ...GameOpt) *SuperFaultDisputeGame { + cfg := NewGameCfg(opts...) + if len(cfg.superOutputRoots) != 0 && cfg.rootClaimSet { + f.t.Error("cannot set both super output roots and root claim in super game") + f.t.FailNow() + } + timestamp := cfg.l2SequenceNumber + if !cfg.l2SequenceNumberSet { + timestamp = f.safeTimestamp() + } + extraData := f.createSuperGameExtraData(timestamp, cfg) + rootClaim := cfg.rootClaim + if !cfg.rootClaimSet { + rootClaim = crypto.Keccak256Hash(extraData) + } + game, addr := f.createNewGame(eoa, gameType, rootClaim, extraData) + + return NewSuperFaultDisputeGame(f.t, f.require, addr, f.getGameHelper, f.honestTraceForGame, game) +} + +func (f *DisputeGameFactory) createSuperGameExtraData(timestamp uint64, cfg *GameCfg) []byte { + f.require.NotNil(f.superNode, "super node is required create super games") + if !cfg.allowFuture { + f.awaitMinVerifiedTimestamp(timestamp) + } + resp, err := f.superNode.QueryAPI().SuperRootAtTimestamp(f.t.Ctx(), timestamp) + f.require.NoError(err, "Failed to fetch super root at timestamp") + f.require.NotNil(resp.Data, "Super root data must be present at timestamp %v", timestamp) + superV1, ok := resp.Data.Super.(*eth.SuperV1) + f.require.Truef(ok, "unsupported super type %T", resp.Data.Super) + if len(cfg.superOutputRoots) != 0 { + f.require.Len(cfg.superOutputRoots, len(superV1.Chains), "Super output roots length mismatch") + for i := range superV1.Chains { + superV1.Chains[i].Output = cfg.superOutputRoots[i] + } + } + extraData := superV1.Marshal() + return extraData +} + +func (f *DisputeGameFactory) awaitMinVerifiedTimestamp(timestamp uint64) { + f.t.Require().Eventually(func() bool { + resp, err := f.superNode.QueryAPI().SuperRootAtTimestamp(f.t.Ctx(), timestamp) + f.require.NoError(err, "Failed to fetch supernode status (superroot_atTimestamp)") + return resp.Data != nil + }, 2*time.Minute, 1*time.Second) +} + +func (f *DisputeGameFactory) StartCannonGame(eoa *dsl.EOA, opts ...GameOpt) *FaultDisputeGame { + return f.startOutputRootGameOfType(eoa, gameTypes.CannonGameType, f.honestTraceForGame, opts...) +} + +func (f *DisputeGameFactory) StartCannonKonaGame(eoa *dsl.EOA, opts ...GameOpt) *FaultDisputeGame { + return f.startOutputRootGameOfType(eoa, gameTypes.CannonKonaGameType, f.honestTraceForGame, opts...) +} + +func (f *DisputeGameFactory) honestTraceForGame(game *FaultDisputeGame) challengerTypes.TraceAccessor { + if existing, ok := f.honestTraces[game.Address]; ok { + return existing + } + f.require.NotNil(f.challengerCfg, "Challenger config is required to create honest trace") + switch game.GameType() { + case gameTypes.CannonGameType: + return f.honestOutputCannonTrace( + game, + f.challengerCfg.CannonAbsolutePreStateBaseURL, + f.challengerCfg.CannonAbsolutePreState, + f.challengerCfg.Cannon, + vm.NewOpProgramServerExecutor(f.log), + ) + case gameTypes.CannonKonaGameType: + return f.honestOutputCannonTrace( + game, + f.challengerCfg.CannonKonaAbsolutePreStateBaseURL, + f.challengerCfg.CannonKonaAbsolutePreState, + f.challengerCfg.CannonKona, + vm.NewKonaExecutor(), + ) + case gameTypes.SuperCannonKonaGameType: + return f.honestSuperCannonTrace( + game, + f.challengerCfg.CannonKonaAbsolutePreStateBaseURL, + f.challengerCfg.CannonKonaAbsolutePreState, + f.challengerCfg.CannonKona, + vm.NewKonaSuperExecutor(), + ) + default: + f.require.Truef(false, "Honest trace not supported for game type %v", game.GameType()) + return nil + } +} + +func (f *DisputeGameFactory) honestOutputCannonTrace( + game *FaultDisputeGame, + prestateBaseUrl *url.URL, + prestateFile string, + vmConfig vm.Config, + serverExecutor vm.OracleServerExecutor, +) challengerTypes.TraceAccessor { + logger := f.t.Logger().New("role", "honestTrace") + prestateBlock := game.StartingL2SequenceNumber() + rollupClient := f.l2CL.Escape().RollupAPI() + prestateProvider := outputs.NewPrestateProvider(rollupClient, prestateBlock) + l1HeadHash := game.L1Head() + l1Head, err := f.ethClient.BlockRefByHash(f.t.Ctx(), l1HeadHash) + f.require.NoError(err, "Failed to fetch L1 Head") + + prestateSource := prestates.NewPrestateSource( + prestateBaseUrl, + prestateFile, + path.Join(f.challengerCfg.Datadir, "test-prestates"), + cannon.NewStateConverter(vmConfig), + ) + prestatePath, err := prestateSource.PrestatePath(f.t.Ctx(), game.absolutePrestate()) + f.require.NoError(err, "Failed to get prestate path") + l2ElClient := f.l2EL.Escape().L2EthClient() + accessor, err := outputs.NewOutputCannonTraceAccessor( + logger, + metrics.NoopMetrics, + vmConfig, + serverExecutor, + ðClientHeaderProvider{client: l2ElClient}, + prestateProvider, + prestatePath, + rollupClient, + f.t.TempDir(), + l1Head.ID(), + game.SplitDepth(), + prestateBlock, + game.L2SequenceNumber(), + ) + f.require.NoError(err, "Failed to create trace accessor") + f.honestTraces[game.Address] = accessor + return accessor +} + +func (f *DisputeGameFactory) honestSuperCannonTrace( + game *FaultDisputeGame, + prestateBaseUrl *url.URL, + prestateFile string, + vmConfig vm.Config, + serverExecutor vm.OracleServerExecutor, +) challengerTypes.TraceAccessor { + logger := f.t.Logger().New("role", "honestSuperTrace") + f.require.NotNil(f.superNode, "SuperNode is required to create honest super trace") + + prestateTimestamp := game.StartingL2SequenceNumber() + poststateTimestamp := game.L2SequenceNumber() + + l1HeadHash := game.L1Head() + l1Head, err := f.ethClient.BlockRefByHash(f.t.Ctx(), l1HeadHash) + f.require.NoError(err, "Failed to fetch L1 Head") + + prestateProvider := super.NewSuperNodePrestateProvider(f.superNode.QueryAPI(), prestateTimestamp) + + vmPrestateSource := prestates.NewPrestateSource( + prestateBaseUrl, + prestateFile, + path.Join(f.challengerCfg.Datadir, "test-prestates"), + cannon.NewStateConverter(vmConfig), + ) + vmPrestatePath, err := vmPrestateSource.PrestatePath(f.t.Ctx(), game.absolutePrestate()) + f.require.NoError(err, "Failed to get prestate path") + + accessor, err := super.NewSuperCannonTraceAccessor( + logger, + metrics.NoopMetrics, + vmConfig, + serverExecutor, + prestateProvider, + nil, // supervisor client + f.superNode.QueryAPI(), + true, + vmPrestatePath, + path.Join(f.challengerCfg.Datadir, "test-prestates"), + l1Head.ID(), + game.SplitDepth(), + prestateTimestamp, + poststateTimestamp, + ) + f.require.NoError(err, "Failed to create super cannon trace accessor") + + f.honestTraces[game.Address] = accessor + return accessor +} + +func (f *DisputeGameFactory) startOutputRootGameOfType( + eoa *dsl.EOA, + gameType gameTypes.GameType, + honestTraceProvider func(game *FaultDisputeGame) challengerTypes.TraceAccessor, + opts ...GameOpt) *FaultDisputeGame { + cfg := NewGameCfg(opts...) + blockNum := cfg.l2SequenceNumber + if !cfg.l2SequenceNumberSet { + blockNum = f.l2CL.SafeL2BlockRef().Number + } + extraData := f.createOutputGameExtraData(blockNum, cfg) + rootClaim := cfg.rootClaim + if !cfg.rootClaimSet { + // Default to correct root claim + response, err := f.l2CL.Escape().RollupAPI().OutputAtBlock(f.t.Ctx(), blockNum) + f.require.NoErrorf(err, "Failed to get output root at block %v", blockNum) + rootClaim = common.Hash(response.OutputRoot) + } + game, addr := f.createNewGame(eoa, gameType, rootClaim, extraData) + return NewFaultDisputeGame(f.t, f.require, addr, f.getGameHelper, honestTraceProvider, game) +} + +func (f *DisputeGameFactory) createOutputGameExtraData(blockNum uint64, cfg *GameCfg) []byte { + f.require.NotNil(f.l2CL, "L2 CL is required create output games") + if !cfg.allowFuture { + f.l2CL.Reached(safetyTypes.LocalSafe, blockNum, 30) + } + extraData := make([]byte, 32) + binary.BigEndian.PutUint64(extraData[24:], blockNum) + return extraData +} + +func (f *DisputeGameFactory) createNewGame(eoa *dsl.EOA, gameType gameTypes.GameType, claim common.Hash, extraData []byte) (*bindings.FaultDisputeGame, common.Address) { + f.log.Info("Creating dispute game", "gameType", gameType, "claim", claim.Hex(), "extradata", common.Bytes2Hex(extraData)) + + // Pull some metadata we need to construct a new game + requiredBonds := f.initBond(gameType) + + receipt := contract.Write(eoa, f.dgf.Create(uint32(gameType), claim, extraData), txplan.WithValue(requiredBonds), txplan.WithGasRatio(2)) + f.require.Equal(types.ReceiptStatusSuccessful, receipt.Status) + + // Extract logs from receipt + f.require.Equal(2, len(receipt.Logs)) + createdLog, err := f.dgf.ParseDisputeGameCreated(receipt.Logs[1]) + f.require.NoError(err) + + gameAddr := createdLog.DisputeProxy + log.Info("Dispute game created", "address", gameAddr.Hex()) + return bindings.NewFaultDisputeGame(bindings.WithClient(f.ethClient), bindings.WithTo(gameAddr), bindings.WithTest(f.t)), gameAddr +} + +func (f *DisputeGameFactory) initBond(gameType gameTypes.GameType) eth.ETH { + return eth.WeiBig(contract.Read(f.dgf.InitBonds(uint32(gameType)))) +} + +func (f *DisputeGameFactory) CreateHelperEOA(eoa *dsl.EOA) *GameHelperEOA { + helper := f.getGameHelper(eoa) + eoaHelper := helper.AuthEOA(eoa) + return &GameHelperEOA{ + helper: eoaHelper, + EOA: eoa, + } +} + +// safeTimestamp retrieves the current safe timestamp from the supernode. +func (f *DisputeGameFactory) safeTimestamp() uint64 { + now := uint64(time.Now().Unix()) + resp, err := f.superNode.QueryAPI().SuperRootAtTimestamp(f.t.Ctx(), now) + f.require.NoError(err, "Failed to fetch super root at timestamp") + return resp.CurrentSafeTimestamp +} + +// RunFPP runs the fault proof program between the two supplied timestamps. Currently only supports kona-interop. +func (f *DisputeGameFactory) RunFPP(startTimestamp uint64, endTimestamp uint64) { + f.require.NotNil(f.superNode, "super node is required to run FPP") + f.require.NotNil(f.challengerCfg, "challenger config is required to run FPP") + + splitDepth := f.GameImpl(gameTypes.SuperCannonKonaGameType).SplitDepth() + + // Use the current L1 head that the super node has processed. Otherwise the trace provider will fail because the node is not sufficiently up to date. + superRootResp, err := f.superNode.QueryAPI().SuperRootAtTimestamp(f.t.Ctx(), endTimestamp) + f.require.NoError(err, "Failed to fetch super root at timestamp") + l1Head := superRootResp.CurrentL1 + + prestateProvider := super.NewSuperNodePrestateProvider(f.superNode.QueryAPI(), startTimestamp) + traceProvider := super.NewSuperNodeTraceProvider( + f.log.New("role", "fpp-trace"), + prestateProvider, + f.superNode.QueryAPI(), + eth.BlockID{Hash: l1Head.Hash, Number: l1Head.Number}, + splitDepth, + startTimestamp, + endTimestamp, + ) + + tmpDir := f.t.TempDir() + + // Starting prestate is the aboslutePrestate + absolutePrestate, err := prestateProvider.AbsolutePreState(f.t.Ctx()) + f.require.NoError(err, "Failed to get absolute prestate") + agreedPrestate := absolutePrestate.Marshal() + + // Iterate through valid claims at splitDepth (the leaves of the top game) to get a few steps past the endTimestamp + for i := uint64(0); i < (endTimestamp-startTimestamp)*super.StepsPerTimestamp+3; i++ { + pos := challengerTypes.NewPosition(splitDepth, new(big.Int).SetUint64(i)) + + timestamp, step, err := traceProvider.ComputeStep(pos) + f.require.NoError(err, "Failed to compute step") + + // Create LocalGameInputs using the previous claim (or anchor state) as agreed and current as disputed + f.log.Info("Getting preimage bytes at position", "position", pos, "timestamp", timestamp, "step", step, "i", i) + claimedPreimage, err := traceProvider.GetPreimageBytes(f.t.Ctx(), pos) + f.require.NoError(err, "Failed to get claim at position %v", pos) + inputs := utils.LocalGameInputs{ + L1Head: l1Head.Hash, + AgreedPreState: agreedPrestate, + L2Claim: crypto.Keccak256Hash(claimedPreimage), + L2SequenceNumber: new(big.Int).SetUint64(endTimestamp), + } + + f.log.Info("Created LocalGameInputs for FPP", + "index", pos.IndexAtDepth(), + "l1Head", inputs.L1Head, + "l2Claim", inputs.L2Claim, + "startTimestamp", startTimestamp, + "endTimestamp", endTimestamp, + "timestamp", timestamp, + "step", step, + "invalidTransition", super.InvalidTransition, + "invalidTransitionHash", super.InvalidTransitionHash, + ) + + runFPPForStep(f, tmpDir, inputs) + + // This claim becomes the agreed prestate for the next iteration + agreedPrestate = claimedPreimage + } +} + +// runFPPForStep executes the native kona interop client using the LocalGameInputs and requires the claim to be successfully validated. +func runFPPForStep(f *DisputeGameFactory, tmpDir string, inputs utils.LocalGameInputs) { + executor := vm.NewNativeKonaSuperExecutor() + oracleCommand, err := executor.OracleCommand(f.challengerCfg.CannonKona, tmpDir, inputs) + f.require.NoError(err, "Failed to create command") + f.log.Info("Executing FPP", "command", oracleCommand) + exePath, err := filepath.Abs(oracleCommand[0]) + f.require.NoError(err, "Failed to get absolute path to executable") + cmd := exec.Command(exePath, oracleCommand[1:]...) + cmd.Dir = tmpDir + log := f.log.New("role", "fpp-trace") + cmd.Stdout = &mipsevm.LoggingWriter{Log: log} + cmd.Stderr = &mipsevm.LoggingWriter{Log: log} + cmd.Env = append(append(cmd.Env, os.Environ()...), "NO_COLOR=1") + err = cmd.Run() + f.require.NoError(err, "Failed to execute game") +} + +type GameHelperEOA struct { + helper *GameHelper + EOA *dsl.EOA +} + +func (a *GameHelperEOA) PerformMoves(game *FaultDisputeGame, moves ...GameHelperMove) []*Claim { + return a.helper.PerformMoves(a.EOA, game, moves) +} + +func (a *GameHelperEOA) Address() common.Address { + return a.EOA.Address() +} + +// ethClientHeaderProvider is an adapter for the L1Client interface used in op-node and devstack to +// the HeaderProvider interface used in challenger +type ethClientHeaderProvider struct { + client apis.EthClient +} + +func (p *ethClientHeaderProvider) HeaderByNumber(ctx context.Context, blockNum *big.Int) (*types.Header, error) { + info, err := p.client.InfoByNumber(ctx, bigs.Uint64Strict(blockNum)) + if err != nil { + return nil, err + } + return info.Header(), nil +} diff --git a/op-devstack/dsl/proofs/fault_dispute_game.go b/op-devstack/dsl/proofs/fault_dispute_game.go new file mode 100644 index 00000000000..c58122951d2 --- /dev/null +++ b/op-devstack/dsl/proofs/fault_dispute_game.go @@ -0,0 +1,208 @@ +package proofs + +import ( + "context" + "fmt" + "math/big" + "time" + + challengerTypes "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/eth" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/dsl/contract" + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils/wait" + "github.com/ethereum-optimism/optimism/op-service/bigs" + "github.com/ethereum-optimism/optimism/op-service/txintent/bindings" + "github.com/ethereum-optimism/optimism/op-service/txplan" +) + +type gameHelperProvider func(deployer *dsl.EOA) *GameHelper + +type FaultDisputeGame struct { + t devtest.T + require *require.Assertions + game *bindings.FaultDisputeGame + Address common.Address + helperProvider gameHelperProvider + honestTraceProvider func() challengerTypes.TraceAccessor +} + +func NewFaultDisputeGame( + t devtest.T, + require *require.Assertions, + addr common.Address, + helperProvider gameHelperProvider, + honestTrace func(game *FaultDisputeGame) challengerTypes.TraceAccessor, + game *bindings.FaultDisputeGame, +) *FaultDisputeGame { + fdg := &FaultDisputeGame{ + t: t, + require: require, + game: game, + Address: addr, + helperProvider: helperProvider, + } + fdg.honestTraceProvider = func() challengerTypes.TraceAccessor { + return honestTrace(fdg) + } + return fdg +} + +func (g *FaultDisputeGame) GameType() gameTypes.GameType { + return gameTypes.GameType(contract.Read(g.game.GameType())) +} + +func (g *FaultDisputeGame) MaxDepth() challengerTypes.Depth { + return challengerTypes.Depth(bigs.Uint64Strict(contract.Read(g.game.MaxGameDepth()))) +} + +func (g *FaultDisputeGame) SplitDepth() challengerTypes.Depth { + return challengerTypes.Depth(bigs.Uint64Strict(contract.Read(g.game.SplitDepth()))) +} + +func (g *FaultDisputeGame) RootClaim() *Claim { + return g.ClaimAtIndex(0) +} + +func (g *FaultDisputeGame) L2SequenceNumber() uint64 { + return bigs.Uint64Strict(contract.Read(g.game.L2SequenceNumber())) +} + +func (g *FaultDisputeGame) StartingL2SequenceNumber() uint64 { + return contract.Read(g.game.StartingBlockNumber()) +} + +func (g *FaultDisputeGame) ClaimAtIndex(claimIndex uint64) *Claim { + claim := g.claimAtIndex(claimIndex) + return g.newClaim(claimIndex, claim) +} + +func (g *FaultDisputeGame) absolutePrestate() common.Hash { + return contract.Read(g.game.AbsolutePrestate()) +} + +func (g *FaultDisputeGame) L1Head() common.Hash { + return contract.Read(g.game.L1Head()) +} + +func (g *FaultDisputeGame) Attack(eoa *dsl.EOA, claimIdx uint64, newClaim common.Hash) { + claim := g.claimAtIndex(claimIdx) + g.t.Logf("Attacking claim %v (depth: %d) with counter-claim %v", claimIdx, claim.Position.Depth(), newClaim) + + requiredBond := g.requiredBond(claim.Position.Attack()) + + attackCall := g.game.Attack(claim.Value, new(big.Int).SetUint64(claimIdx), newClaim) + + receipt := contract.Write(eoa, attackCall, txplan.WithValue(requiredBond), txplan.WithGasRatio(2)) + g.t.Require().Equal(receipt.Status, types.ReceiptStatusSuccessful) +} + +func (g *FaultDisputeGame) Defend(eoa *dsl.EOA, claimIdx uint64, newClaim common.Hash) { + claim := g.claimAtIndex(claimIdx) + g.t.Logf("Defending claim %v (depth: %d) with counter-claim %v", claimIdx, claim.Position.Depth(), newClaim) + g.require.False(claim.IsRootPosition(), "Cannot defend the root claim") + + requiredBond := g.requiredBond(claim.Position.Defend()) + + defendCall := g.game.Defend(claim.Value, new(big.Int).SetUint64(claimIdx), newClaim) + + receipt := contract.Write(eoa, defendCall, txplan.WithValue(requiredBond), txplan.WithGasRatio(2)) + g.t.Require().Equal(receipt.Status, types.ReceiptStatusSuccessful) +} + +func (g *FaultDisputeGame) PerformMoves(eoa *dsl.EOA, moves ...GameHelperMove) []*Claim { + return g.helperProvider(eoa).PerformMoves(eoa, g, moves) +} + +func (g *FaultDisputeGame) DisputeL2SequenceNumber(eoa *dsl.EOA, startClaim *Claim, l2SequenceNumber uint64) *Claim { + return g.helperProvider(eoa).DisputeL2SequenceNumber(eoa, g, startClaim, l2SequenceNumber) +} + +func (g *FaultDisputeGame) DisputeToStep(eoa *dsl.EOA, startClaim *Claim, traceIndex uint64) *Claim { + return g.helperProvider(eoa).DisputeToStep(eoa, g, startClaim, traceIndex) +} + +func (g *FaultDisputeGame) requiredBond(pos challengerTypes.Position) eth.ETH { + return eth.WeiBig(contract.Read(g.game.GetRequiredBond((*bindings.Uint128)(pos.ToGIndex())))) +} + +func (g *FaultDisputeGame) status() gameTypes.GameStatus { + status := contract.Read(g.game.Status()) + return gameTypes.GameStatus(status) +} + +func (g *FaultDisputeGame) newClaim(claimIndex uint64, claim bindings.Claim) *Claim { + return newClaim(g.t, g.require, claimIndex, claim, g) +} + +func (g *FaultDisputeGame) claimAtIndex(claimIndex uint64) bindings.Claim { + return contract.Read(g.game.ClaimData(new(big.Int).SetUint64(claimIndex))).Decode() +} + +func (g *FaultDisputeGame) allClaims() []bindings.Claim { + allClaimData := contract.ReadArray(g.game.ClaimDataLen(), func(i *big.Int) bindings.TypedCall[bindings.ClaimData] { + return g.game.ClaimData(i) + }) + + // Decode claims + var claims []bindings.Claim + for _, claimData := range allClaimData { + claims = append(claims, claimData.Decode()) + } + + return claims +} + +func (g *FaultDisputeGame) claimCount() uint64 { + return bigs.Uint64Strict(contract.Read(g.game.ClaimDataLen())) +} + +func (g *FaultDisputeGame) waitForClaim(timeout time.Duration, errorMsg string, predicate func(claimIdx uint64, claim bindings.Claim) bool) (uint64, bindings.Claim) { + timedCtx, cancel := context.WithTimeout(g.t.Ctx(), timeout) + defer cancel() + var matchedClaim bindings.Claim + var matchClaimIdx uint64 + err := wait.For(timedCtx, time.Second, func() (bool, error) { + claims := g.allClaims() + // Search backwards because the new claims are at the end and more likely the ones we want. + for i := len(claims) - 1; i >= 0; i-- { + claim := claims[i] + if predicate(uint64(i), claim) { + matchClaimIdx = uint64(i) + matchedClaim = claim + return true, nil + } + } + return false, nil + }) + if err != nil { // Avoid waiting time capturing game data when there's no error + g.require.NoErrorf(err, "%v\n%v", errorMsg, g.GameData()) + } + return matchClaimIdx, matchedClaim +} + +func (g *FaultDisputeGame) GameData() string { + maxDepth := g.MaxDepth() + splitDepth := g.SplitDepth() + claims := g.allClaims() + info := fmt.Sprintf("Claim count: %v\n", len(claims)) + for i, claim := range claims { + pos := claim.Position + info = info + fmt.Sprintf("%v - Position: %v, Depth: %v, IndexAtDepth: %v Trace Index: %v, ClaimHash: %v, Countered By: %v, ParentIndex: %v Claimant: %v Bond: %v\n", + i, claim.Position.ToGIndex(), pos.Depth(), pos.IndexAtDepth(), pos.TraceIndex(maxDepth), claim.Value.Hex(), claim.CounteredBy, claim.ParentContractIndex, claim.Claimant, claim.Bond) + } + seqNum := g.L2SequenceNumber() + status := g.status() + return fmt.Sprintf("Game %v - %v - L2 Block: %v - Split Depth: %v - Max Depth: %v:\n%v\n", + g.Address, status, seqNum, splitDepth, maxDepth, info) +} + +func (g *FaultDisputeGame) LogGameData() { + g.t.Log(g.GameData()) +} diff --git a/op-devstack/dsl/proofs/game_helper.go b/op-devstack/dsl/proofs/game_helper.go new file mode 100644 index 00000000000..d31e4730c5f --- /dev/null +++ b/op-devstack/dsl/proofs/game_helper.go @@ -0,0 +1,394 @@ +package proofs + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "os" + "path/filepath" + "time" + + challengerTypes "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/eth" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + opservice "github.com/ethereum-optimism/optimism/op-service" + "github.com/ethereum-optimism/optimism/op-service/bigs" + "github.com/ethereum-optimism/optimism/op-service/txplan" +) + +// txTimeout is the maximum time to wait for a single transaction to be +// estimated, submitted, and included. Without this, transactions inherit the +// test's full context deadline (~2 hours in CI) and a hanging RPC call +// (e.g. eth_estimateGas for an EIP-7702 delegated EOA) blocks silently +// for the entire duration. +const txTimeout = 5 * time.Minute + +type GameHelperMove struct { + ParentIdx *big.Int + Claim common.Hash + Attack bool +} + +type contractArtifactData struct { + Bytecode []byte + ABI abi.ABI +} + +type GameHelper struct { + t devtest.T + require *require.Assertions + contractAddr common.Address + abi abi.ABI + honestTraceProvider func(game *FaultDisputeGame) challengerTypes.TraceAccessor +} + +func DeployGameHelper(t devtest.T, deployer *dsl.EOA, honestTraceProvider func(game *FaultDisputeGame) challengerTypes.TraceAccessor) *GameHelper { + req := require.New(t) + + artifactData := getGameHelperArtifactData(t) + + constructorABI := artifactData.ABI + + encodedArgs, err := constructorABI.Pack("") + req.NoError(err, "Failed to encode constructor arguments") + + deploymentData := append(artifactData.Bytecode, encodedArgs...) + + deployTxOpts := txplan.Combine( + deployer.Plan(), + txplan.WithData(deploymentData), + ) + + deployTx := txplan.NewPlannedTx(deployTxOpts) + deployCtx, deployCancel := context.WithTimeout(t.Ctx(), txTimeout) + defer deployCancel() + receipt, err := deployTx.Included.Eval(deployCtx) + req.NoError(err, "Failed to deploy GameHelper contract") + + req.Equal(types.ReceiptStatusSuccessful, receipt.Status, "GameHelper deployment failed") + req.NotEqual(common.Address{}, receipt.ContractAddress, "GameHelper contract address not set in receipt") + + contractAddr := receipt.ContractAddress + t.Logf("GameHelper contract deployed at: %s", contractAddr.Hex()) + + return &GameHelper{ + t: t, + require: require.New(t), + contractAddr: contractAddr, + abi: artifactData.ABI, + honestTraceProvider: honestTraceProvider, + } +} + +type ArtifactBytecode struct { + Object string `json:"object"` +} + +type ArtifactJSON struct { + Bytecode ArtifactBytecode `json:"bytecode"` + ABI json.RawMessage `json:"abi"` +} + +func getGameHelperArtifactData(t devtest.T) *contractArtifactData { + req := require.New(t) + artifactPath := getGameHelperArtifactPath(t) + + fileData, err := os.ReadFile(artifactPath) + req.NoError(err, "Failed to read GameHelper artifact file") + + var artifactJSON ArtifactJSON + err = json.Unmarshal(fileData, &artifactJSON) + req.NoError(err, "Failed to parse GameHelper artifact JSON") + + req.NotEmpty(artifactJSON.Bytecode.Object, "Bytecode object not found in GameHelper artifact") + + bytecode := common.FromHex(artifactJSON.Bytecode.Object) + + parsedABI, err := abi.JSON(bytes.NewReader(artifactJSON.ABI)) + req.NoError(err, "Failed to parse ABI") + + return &contractArtifactData{ + Bytecode: bytecode, + ABI: parsedABI, + } +} + +func getGameHelperArtifactPath(t devtest.T) string { + req := require.New(t) + wd, err := os.Getwd() + req.NoError(err, "Failed to get current working directory") + + monorepoRoot, err := opservice.FindMonorepoRoot(wd) + req.NoError(err, "Failed to find monorepo root") + + contractsBedrock := filepath.Join(monorepoRoot, "packages", "contracts-bedrock") + return filepath.Join(contractsBedrock, "forge-artifacts", "GameHelper.sol", "GameHelper.json") +} + +func (gs *GameHelper) AuthEOA(eoa *dsl.EOA) *GameHelper { + tx := txplan.NewPlannedTx(eoa.PlanAuth(gs.contractAddr)) + authCtx, authCancel := context.WithTimeout(gs.t.Ctx(), txTimeout) + defer authCancel() + receipt, err := tx.Included.Eval(authCtx) + gs.require.NoError(err) + gs.require.Equal(types.ReceiptStatusSuccessful, receipt.Status) + return &GameHelper{ + t: gs.t, + require: require.New(gs.t), + contractAddr: eoa.Address(), + abi: gs.abi, + honestTraceProvider: gs.honestTraceProvider, + } +} + +func (gs *GameHelper) CreateGameWithClaims( + eoa *dsl.EOA, + factory *DisputeGameFactory, + gameType gameTypes.GameType, + rootClaim common.Hash, + extraData []byte, + moves []GameHelperMove, +) common.Address { + data, err := gs.abi.Pack("createGameWithClaims", factory.Address(), gameType, rootClaim, extraData, moves) + gs.require.NoError(err) + + gameImpl := factory.GameImpl(gameType) + bonds := factory.initBond(gameType) + bonds = bonds.Add(gs.totalMoveBonds(gameImpl, moves)) + + tx := txplan.NewPlannedTx( + txplan.Combine( + eoa.Plan(), + txplan.WithValue(bonds), + txplan.WithTo(&gs.contractAddr), + txplan.WithData(data), + ), + ) + createCtx, createCancel := context.WithTimeout(gs.t.Ctx(), txTimeout) + defer createCancel() + receipt, err := tx.Included.Eval(createCtx) + gs.require.NoError(err) + gs.require.Equal(types.ReceiptStatusSuccessful, receipt.Status) + + return receipt.ContractAddress +} + +func (gs *GameHelper) DisputeL2SequenceNumber(eoa *dsl.EOA, game *FaultDisputeGame, startClaim *Claim, l2SequenceNumber uint64) *Claim { + splitDepth := game.SplitDepth() + startingSeqNumber := game.StartingL2SequenceNumber() + gs.require.Greater(l2SequenceNumber, startingSeqNumber, "Cannot dispute things at or prior to the starting block") + seqNumAtPosition := func(pos challengerTypes.Position) uint64 { + return bigs.Uint64Strict(pos.TraceIndex(splitDepth)) + startingSeqNumber + 1 + } + shouldMoveLeftFrom := func(pos challengerTypes.Position) bool { + // Move left when equal to the sequence number so that we disagree with it + return seqNumAtPosition(pos) >= l2SequenceNumber + } + finalClaim, gameState := gs.disputeTo(eoa, game, startClaim, splitDepth, shouldMoveLeftFrom) + + // Check that we landed in the right place + // We can only land on every second sequence number, starting from startingSeqNumber+1 + // And we want to land on the sequence number that's either equal to or one before l2SequenceNumber + // If it's equal to we would attack, if it's the one before we would defend to ensure the bottom + // half of the game is executing l2SequenceNumber. + finalPosition := seqNumAtPosition(finalClaim.Position()) + if l2SequenceNumber%2 == startingSeqNumber%2 { + gs.require.Equal(l2SequenceNumber-1, finalPosition) + // When defending the required status code depends on whether we provided the next trace or not. + disputedClaim, found := gameState.AncestorWithTraceIndex(finalClaim.asChallengerClaim(), finalClaim.Position().MoveRight().TraceIndex(game.MaxDepth())) + gs.require.True(found, "Did not find ancestor at target trace index") + statusCode := byte(0x00) + if disputedClaim.Position.Depth()%2 == splitDepth%2 { + statusCode = byte(0x01) + } + gs.t.Logf("Defend at split depth with status code %v", statusCode) + finalClaim = finalClaim.Defend(eoa, common.Hash{statusCode, 0xba, 0xd0}) + } else { + gs.t.Log("Attack at split depth") + gs.require.Equal(l2SequenceNumber, finalPosition) + // When attacking, the final block must be invalid so always use 0x01 as status code + finalClaim = finalClaim.Attack(eoa, common.Hash{0x01, 0xba, 0xd0}) + } + return finalClaim +} + +func (gs *GameHelper) DisputeToStep(eoa *dsl.EOA, game *FaultDisputeGame, startClaim *Claim, traceIndex uint64) *Claim { + splitDepth := game.SplitDepth() + maxDepth := game.MaxDepth() + if startClaim.Depth() < splitDepth { + gs.require.Greater(startClaim.Depth(), splitDepth, "Start claim must be past the game split depth") + } + traceIndexAtPosition := func(pos challengerTypes.Position) uint64 { + relativeFinalPosition, err := pos.RelativeToAncestorAtDepth(splitDepth + 1) + gs.require.NoError(err, "Failed to calculate relative position") + return bigs.Uint64Strict(relativeFinalPosition.TraceIndex(maxDepth - splitDepth - 1)) + } + shouldMoveLeftFrom := func(pos challengerTypes.Position) bool { + // Move left when equal to the trace index so that we disagree with it + return traceIndexAtPosition(pos) >= traceIndex + } + finalClaim, _ := gs.disputeTo(eoa, game, startClaim, maxDepth, shouldMoveLeftFrom) + // Check that we landed in the right place + // We can only land on every second sequence number, starting from startingSeqNumber+1 + // And we want to land on the sequence number that's either equal to or one before l2SequenceNumber + // If it's equal to we would attack, if it's the one before we would defend to ensure the bottom + // half of the game is executing l2SequenceNumber. + finalTraceIndex := traceIndexAtPosition(finalClaim.Position()) + if traceIndex%2 == 1 { + gs.require.Equal(traceIndex-1, finalTraceIndex) + } else { + gs.require.Equal(traceIndex, finalTraceIndex) + } + return finalClaim +} + +func (gs *GameHelper) disputeTo(eoa *dsl.EOA, game *FaultDisputeGame, startClaim *Claim, targetDepth challengerTypes.Depth, shouldMoveLeftFrom func(pos challengerTypes.Position) bool) (*Claim, challengerTypes.Game) { + honestTrace := gs.honestTraceProvider(game) + maxDepth := game.MaxDepth() + parentIdx := int64(startClaim.Index) + moves := make([]GameHelperMove, 0, targetDepth) + currentPos := startClaim.Position() + claims := allChallengerClaims(game) + gameState := challengerTypes.NewGameState(claims, maxDepth) + honestRootClaim, err := honestTrace.Get(gs.t.Ctx(), gameState, claims[0], challengerTypes.RootPosition) + gs.require.NoError(err, "Failed to get honest root claim") + agreeWithRoot := claims[0].Value == honestRootClaim + for currentPos.Depth() < targetDepth { + shouldAttack := shouldMoveLeftFrom(currentPos) + nextPos := currentPos.Defend() + if shouldAttack { + nextPos = currentPos.Attack() + } + claimValue := common.Hash{0xba, 0xd0} + gs.t.Logf("Disputing claim %v at depth %v with claim at depth %v", parentIdx, currentPos.Depth(), nextPos.Depth()) + if !shouldMoveLeftFrom(nextPos) || !gameState.AgreeWithClaimLevel(claims[len(claims)-1], agreeWithRoot) { + // Either we needed the honest actor to move right (defend) after this move or we are the honest actor + // so make sure we use an honest claim value. + value, err := honestTrace.Get(gs.t.Ctx(), gameState, startClaim.asChallengerClaim(), nextPos) + gs.require.NoError(err, "Failed to get trace value at position %v", nextPos) + claimValue = value + } + nextMove := Move(parentIdx, claimValue, shouldAttack) + + moves = append(moves, nextMove) + claims = append(claims, challengerTypes.Claim{ + ClaimData: challengerTypes.ClaimData{ + Value: nextMove.Claim, + Bond: big.NewInt(0), + Position: nextPos, + }, + Claimant: eoa.Address(), + Clock: challengerTypes.Clock{}, + ContractIndex: int(parentIdx + 1), + ParentContractIndex: int(parentIdx), + }) + gameState = challengerTypes.NewGameState(claims, maxDepth) + currentPos = nextPos + parentIdx++ + } + addedClaims := gs.PerformMoves(eoa, game, moves) + return addedClaims[len(addedClaims)-1], gameState +} + +func allChallengerClaims(game *FaultDisputeGame) []challengerTypes.Claim { + claims := game.allClaims() + challengerClaims := make([]challengerTypes.Claim, len(claims)) + for i, claim := range claims { + challengerClaims[i] = game.newClaim(uint64(i), claim).asChallengerClaim() + } + return challengerClaims +} + +func (gs *GameHelper) PerformMoves(eoa *dsl.EOA, game *FaultDisputeGame, moves []GameHelperMove) []*Claim { + gs.t.Log("Performing moves: \n" + describeMoves(moves)) + data, err := gs.abi.Pack("performMoves", game.Address, moves) + gs.require.NoError(err) + + tx := txplan.NewPlannedTx( + txplan.Combine( + eoa.Plan(), + txplan.WithValue(gs.totalMoveBonds(game, moves)), + txplan.WithTo(&gs.contractAddr), + txplan.WithData(data), + ), + ) + preClaimCount := game.claimCount() + moveCtx, moveCancel := context.WithTimeout(gs.t.Ctx(), txTimeout) + defer moveCancel() + receipt, err := tx.Included.Eval(moveCtx) + gs.require.NoError(err) + gs.require.Equal(types.ReceiptStatusSuccessful, receipt.Status) + postClaimCount := game.claimCount() + + // While all claims are performed within one transaction, it's possible another transaction also added claims + // between the calls to get claim count above (e.g. by a challenger running in parallel). + // So iterate to find the claims we added rather than just assuming the claim indices. + // Assumes that claims added by this helper contract are only added by this thread, + // which is safe because we deployed this particular instance of GameHelper. + claims := make([]*Claim, 0, len(moves)) + for claimIdx := preClaimCount; claimIdx < postClaimCount; claimIdx++ { + claim := game.ClaimAtIndex(claimIdx) + if claim.claim.Claimant != gs.contractAddr { + continue + } + claims = append(claims, claim) + } + gs.require.Equal(len(claims), len(moves), "Did not find claims for all moves") + return claims +} + +func describeMoves(moves []GameHelperMove) string { + description := "" + for _, move := range moves { + moveType := "Defend" + if move.Attack { + moveType = "Attack" + } + description += fmt.Sprintf("%s claim %s, value %s\n", moveType, move.ParentIdx, move.Claim) + } + return description +} + +func (gs *GameHelper) totalMoveBonds(game *FaultDisputeGame, moves []GameHelperMove) eth.ETH { + claimPositions := map[uint64]challengerTypes.Position{ + 0: challengerTypes.RootPosition, // The claim at index 0 is always in the root position + } + preExistingClaimCount := game.claimCount() + totalBond := eth.Ether(0) + for i, move := range moves { + parentPos := claimPositions[bigs.Uint64Strict(move.ParentIdx)] + if parentPos == (challengerTypes.Position{}) { + gs.require.LessOrEqual(bigs.Uint64Strict(move.ParentIdx), preExistingClaimCount, "No parent position found - moves may be out of order") + // Handle cases were there are existing claims and we're adding moves that reference them + gs.t.Logf("Loading parent position for existing claim at index %v", move.ParentIdx) + parentClaim := game.ClaimAtIndex(bigs.Uint64Strict(move.ParentIdx)) + parentPos = parentClaim.Position() + claimPositions[bigs.Uint64Strict(move.ParentIdx)] = parentPos + } + childPos := parentPos.Defend() + if move.Attack { + childPos = parentPos.Attack() + } + claimPositions[uint64(i)+preExistingClaimCount] = childPos + bond := game.requiredBond(childPos) + totalBond = totalBond.Add(bond) + } + return totalBond +} + +func Move(parentIdx int64, claim common.Hash, attack bool) GameHelperMove { + return GameHelperMove{ + ParentIdx: big.NewInt(parentIdx), + Claim: claim, + Attack: attack, + } +} diff --git a/op-devstack/dsl/proofs/super_fault_dispute_game.go b/op-devstack/dsl/proofs/super_fault_dispute_game.go new file mode 100644 index 00000000000..2d741a3731c --- /dev/null +++ b/op-devstack/dsl/proofs/super_fault_dispute_game.go @@ -0,0 +1,33 @@ +package proofs + +import ( + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types" + "github.com/ethereum-optimism/optimism/op-devstack/dsl/contract" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-service/txintent/bindings" +) + +type SuperFaultDisputeGame struct { + *FaultDisputeGame +} + +func NewSuperFaultDisputeGame( + t devtest.T, + require *require.Assertions, + addr common.Address, + helperProvider gameHelperProvider, + honestTrace func(game *FaultDisputeGame) types.TraceAccessor, + game *bindings.FaultDisputeGame, +) *SuperFaultDisputeGame { + fdg := NewFaultDisputeGame(t, require, addr, helperProvider, honestTrace, game) + return &SuperFaultDisputeGame{ + FaultDisputeGame: fdg, + } +} + +func (g *SuperFaultDisputeGame) StartingL2SequenceNumber() uint64 { + return contract.Read(g.game.StartingSequenceNumber()) +} diff --git a/op-devstack/dsl/rollup_boost.go b/op-devstack/dsl/rollup_boost.go new file mode 100644 index 00000000000..733a9e094e4 --- /dev/null +++ b/op-devstack/dsl/rollup_boost.go @@ -0,0 +1,37 @@ +package dsl + +import ( + opclient "github.com/ethereum-optimism/optimism/op-service/client" + + "github.com/ethereum-optimism/optimism/op-devstack/stack" +) + +type RollupBoostNodesSet []*RollupBoostNode + +func NewRollupBoostNodesSet(inner []stack.RollupBoostNode) RollupBoostNodesSet { + rollupBoostNodes := make([]*RollupBoostNode, len(inner)) + for i, c := range inner { + rollupBoostNodes[i] = NewRollupBoostNode(c) + } + return rollupBoostNodes +} + +// RollupBoostNode wraps a stack.RollupBoostNode interface for DSL operations +type RollupBoostNode struct { + inner stack.RollupBoostNode +} + +func (r *RollupBoostNode) Escape() stack.RollupBoostNode { + return r.inner +} + +// NewRollupBoostNode creates a new RollupBoostNode DSL wrapper +func NewRollupBoostNode(inner stack.RollupBoostNode) *RollupBoostNode { + return &RollupBoostNode{ + inner: inner, + } +} + +func (r *RollupBoostNode) FlashblocksClient() *opclient.WSClient { + return r.inner.FlashblocksClient() +} diff --git a/op-devstack/dsl/safedb.go b/op-devstack/dsl/safedb.go new file mode 100644 index 00000000000..b11170bf562 --- /dev/null +++ b/op-devstack/dsl/safedb.go @@ -0,0 +1,38 @@ +package dsl + +import ( + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/testreq" +) + +type safeHeadDBProvider interface { + safeHeadAtL1Block(l1BlockNum uint64) *eth.SafeHeadResponse +} + +func checkSafeHeadConsistent(t devtest.T, maxL1BlockNum uint64, checkNode, sourceOfTruth safeHeadDBProvider, minRequiredL2Block *uint64) { + require := testreq.New(t) + l1BlockNum := maxL1BlockNum + var minL2BlockRecorded *uint64 + for { + actual := checkNode.safeHeadAtL1Block(l1BlockNum) + if actual == nil { + // No further safe head data available + // Stop iterating as long as we found _some_ data + require.NotNil(minL2BlockRecorded, "no safe head data available at L1 block %v", l1BlockNum) + if minRequiredL2Block != nil { + // Ensure we had data back at least as far as minRequiredL2Block + require.LessOrEqual(*minL2BlockRecorded, *minRequiredL2Block, "safe head db did not go back far enough") + } + return + } + + expected := sourceOfTruth.safeHeadAtL1Block(l1BlockNum) + require.Equalf(expected, actual, "Mismatched safe head data at l1 block %v", l1BlockNum) + if actual.L1Block.Number == 0 { + return // Reached L1 and L2 genesis. + } + l1BlockNum = actual.L1Block.Number - 1 + minL2BlockRecorded = &actual.SafeHead.Number + } +} diff --git a/op-devstack/dsl/sequencer.go b/op-devstack/dsl/sequencer.go new file mode 100644 index 00000000000..04e398e538c --- /dev/null +++ b/op-devstack/dsl/sequencer.go @@ -0,0 +1,71 @@ +package dsl + +import ( + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-test-sequencer/sequencer/seqtypes" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +type TestSequencer struct { + commonImpl + + inner stack.TestSequencer +} + +func NewTestSequencer(inner stack.TestSequencer) *TestSequencer { + return &TestSequencer{ + commonImpl: commonFromT(inner.T()), + inner: inner, + } +} + +func (s *TestSequencer) String() string { + return s.inner.Name() +} + +func (s *TestSequencer) Escape() stack.TestSequencer { + return s.inner +} + +// SequenceBlock builds a block at deterministic timestamp (parent.Time + blockTime). +// This is useful for tests that need predictable block timestamps. +func (s *TestSequencer) SequenceBlock(t devtest.T, chainID eth.ChainID, parent common.Hash) { + ca := s.Escape().ControlAPI(chainID) + + require.NoError(t, ca.New(t.Ctx(), seqtypes.BuildOpts{Parent: parent})) + require.NoError(t, ca.Next(t.Ctx())) +} + +// SequenceBlockWithTxs builds a block with timestamp parent.Time + blockTime with the supplied transactions (bypassing the mempool). +// This makes it ideal for same-timestamp interop testing, and avoids the chance that transactions are sequenced into later blocks. +func (s *TestSequencer) SequenceBlockWithTxs(t devtest.T, chainID eth.ChainID, parent common.Hash, rawTxs [][]byte) { + ctx := t.Ctx() + ca := s.Escape().ControlAPI(chainID) + + // Start a new block building job + require.NoError(t, ca.New(ctx, seqtypes.BuildOpts{Parent: parent})) + + // Include each transaction BEFORE opening + // IncludeTx adds to the job's attrs.Transactions which are used when Open() starts block building + for _, rawTx := range rawTxs { + require.NoError(t, ca.IncludeTx(ctx, hexutil.Bytes(rawTx))) + } + + // Open the block building with the included transactions + require.NoError(t, ca.Open(ctx)) + + // Seal, sign, and commit the block + // Commit is what makes the block canonical in the EL + require.NoError(t, ca.Seal(ctx)) + require.NoError(t, ca.Sign(ctx)) + require.NoError(t, ca.Commit(ctx)) + + // Publish is optional - it broadcasts via P2P which may not be enabled in tests. + // The block is already committed and canonical at this point. + _ = ca.Publish(ctx) // ignore publish errors +} diff --git a/op-devstack/dsl/supernode.go b/op-devstack/dsl/supernode.go new file mode 100644 index 00000000000..6e938abdae5 --- /dev/null +++ b/op-devstack/dsl/supernode.go @@ -0,0 +1,167 @@ +package dsl + +import ( + "context" + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils/wait" + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum/go-ethereum/common" +) + +// Supernode wraps a stack.Supernode interface for DSL operations +type Supernode struct { + commonImpl + inner stack.Supernode + testControl stack.InteropTestControl +} + +// NewSupernode creates a new Supernode DSL wrapper +func NewSupernode(inner stack.Supernode) *Supernode { + return &Supernode{ + commonImpl: commonFromT(inner.T()), + inner: inner, + } +} + +// NewSupernodeWithTestControl creates a new Supernode DSL wrapper with test control support. +// The testControl parameter can be nil if no test control is needed. +func NewSupernodeWithTestControl(inner stack.Supernode, testControl stack.InteropTestControl) *Supernode { + return &Supernode{ + commonImpl: commonFromT(inner.T()), + inner: inner, + testControl: testControl, + } +} + +func (s *Supernode) Name() string { + return s.inner.Name() +} + +func (s *Supernode) String() string { + return s.inner.Name() +} + +// Escape returns the underlying stack.Supernode +func (s *Supernode) Escape() stack.Supernode { + return s.inner +} + +// QueryAPI returns the supernode's query API +func (s *Supernode) QueryAPI() apis.SupernodeQueryAPI { + return s.inner.QueryAPI() +} + +// SuperRootAtTimestamp fetches the super-root at the given timestamp +func (s *Supernode) SuperRootAtTimestamp(timestamp uint64) eth.SuperRootAtTimestampResponse { + ctx, cancel := context.WithTimeout(s.ctx, DefaultTimeout) + defer cancel() + resp, err := s.inner.QueryAPI().SuperRootAtTimestamp(ctx, timestamp) + s.require.NoError(err, "failed to get super-root at timestamp %d", timestamp) + return resp +} + +// AssertSuperRootAtTimestamp asserts that the super-root at the given timestamp matches the expected root claim +func (s *Supernode) AssertSuperRootAtTimestamp(l2SequenceNumber uint64, rootClaim common.Hash) { + resp := s.SuperRootAtTimestamp(l2SequenceNumber) + s.require.NotNilf(resp.Data, "super root does not exist at time %d", l2SequenceNumber) + superRoot := eth.SuperRoot(resp.Data.Super) + s.require.Equal(superRoot[:], rootClaim[:]) +} + +// AwaitValidatedTimestamp waits for the super-root at the given timestamp to be fully validated +func (s *Supernode) AwaitValidatedTimestamp(timestamp uint64) { + ctx, cancel := context.WithTimeout(s.ctx, 5*DefaultTimeout) + defer cancel() + err := wait.For(ctx, 1*time.Second, func() (bool, error) { + resp, err := s.inner.QueryAPI().SuperRootAtTimestamp(ctx, timestamp) + if err != nil { + return false, nil // Ignore transient errors. + } + return resp.Data != nil, nil + }) + s.require.NoError(err, "super-root at timestamp %d was not validated in time", timestamp) +} + +// PauseInterop pauses the interop activity at the given timestamp. +// When the interop activity attempts to process this timestamp, it returns early. +// This function is for integration test control only. +// Requires the Supernode to be created with NewSupernodeWithTestControl. +func (s *Supernode) PauseInterop(ts uint64) { + s.require.NotNil(s.testControl, "PauseInterop requires test control; use NewSupernodeWithTestControl") + s.testControl.PauseInteropActivity(ts) +} + +// ResumeInterop clears any pause on the interop activity, allowing normal processing. +// This function is for integration test control only. +// Requires the Supernode to be created with NewSupernodeWithTestControl. +func (s *Supernode) ResumeInterop() { + s.require.NotNil(s.testControl, "ResumeInterop requires test control; use NewSupernodeWithTestControl") + s.testControl.ResumeInteropActivity() +} + +// EnsureInteropPaused pauses the interop activity and verifies it has stopped. +// It takes the local safe timestamps from two CL nodes, uses the maximum, then: +// 1. Pauses interop at localSafeTimestamp + pauseOffset +// 2. Awaits validation of localSafeTimestamp + pauseOffset - 1 +// 3. Finds the first timestamp that is NOT verified (the actual pause point) +// Returns the first unverified timestamp (adjusted if pause came in late). +// Requires the Supernode to be created with NewSupernodeWithTestControl. +func (s *Supernode) EnsureInteropPaused(clA, clB *L2CLNode, pauseOffset uint64) uint64 { + s.require.NotNil(s.testControl, "EnsureInteropPaused requires test control; use NewSupernodeWithTestControl") + + // Get the local safe of both chains from sync status + statusA := clA.SyncStatus() + statusB := clB.SyncStatus() + + // Use the maximum local safe timestamp between both chains + localSafeTimestamp := max(statusA.LocalSafeL2.Time, statusB.LocalSafeL2.Time) + + s.log.Info("EnsureInteropPaused: initial sync status", + "chainA_local_safe_num", statusA.LocalSafeL2.Number, + "chainA_local_safe_ts", statusA.LocalSafeL2.Time, + "chainB_local_safe_num", statusB.LocalSafeL2.Number, + "chainB_local_safe_ts", statusB.LocalSafeL2.Time, + "localSafeTimestamp", localSafeTimestamp, + ) + + pauseTimestamp := localSafeTimestamp + pauseOffset + awaitTimestamp := pauseTimestamp - 1 + + // Pause interop activity at the pause timestamp + s.testControl.PauseInteropActivity(pauseTimestamp) + + // Await interop validation of the timestamp before the pause + s.AwaitValidatedTimestamp(awaitTimestamp) + + s.log.Info("EnsureInteropPaused: validation confirmed before pause", "timestamp", awaitTimestamp) + + // Find the first timestamp that is NOT verified. + // If the pause came in late, some timestamps past pauseTimestamp may already be verified. + // We scan forward to find where interop actually stopped. + ctx, cancel := context.WithTimeout(s.ctx, DefaultTimeout) + defer cancel() + + for ts := pauseTimestamp; ts < pauseTimestamp+100; ts++ { + resp, err := s.inner.QueryAPI().SuperRootAtTimestamp(ctx, ts) + if err != nil || resp.Data == nil { + // Found the first unverified timestamp + s.log.Info("EnsureInteropPaused: confirmed interop is paused", + "intendedPauseTimestamp", pauseTimestamp, + "actualPauseTimestamp", ts, + ) + return ts + } + // This timestamp is verified, continue scanning + s.log.Warn("EnsureInteropPaused: pause came in late, timestamp already verified", + "timestamp", ts, + "intendedPause", pauseTimestamp, + ) + } + + s.t.Error("EnsureInteropPaused: failed to find unverified timestamp within 100 timestamps") + s.t.FailNow() + return pauseTimestamp +} diff --git a/op-devstack/dsl/supervisor.go b/op-devstack/dsl/supervisor.go new file mode 100644 index 00000000000..04f5ba5ac6d --- /dev/null +++ b/op-devstack/dsl/supervisor.go @@ -0,0 +1,228 @@ +package dsl + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils/wait" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/retry" + "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/backend/status" + "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +type Supervisor struct { + commonImpl + inner stack.Supervisor +} + +func NewSupervisor(inner stack.Supervisor) *Supervisor { + return &Supervisor{ + commonImpl: commonFromT(inner.T()), + inner: inner, + } +} + +func (s *Supervisor) String() string { + return s.inner.Name() +} + +func (s *Supervisor) Escape() stack.Supervisor { + return s.inner +} + +type VerifySyncStatusConfig struct { + AllUnsafeHeadsAdvance uint64 +} + +// WithAllLocalUnsafeHeadsAdvancedBy verifies that the local unsafe head of every chain advances by at least the +// specified number of blocks compared to the value when VerifySyncStatus is called. +func WithAllLocalUnsafeHeadsAdvancedBy(blocks uint64) func(cfg *VerifySyncStatusConfig) { + return func(cfg *VerifySyncStatusConfig) { + cfg.AllUnsafeHeadsAdvance = blocks + } +} + +// VerifySyncStatus performs assertions based on the supervisor's SyncStatus endpoint. +func (s *Supervisor) VerifySyncStatus(opts ...func(config *VerifySyncStatusConfig)) { + cfg := applyOpts(VerifySyncStatusConfig{}, opts...) + initial := s.FetchSyncStatus() + ctx, cancel := context.WithTimeout(s.ctx, DefaultTimeout) + defer cancel() + err := wait.For(ctx, 1*time.Second, func() (bool, error) { + status := s.FetchSyncStatus() + s.require.Equalf(len(initial.Chains), len(status.Chains), "Expected %d chains in status but got %d", len(initial.Chains), len(status.Chains)) + for chID, chStatus := range status.Chains { + chInitial := initial.Chains[chID] + required := chInitial.LocalUnsafe.Number + cfg.AllUnsafeHeadsAdvance + if chStatus.LocalUnsafe.Number < required { + s.log.Info("Required sync status not reached. Chain local unsafe has not advanced enough", + "chain", chID, "initialUnsafe", chInitial.LocalUnsafe, "currentUnsafe", chStatus.LocalUnsafe, "minRequired", required) + return false, nil + } + } + return true, nil + }) + s.require.NoError(err, "Expected sync status not found") +} + +func (s *Supervisor) AwaitMinL1(minL1 uint64) { + ctx, cancel := context.WithTimeout(s.ctx, DefaultTimeout) + defer cancel() + err := wait.For(ctx, 1*time.Second, func() (bool, error) { + return s.FetchSyncStatus().MinSyncedL1.Number >= minL1, nil + }) + s.require.NoError(err, "Expected sync status not found") +} + +func (s *Supervisor) AwaitMinCrossSafeTimestamp(timestamp uint64) { + ctx, cancel := context.WithTimeout(s.ctx, DefaultTimeout) + defer cancel() + err := wait.For(ctx, 1*time.Second, func() (bool, error) { + return s.FetchSyncStatus().SafeTimestamp >= timestamp, nil + }) + s.require.NoError(err, "Expected sync status not found") +} + +func (s *Supervisor) FetchSyncStatus() eth.SupervisorSyncStatus { + s.log.Debug("Fetching supervisor sync status") + ctx, cancel := context.WithTimeout(s.ctx, DefaultTimeout) + defer cancel() + syncStatus, err := retry.Do(ctx, 10, retry.Fixed(500*time.Millisecond), func() (eth.SupervisorSyncStatus, error) { + ctx, cancel := context.WithTimeout(s.ctx, 300*time.Millisecond) + defer cancel() + syncStatus, err := s.inner.QueryAPI().SyncStatus(ctx) + if errors.Is(err, status.ErrStatusTrackerNotReady) { + s.log.Debug("Sync status not ready from supervisor") + return syncStatus, err + } + // Check for L1 sync mismatch error and retry + if err != nil && strings.Contains(err.Error(), "min synced L1 mismatch") { + s.log.Debug("L1 sync mismatch, retrying", "error", err) + return syncStatus, err + } + return syncStatus, err + }) + s.require.NoError(err, "Failed to fetch sync status") + s.log.Info("Fetched supervisor sync status", + "minSyncedL1", syncStatus.MinSyncedL1, + "safeTimestamp", syncStatus.SafeTimestamp, + "finalizedTimestamp", syncStatus.FinalizedTimestamp) + return syncStatus +} + +func (s *Supervisor) SafeBlockID(chainID eth.ChainID) eth.BlockID { + return s.L2HeadBlockID(chainID, types.CrossSafe) +} + +// ChainSyncStatus satisfies that the supervisor can provide sync status per chain +func (s *Supervisor) ChainSyncStatus(chainID eth.ChainID, lvl types.SafetyLevel) eth.BlockID { + return s.L2HeadBlockID(chainID, lvl) +} + +// L2HeadBlockID fetches supervisor sync status and returns block id with given safety level +func (s *Supervisor) L2HeadBlockID(chainID eth.ChainID, lvl types.SafetyLevel) eth.BlockID { + supervisorSyncStatus := s.FetchSyncStatus() + supervisorChainSyncStatus, ok := supervisorSyncStatus.Chains[chainID] + s.require.True(ok, "chain id not found in supervisor sync status") + var blockID eth.BlockID + switch lvl { + case types.Finalized: + blockID = supervisorChainSyncStatus.Finalized + case types.CrossSafe: + blockID = supervisorChainSyncStatus.CrossSafe + case types.LocalSafe: + blockID = supervisorChainSyncStatus.LocalSafe + case types.CrossUnsafe: + blockID = supervisorChainSyncStatus.CrossUnsafe + case types.LocalUnsafe: + blockID = supervisorChainSyncStatus.LocalUnsafe.ID() + default: + s.require.NoError(errors.New("invalid safety level")) + } + return blockID +} + +// WaitForL2HeadToAdvance checks the supervisor view of L2CL chain head with given safety level advanced more than delta block number +func (s *Supervisor) WaitForL2HeadToAdvance(chainID eth.ChainID, delta uint64, lvl types.SafetyLevel, attempts int) { + chInitial := s.L2HeadBlockID(chainID, lvl) + target := chInitial.Number + delta + err := retry.Do0(s.ctx, attempts, &retry.FixedStrategy{Dur: 2 * time.Second}, + func() error { + chStatus := s.L2HeadBlockID(chainID, lvl) + s.log.Info("Supervisor view", + "chain", chainID, "label", lvl, "initial", chInitial.Number, "current", chStatus.Number, "target", target) + if chStatus.Number >= target { + s.log.Info("Supervisor view advanced", "chain", chainID, "label", lvl, "target", target) + return nil + } + return fmt.Errorf("expected head to advance: %s", lvl) + }) + s.require.NoError(err) +} + +func (s *Supervisor) WaitForL2HeadToAdvanceTo(chainID eth.ChainID, lvl types.SafetyLevel, blockID eth.BlockID) { + ctx, cancel := context.WithCancelCause(s.ctx) + defer cancel(nil) + err := retry.Do0(ctx, 5*60, &retry.FixedStrategy{Dur: 1 * time.Second}, func() error { + chStatus := s.L2HeadBlockID(chainID, lvl) + s.log.Info("Supervisor view", + "chain", chainID, "label", lvl, "current", chStatus.Number, "target", blockID.Number) + if chStatus.Number < blockID.Number { + return fmt.Errorf("expected %s head to advance to blockID: %v", lvl, blockID) + } else if chStatus.Number == blockID.Number && chStatus.Hash != blockID.Hash { + err := fmt.Errorf("supervisor %s head with blockID %v for chainID %s does not match target blockID: %v", lvl, chStatus, chainID, blockID) + cancel(err) + return err + } + return nil + }) + + // If we got a context.Canceled error, check if there's a more descriptive cause + if err != nil && errors.Is(err, context.Canceled) { + if cause := context.Cause(ctx); cause != nil && !errors.Is(cause, context.Canceled) { + // Log the original cause for better debugging + err = fmt.Errorf("supervisor wait failed: %w (original cause: %w)", err, cause) + } + } + + s.require.NoError(err) +} + +func (s *Supervisor) WaitForUnsafeHeadToAdvance(chainID eth.ChainID, delta uint64) { + attempts := int(delta + 3) // intentionally allow few more attempts for avoid flaking + s.WaitForL2HeadToAdvance(chainID, delta, types.LocalUnsafe, attempts) +} + +func (s *Supervisor) AdvancedSafeHead(chainID eth.ChainID, delta uint64, attempts int) { + s.WaitForL2HeadToAdvance(chainID, delta, types.CrossSafe, attempts) +} + +func (s *Supervisor) FetchSuperRootAtTimestamp(timestamp uint64) eth.SuperRootResponse { + response, err := s.inner.QueryAPI().SuperRootAtTimestamp(s.ctx, hexutil.Uint64(timestamp)) + s.require.NoError(err, "Unable to fetch super root at timestamp") + return response +} + +func (s *Supervisor) Start() { + lifecycle, ok := s.inner.(stack.Lifecycle) + s.require.Truef(ok, "supervisor %s is not lifecycle-controllable", s.inner.Name()) + lifecycle.Start() +} + +func (s *Supervisor) Stop() { + lifecycle, ok := s.inner.(stack.Lifecycle) + s.require.Truef(ok, "supervisor %s is not lifecycle-controllable", s.inner.Name()) + lifecycle.Stop() +} + +func (s *Supervisor) AddManagedL2CL(cl *L2CLNode) { + interopEndpoint, secret := cl.inner.InteropRPC() + err := s.inner.AdminAPI().AddL2RPC(s.ctx, interopEndpoint, secret) + s.require.NoError(err, "failed to connect L2CL to supervisor") +} diff --git a/op-devstack/dsl/sync_tester.go b/op-devstack/dsl/sync_tester.go new file mode 100644 index 00000000000..7f5fcf108ce --- /dev/null +++ b/op-devstack/dsl/sync_tester.go @@ -0,0 +1,48 @@ +package dsl + +import ( + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +// SyncTester wraps a stack.SyncTester interface for DSL operations +type SyncTester struct { + commonImpl + inner stack.SyncTester +} + +// NewSyncTester creates a new Sync Tester DSL wrapper +func NewSyncTester(inner stack.SyncTester) *SyncTester { + return &SyncTester{ + commonImpl: commonFromT(inner.T()), + inner: inner, + } +} + +// Escape returns the underlying stack.SyncTester +func (s *SyncTester) Escape() stack.SyncTester { + return s.inner +} + +func (s *SyncTester) ListSessions() []string { + sessionIDs, err := s.inner.API().ListSessions(s.ctx) + s.t.Require().NoError(err) + return sessionIDs +} + +func (s *SyncTester) GetSession(sessionID string) *eth.SyncTesterSession { + session, err := s.inner.APIWithSession(sessionID).GetSession(s.ctx) + s.t.Require().NoError(err) + return session +} + +func (s *SyncTester) DeleteSession(sessionID string) { + err := s.inner.APIWithSession(sessionID).DeleteSession(s.ctx) + s.t.Require().NoError(err) +} + +func (s *SyncTester) ChainID(sessionID string) eth.ChainID { + chainID, err := s.inner.APIWithSession(sessionID).ChainID(s.ctx) + s.t.Require().NoError(err, "should be able to get chain ID from SyncTester") + return chainID +} diff --git a/op-devstack/presets/flashblocks.go b/op-devstack/presets/flashblocks.go new file mode 100644 index 00000000000..b16d067cdd1 --- /dev/null +++ b/op-devstack/presets/flashblocks.go @@ -0,0 +1,176 @@ +package presets + +import ( + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/dsl/proofs" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/ethereum-optimism/optimism/op-faucet/faucet" + "github.com/ethereum-optimism/optimism/op-service/client" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +type SingleChainWithFlashblocks struct { + *Minimal + + L2OPRBuilder *dsl.OPRBuilderNode + L2RollupBoost *dsl.RollupBoostNode + TestSequencer *dsl.TestSequencer +} + +func (m *SingleChainWithFlashblocks) L2Networks() []*dsl.L2Network { + return []*dsl.L2Network{ + m.L2Chain, + } +} + +func (m *SingleChainWithFlashblocks) StandardBridge() *dsl.StandardBridge { + return dsl.NewStandardBridge(m.T, m.L2Chain, m.L1EL) +} + +func (m *SingleChainWithFlashblocks) DisputeGameFactory() *proofs.DisputeGameFactory { + return proofs.NewDisputeGameFactory(m.T, m.L1Network, m.L1EL.EthClient(), m.L2Chain.DisputeGameFactoryProxyAddr(), m.L2CL, m.L2EL, nil, m.challengerConfig) +} + +func (m *SingleChainWithFlashblocks) AdvanceTime(amount time.Duration) { + m.Minimal.AdvanceTime(amount) +} + +func NewSingleChainWithFlashblocks(t devtest.T, opts ...Option) *SingleChainWithFlashblocks { + presetCfg, _ := collectSupportedPresetConfig(t, "NewSingleChainWithFlashblocks", opts, singleChainWithFlashblocksPresetSupportedOptionKinds) + runtime := sysgo.NewFlashblocksRuntimeWithConfig(t, presetCfg) + return singleChainWithFlashblocksFromRuntime(t, runtime) +} + +func singleChainWithFlashblocksFromRuntime(t devtest.T, runtime *sysgo.SingleChainRuntime) *SingleChainWithFlashblocks { + t.Require().NotNil(runtime.Flashblocks, "missing flashblocks support") + l1ChainID := runtime.L1Network.ChainID() + l2ChainID := runtime.L2Network.ChainID() + + l1Network := newPresetL1Network(t, "l1", runtime.L1Network.ChainConfig()) + l1EL := newL1ELFrontend(t, "l1", l1ChainID, runtime.L1EL.UserRPC()) + l1CL := newL1CLFrontend(t, "l1", l1ChainID, runtime.L1CL.BeaconHTTPAddr(), runtime.L1CL.FakePoS()) + l1Network.AddL1ELNode(l1EL) + l1Network.AddL1CLNode(l1CL) + + l2Chain := newPresetL2Network( + t, + "l2a", + runtime.L2Network.ChainConfig(), + runtime.L2Network.RollupConfig(), + runtime.L2Network.Deployment(), + newKeyring(runtime.Keys, t.Require()), + l1Network, + ) + + l2EL := newL2ELFrontend( + t, + "sequencer", + l2ChainID, + runtime.L2EL.UserRPC(), + runtime.L2EL.EngineRPC(), + runtime.L2EL.JWTPath(), + runtime.L2Network.RollupConfig(), + ) + l2CL := newL2CLFrontend( + t, + "sequencer", + l2ChainID, + runtime.L2CL.UserRPC(), + runtime.L2CL, + ) + + l2OPRBuilder := newOPRBuilderFrontend( + t, + "sequencer-builder", + l2ChainID, + runtime.Flashblocks.Builder.UserRPC(), + runtime.Flashblocks.Builder.FlashblocksWSURL(), + runtime.Flashblocks.Builder.UpdateRuleSet, + runtime.L2Network.RollupConfig(), + runtime.Flashblocks.Builder, + ) + l2RollupBoost := newRollupBoostFrontend( + t, + "rollup-boost", + l2ChainID, + runtime.Flashblocks.RollupBoost.UserRPC(), + runtime.Flashblocks.RollupBoost.FlashblocksWSURL(), + runtime.L2Network.RollupConfig(), + runtime.Flashblocks.RollupBoost, + ) + testSequencer := newTestSequencerFrontend( + t, + runtime.TestSequencer.Name, + runtime.TestSequencer.AdminRPC, + runtime.TestSequencer.ControlRPC, + runtime.TestSequencer.JWTSecret, + ) + + l2Chain.AddL2ELNode(l2EL) + l2Chain.AddL2CLNode(l2CL) + l2Chain.AddOPRBuilderNode(l2OPRBuilder) + l2Chain.AddRollupBoostNode(l2RollupBoost) + l2CL.attachEL(l2EL) + l2CL.attachOPRBuilderNode(l2OPRBuilder) + l2CL.attachRollupBoostNode(l2RollupBoost) + + faucetL1Frontend := newFaucetFrontendForChain(t, runtime.FaucetService, l1ChainID) + faucetL2Frontend := newFaucetFrontendForChain(t, runtime.FaucetService, l2ChainID) + l1Network.AddFaucet(faucetL1Frontend) + l2Chain.AddFaucet(faucetL2Frontend) + faucetL1 := dsl.NewFaucet(faucetL1Frontend) + faucetL2 := dsl.NewFaucet(faucetL2Frontend) + + l1ELDSL := dsl.NewL1ELNode(l1EL) + l1CLDSL := dsl.NewL1CLNode(l1CL) + l2ELDSL := dsl.NewL2ELNode(l2EL) + l2CLDSL := dsl.NewL2CLNode(l2CL) + + minimal := &Minimal{ + Log: t.Logger(), + T: t, + L1Network: dsl.NewL1Network(l1Network, l1ELDSL, l1CLDSL), + L1EL: l1ELDSL, + L1CL: l1CLDSL, + L2Chain: dsl.NewL2Network(l2Chain, l2ELDSL, l2CLDSL, l1ELDSL, nil, nil), + L2EL: l2ELDSL, + L2CL: l2CLDSL, + Wallet: dsl.NewRandomHDWallet(t, 30), // Random for test isolation + FaucetL1: faucetL1, + FaucetL2: faucetL2, + } + minimal.FunderL1 = dsl.NewFunder(minimal.Wallet, minimal.FaucetL1, minimal.L1EL) + minimal.FunderL2 = dsl.NewFunder(minimal.Wallet, minimal.FaucetL2, minimal.L2EL) + + return &SingleChainWithFlashblocks{ + L2OPRBuilder: dsl.NewOPRBuilderNode(l2OPRBuilder), + L2RollupBoost: dsl.NewRollupBoostNode(l2RollupBoost), + Minimal: minimal, + TestSequencer: dsl.NewTestSequencer(testSequencer), + } +} + +func newFaucetFrontendForChain(t devtest.T, faucetService *faucet.Service, chainID eth.ChainID) *faucetFrontend { + faucetName, faucetRPC, ok := defaultFaucetForChain(faucetService, chainID) + t.Require().Truef(ok, "missing default faucet for chain %s", chainID) + + rpcCl, err := client.NewRPC(t.Ctx(), t.Logger(), faucetRPC, client.WithLazyDial()) + t.Require().NoError(err) + t.Cleanup(rpcCl.Close) + + return newPresetFaucet(t, faucetName, chainID, rpcCl) +} + +func defaultFaucetForChain(faucetService *faucet.Service, chainID eth.ChainID) (string, string, bool) { + if faucetService == nil { + return "", "", false + } + faucetID, ok := faucetService.Defaults()[chainID] + if !ok { + return "", "", false + } + return faucetID.String(), faucetService.FaucetEndpoint(faucetID), true +} diff --git a/op-devstack/presets/interop.go b/op-devstack/presets/interop.go new file mode 100644 index 00000000000..cc55d7d413c --- /dev/null +++ b/op-devstack/presets/interop.go @@ -0,0 +1,213 @@ +package presets + +import ( + "time" + + "github.com/ethereum/go-ethereum/log" + + "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" + challengerConfig "github.com/ethereum-optimism/optimism/op-challenger/config" + "github.com/ethereum-optimism/optimism/op-core/forks" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/dsl/proofs" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils/intentbuilder" + "github.com/ethereum-optimism/optimism/op-service/clock" +) + +type SingleChainInterop struct { + Log log.Logger + T devtest.T + timeTravel *clock.AdvancingClock + + Supervisor *dsl.Supervisor + SuperRoots *dsl.Supernode + TestSequencer *dsl.TestSequencer + + L1Network *dsl.L1Network + L1EL *dsl.L1ELNode + L1CL *dsl.L1CLNode + + L2ChainA *dsl.L2Network + L2BatcherA *dsl.L2Batcher + L2ELA *dsl.L2ELNode + L2CLA *dsl.L2CLNode + + Wallet *dsl.HDWallet + + FaucetA *dsl.Faucet + FaucetL1 *dsl.Faucet + FunderL1 *dsl.Funder + FunderA *dsl.Funder + + // May be nil if not using sysgo + challengerConfig *challengerConfig.Config +} + +// NewSingleChainInterop creates a fresh SingleChainInterop target for the current test. +// +// The target is created from the single-chain interop runtime plus any additional preset options. +func NewSingleChainInterop(t devtest.T, opts ...Option) *SingleChainInterop { + presetCfg, presetOpts := collectSupportedPresetConfig(t, "NewSingleChainInterop", opts, singleChainInteropPresetSupportedOptionKinds) + out := singleChainInteropFromRuntime(t, sysgo.NewSingleChainInteropRuntimeWithConfig(t, presetCfg)) + presetOpts.applyPreset(out) + return out +} + +func (s *SingleChainInterop) L2Networks() []*dsl.L2Network { + return []*dsl.L2Network{ + s.L2ChainA, + } +} + +func (s *SingleChainInterop) DisputeGameFactory() *proofs.DisputeGameFactory { + s.T.Require().NotNil(s.SuperRoots, "supernode not configured for this preset") + return proofs.NewDisputeGameFactory(s.T, s.L1Network, s.L1EL.EthClient(), s.L2ChainA.DisputeGameFactoryProxyAddr(), nil, nil, s.SuperRoots, s.challengerConfig) +} + +func (s *SingleChainInterop) AdvanceTime(amount time.Duration) { + s.T.Require().NotNil(s.timeTravel, "attempting to advance time on incompatible system") + s.timeTravel.AdvanceTime(amount) +} + +func (s *SingleChainInterop) proofValidationContext() (devtest.T, *dsl.L1ELNode, []*dsl.L2Network) { + return s.T, s.L1EL, []*dsl.L2Network{s.L2ChainA} +} + +type SimpleInterop struct { + SingleChainInterop + + L2ChainB *dsl.L2Network + L2BatcherB *dsl.L2Batcher + L2ELB *dsl.L2ELNode + L2CLB *dsl.L2CLNode + + FaucetB *dsl.Faucet + FunderB *dsl.Funder +} + +func (s *SimpleInterop) L2Networks() []*dsl.L2Network { + return []*dsl.L2Network{ + s.L2ChainA, s.L2ChainB, + } +} + +func (s *SimpleInterop) proofValidationContext() (devtest.T, *dsl.L1ELNode, []*dsl.L2Network) { + return s.T, s.L1EL, s.L2Networks() +} + +func (s *SingleChainInterop) StandardBridge(l2Chain *dsl.L2Network) *dsl.StandardBridge { + return dsl.NewStandardBridge(s.T, l2Chain, s.L1EL) +} + +// NewSimpleInteropSuperProofs creates a fresh SimpleInterop target for the current test +// using the default super-root proofs system. +func NewSimpleInteropSuperProofs(t devtest.T, opts ...Option) *SimpleInterop { + presetCfg, _ := collectSupportedPresetConfig(t, "NewSimpleInteropSuperProofs", opts, simpleInteropSuperProofsPresetSupportedOptionKinds) + return simpleInteropFromRuntime(t, sysgo.NewSimpleInteropSuperProofsRuntimeWithConfig(t, presetCfg)) +} + +// NewSimpleInteropSupernodeProofs creates a fresh SimpleInterop target for the current +// test using the super-root proofs system backed by op-supernode. +func NewSimpleInteropSupernodeProofs(t devtest.T, opts ...Option) *SimpleInterop { + presetCfg, _ := collectSupportedPresetConfig(t, "NewSimpleInteropSupernodeProofs", opts, supernodeProofsPresetSupportedOptionKinds) + return simpleInteropFromSupernodeProofsRuntime(t, sysgo.NewTwoL2SupernodeProofsRuntimeWithConfig(t, true, presetCfg)) +} + +// NewSingleChainInteropSupernodeProofs creates a fresh SingleChainInterop target for the +// current test using the single-chain super-root proofs system backed by op-supernode. +func NewSingleChainInteropSupernodeProofs(t devtest.T, opts ...Option) *SingleChainInterop { + presetCfg, _ := collectSupportedPresetConfig(t, "NewSingleChainInteropSupernodeProofs", opts, supernodeProofsPresetSupportedOptionKinds) + return singleChainInteropFromSupernodeProofsRuntime(t, sysgo.NewSingleChainSupernodeProofsRuntimeWithConfig(t, true, presetCfg)) +} + +// NewSimpleInteropIsthmusSuper creates a fresh SimpleInterop target for the current test +// using the Isthmus super-root system backed by op-supernode. +func NewSimpleInteropIsthmusSuper(t devtest.T, opts ...Option) *SimpleInterop { + presetCfg, _ := collectSupportedPresetConfig(t, "NewSimpleInteropIsthmusSuper", opts, supernodeProofsPresetSupportedOptionKinds) + return simpleInteropFromSupernodeProofsRuntime(t, sysgo.NewTwoL2SupernodeProofsRuntimeWithConfig(t, false, presetCfg)) +} + +// NewSingleChainInteropIsthmusSuper creates a fresh SingleChainInterop target for the +// current test using the single-chain Isthmus super-root system backed by op-supernode. +func NewSingleChainInteropIsthmusSuper(t devtest.T, opts ...Option) *SingleChainInterop { + presetCfg, _ := collectSupportedPresetConfig(t, "NewSingleChainInteropIsthmusSuper", opts, supernodeProofsPresetSupportedOptionKinds) + return singleChainInteropFromSupernodeProofsRuntime(t, sysgo.NewSingleChainSupernodeProofsRuntimeWithConfig(t, false, presetCfg)) +} + +// NewSimpleInterop creates a fresh SimpleInterop target for the current test. +// +// The target is created from the interop runtime plus any additional preset options. +func NewSimpleInterop(t devtest.T, opts ...Option) *SimpleInterop { + presetCfg, presetOpts := collectSupportedPresetConfig(t, "NewSimpleInterop", opts, singleChainInteropPresetSupportedOptionKinds) + out := simpleInteropFromRuntime(t, sysgo.NewSimpleInteropRuntimeWithConfig(t, presetCfg)) + presetOpts.applyPreset(out) + return out +} + +// WithSuggestedInteropActivationOffset suggests a hardfork time offset to use. +// This is applied e.g. to the deployment if running against sysgo. +func WithSuggestedInteropActivationOffset(offset uint64) Option { + return WithDeployerOptions( + func(p devtest.T, keys devkeys.Keys, builder intentbuilder.Builder) { + for _, l2Cfg := range builder.L2s() { + l2Cfg.WithForkAtOffset(forks.Interop, &offset) + } + }, + ) +} + +// WithSequencingWindow suggests a sequencing window to use, and checks the maximum sequencing window. +// The sequencing windows are expressed in number of L1 execution-layer blocks till sequencing window expiry. +// This is applied to runtime deployment/config validation. +func WithSequencingWindow(suggestedSequencingWindow uint64, maxSequencingWindow uint64) Option { + return option{ + kinds: optionKindDeployer | optionKindMaxSequencingWindow, + applyFn: func(cfg *sysgo.PresetConfig) { + cfg.DeployerOptions = append(cfg.DeployerOptions, sysgo.WithSequencingWindow(suggestedSequencingWindow)) + v := maxSequencingWindow + cfg.MaxSequencingWindow = &v + }, + } +} + +// WithInteropNotAtGenesis adds a test-gate that checks +// if the interop hardfork is configured at a non-genesis time. +func WithInteropNotAtGenesis() Option { + return WithRequireInteropNotAtGenesis() +} + +type MultiSupervisorInterop struct { + SimpleInterop + + // Supervisor does not support multinode so need a additional supervisor for verifier nodes + SupervisorSecondary *dsl.Supervisor + + L2ELA2 *dsl.L2ELNode + L2CLA2 *dsl.L2CLNode + L2ELB2 *dsl.L2ELNode + L2CLB2 *dsl.L2CLNode +} + +// NewMultiSupervisorInterop initializes a fresh multi-supervisor interop target for the +// current test. +func NewMultiSupervisorInterop(t devtest.T, opts ...Option) *MultiSupervisorInterop { + _, _ = collectSupportedPresetConfig(t, "NewMultiSupervisorInterop", opts, 0) + return multiSupervisorInteropFromRuntime(t, sysgo.NewMultiSupervisorInteropRuntime(t)) +} + +// MinimalInteropNoSupervisor is like Minimal but with interop contracts deployed. +// No supervisor is running - this tests interop contract deployment with local finality. +type MinimalInteropNoSupervisor struct { + Minimal +} + +// NewMinimalInteropNoSupervisor creates a fresh MinimalInteropNoSupervisor target for the +// current test. +func NewMinimalInteropNoSupervisor(t devtest.T, opts ...Option) *MinimalInteropNoSupervisor { + _, _ = collectSupportedPresetConfig(t, "NewMinimalInteropNoSupervisor", opts, 0) + return &MinimalInteropNoSupervisor{ + Minimal: *minimalFromRuntime(t, sysgo.NewMinimalInteropNoSupervisorRuntime(t)), + } +} diff --git a/op-devstack/presets/interop_from_runtime.go b/op-devstack/presets/interop_from_runtime.go new file mode 100644 index 00000000000..c9e917ce091 --- /dev/null +++ b/op-devstack/presets/interop_from_runtime.go @@ -0,0 +1,213 @@ +package presets + +import ( + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" +) + +func singleChainInteropFromRuntime(t devtest.T, runtime *sysgo.MultiChainRuntime) *SingleChainInterop { + chainA := runtime.Chains["l2a"] + t.Require().NotNil(chainA, "missing l2a interop chain") + l1ChainID := runtime.L1Network.ChainID() + l2ChainID := chainA.Network.ChainID() + + l1Network := newPresetL1Network(t, "l1", runtime.L1Network.ChainConfig()) + l1EL := newL1ELFrontend(t, "l1", l1ChainID, runtime.L1EL.UserRPC()) + l1CL := newL1CLFrontend(t, "l1", l1ChainID, runtime.L1CL.BeaconHTTPAddr(), runtime.L1CL.FakePoS()) + l1Network.AddL1ELNode(l1EL) + l1Network.AddL1CLNode(l1CL) + + l2Chain := newPresetL2Network( + t, + "l2a", + chainA.Network.ChainConfig(), + chainA.Network.RollupConfig(), + chainA.Network.Deployment(), + newKeyring(runtime.Keys, t.Require()), + l1Network, + ) + + l2EL := newL2ELFrontend( + t, + "sequencer", + l2ChainID, + chainA.EL.UserRPC(), + chainA.EL.EngineRPC(), + chainA.EL.JWTPath(), + chainA.Network.RollupConfig(), + chainA.EL, + ) + l2CL := newL2CLFrontend( + t, + "sequencer", + l2ChainID, + chainA.CL.UserRPC(), + chainA.CL, + ) + l2CL.attachEL(l2EL) + l2Batcher := newL2BatcherFrontend(t, "main", l2ChainID, chainA.Batcher.UserRPC()) + l2Chain.AddL2ELNode(l2EL) + l2Chain.AddL2CLNode(l2CL) + l2Chain.AddL2Batcher(l2Batcher) + + supervisor := newSupervisorFrontend(t, "1-primary", runtime.PrimarySupervisor.UserRPC(), runtime.PrimarySupervisor) + testSequencer := newTestSequencerFrontend( + t, + runtime.TestSequencer.Name, + runtime.TestSequencer.AdminRPC, + runtime.TestSequencer.ControlRPC, + runtime.TestSequencer.JWTSecret, + ) + l1ELDSL := dsl.NewL1ELNode(l1EL) + l1CLDSL := dsl.NewL1CLNode(l1CL) + l2ELDSL := dsl.NewL2ELNode(l2EL) + l2CLDSL := dsl.NewL2CLNode(l2CL) + + faucetAFrontend := newFaucetFrontendForChain(t, runtime.FaucetService, l2ChainID) + faucetL1Frontend := newFaucetFrontendForChain(t, runtime.FaucetService, l1ChainID) + out := &SingleChainInterop{ + Log: t.Logger(), + T: t, + timeTravel: runtime.TimeTravel, + Supervisor: dsl.NewSupervisor(supervisor), + SuperRoots: nil, + TestSequencer: dsl.NewTestSequencer(testSequencer), + L1Network: dsl.NewL1Network(l1Network, l1ELDSL, l1CLDSL), + L1EL: l1ELDSL, + L1CL: l1CLDSL, + L2ChainA: dsl.NewL2Network(l2Chain, l2ELDSL, l2CLDSL, l1ELDSL, nil, nil), + L2BatcherA: dsl.NewL2Batcher(l2Batcher), + L2ELA: l2ELDSL, + L2CLA: l2CLDSL, + Wallet: dsl.NewRandomHDWallet(t, 30), + FaucetA: dsl.NewFaucet(faucetAFrontend), + FaucetL1: dsl.NewFaucet(faucetL1Frontend), + challengerConfig: runtime.L2ChallengerConfig, + } + l1Network.AddFaucet(faucetL1Frontend) + l2Chain.AddFaucet(faucetAFrontend) + out.FunderL1 = dsl.NewFunder(out.Wallet, out.FaucetL1, out.L1EL) + out.FunderA = dsl.NewFunder(out.Wallet, out.FaucetA, out.L2ELA) + return out +} + +func simpleInteropFromRuntime(t devtest.T, runtime *sysgo.MultiChainRuntime) *SimpleInterop { + singleChain := singleChainInteropFromRuntime(t, runtime) + chainB := runtime.Chains["l2b"] + t.Require().NotNil(chainB, "missing l2b interop chain") + l2BChainID := chainB.Network.ChainID() + + l1Network, ok := singleChain.L1Network.Escape().(*presetL1Network) + t.Require().True(ok, "expected preset L1 network") + + l2B := newPresetL2Network( + t, + "l2b", + chainB.Network.ChainConfig(), + chainB.Network.RollupConfig(), + chainB.Network.Deployment(), + newKeyring(runtime.Keys, t.Require()), + l1Network, + ) + + l2BEL := newL2ELFrontend( + t, + "sequencer", + l2BChainID, + chainB.EL.UserRPC(), + chainB.EL.EngineRPC(), + chainB.EL.JWTPath(), + chainB.Network.RollupConfig(), + chainB.EL, + ) + l2BCL := newL2CLFrontend(t, "sequencer", l2BChainID, chainB.CL.UserRPC(), chainB.CL) + l2BCL.attachEL(l2BEL) + l2BBatcher := newL2BatcherFrontend(t, "main", l2BChainID, chainB.Batcher.UserRPC()) + l2B.AddL2ELNode(l2BEL) + l2B.AddL2CLNode(l2BCL) + l2B.AddL2Batcher(l2BBatcher) + + l2BELDSL := dsl.NewL2ELNode(l2BEL) + l2BCLDSL := dsl.NewL2CLNode(l2BCL) + + faucetBFrontend := newFaucetFrontendForChain(t, runtime.FaucetService, l2BChainID) + out := &SimpleInterop{ + SingleChainInterop: *singleChain, + L2ChainB: dsl.NewL2Network(l2B, l2BELDSL, l2BCLDSL, singleChain.L1EL, nil, nil), + L2BatcherB: dsl.NewL2Batcher(l2BBatcher), + L2ELB: l2BELDSL, + L2CLB: l2BCLDSL, + FaucetB: dsl.NewFaucet(faucetBFrontend), + } + l2B.AddFaucet(faucetBFrontend) + out.FunderB = dsl.NewFunder(out.Wallet, out.FaucetB, out.L2ELB) + return out +} + +func multiSupervisorInteropFromRuntime(t devtest.T, runtime *sysgo.MultiChainRuntime) *MultiSupervisorInterop { + simpleInterop := simpleInteropFromRuntime(t, runtime) + chainA := runtime.Chains["l2a"] + chainB := runtime.Chains["l2b"] + t.Require().NotNil(chainA, "missing l2a interop chain") + t.Require().NotNil(chainB, "missing l2b interop chain") + t.Require().NotNil(chainA.Followers, "missing l2a followers") + t.Require().NotNil(chainB.Followers, "missing l2b followers") + l2A2 := chainA.Followers["verifier"] + l2B2 := chainB.Followers["verifier"] + t.Require().NotNil(l2A2, "missing l2a verifier follower") + t.Require().NotNil(l2B2, "missing l2b verifier follower") + l2AChainID := chainA.Network.ChainID() + l2BChainID := chainB.Network.ChainID() + + l2ELA2 := newL2ELFrontend( + t, + "verifier", + l2AChainID, + l2A2.EL.UserRPC(), + l2A2.EL.EngineRPC(), + l2A2.EL.JWTPath(), + chainA.Network.RollupConfig(), + l2A2.EL, + ) + l2CLA2 := newL2CLFrontend(t, "verifier", l2AChainID, l2A2.CL.UserRPC(), l2A2.CL) + l2CLA2.attachEL(l2ELA2) + + l2ELB2 := newL2ELFrontend( + t, + "verifier", + l2BChainID, + l2B2.EL.UserRPC(), + l2B2.EL.EngineRPC(), + l2B2.EL.JWTPath(), + chainB.Network.RollupConfig(), + l2B2.EL, + ) + l2CLB2 := newL2CLFrontend(t, "verifier", l2BChainID, l2B2.CL.UserRPC(), l2B2.CL) + l2CLB2.attachEL(l2ELB2) + + l2ANet, ok := simpleInterop.L2ChainA.Escape().(*presetL2Network) + t.Require().True(ok, "expected preset L2 network A") + l2ANet.AddL2ELNode(l2ELA2) + l2ANet.AddL2CLNode(l2CLA2) + l2BNet, ok := simpleInterop.L2ChainB.Escape().(*presetL2Network) + t.Require().True(ok, "expected preset L2 network B") + l2BNet.AddL2ELNode(l2ELB2) + l2BNet.AddL2CLNode(l2CLB2) + + supervisorSecondary := newSupervisorFrontend( + t, + "2-secondary", + runtime.SecondarySupervisor.UserRPC(), + runtime.SecondarySupervisor, + ) + + return &MultiSupervisorInterop{ + SimpleInterop: *simpleInterop, + SupervisorSecondary: dsl.NewSupervisor(supervisorSecondary), + L2ELA2: dsl.NewL2ELNode(l2ELA2), + L2CLA2: dsl.NewL2CLNode(l2CLA2), + L2ELB2: dsl.NewL2ELNode(l2ELB2), + L2CLB2: dsl.NewL2CLNode(l2CLB2), + } +} diff --git a/op-devstack/presets/minimal.go b/op-devstack/presets/minimal.go new file mode 100644 index 00000000000..0ad50bbe418 --- /dev/null +++ b/op-devstack/presets/minimal.go @@ -0,0 +1,72 @@ +package presets + +import ( + "time" + + "github.com/ethereum/go-ethereum/log" + + challengerConfig "github.com/ethereum-optimism/optimism/op-challenger/config" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/dsl/proofs" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/ethereum-optimism/optimism/op-service/clock" +) + +type Minimal struct { + Log log.Logger + T devtest.T + timeTravel *clock.AdvancingClock + + L1Network *dsl.L1Network + L1EL *dsl.L1ELNode + L1CL *dsl.L1CLNode + + L2Chain *dsl.L2Network + L2Batcher *dsl.L2Batcher + L2EL *dsl.L2ELNode + L2CL *dsl.L2CLNode + + Wallet *dsl.HDWallet + + FaucetL1 *dsl.Faucet + FaucetL2 *dsl.Faucet + FunderL1 *dsl.Funder + FunderL2 *dsl.Funder + + // May be nil if not using sysgo + challengerConfig *challengerConfig.Config +} + +func (m *Minimal) L2Networks() []*dsl.L2Network { + return []*dsl.L2Network{ + m.L2Chain, + } +} + +func (m *Minimal) StandardBridge() *dsl.StandardBridge { + return dsl.NewStandardBridge(m.T, m.L2Chain, m.L1EL) +} + +func (m *Minimal) DisputeGameFactory() *proofs.DisputeGameFactory { + return proofs.NewDisputeGameFactory(m.T, m.L1Network, m.L1EL.EthClient(), m.L2Chain.DisputeGameFactoryProxyAddr(), m.L2CL, m.L2EL, nil, m.challengerConfig) +} + +func (m *Minimal) AdvanceTime(amount time.Duration) { + m.T.Require().NotNil(m.timeTravel, "attempting to advance time on incompatible system") + m.timeTravel.AdvanceTime(amount) +} + +func (m *Minimal) proofValidationContext() (devtest.T, *dsl.L1ELNode, []*dsl.L2Network) { + return m.T, m.L1EL, m.L2Networks() +} + +// NewMinimal creates a fresh Minimal target for the current test. +// +// The target is created from the minimal runtime plus any additional preset options. +func NewMinimal(t devtest.T, opts ...Option) *Minimal { + presetCfg, presetOpts := collectSupportedPresetConfig(t, "NewMinimal", opts, minimalPresetSupportedOptionKinds) + out := minimalFromRuntime(t, sysgo.NewMinimalRuntimeWithConfig(t, presetCfg)) + presetOpts.applyPreset(out) + return out +} diff --git a/op-devstack/presets/minimal_from_runtime.go b/op-devstack/presets/minimal_from_runtime.go new file mode 100644 index 00000000000..0b8a3512d1b --- /dev/null +++ b/op-devstack/presets/minimal_from_runtime.go @@ -0,0 +1,76 @@ +package presets + +import ( + challengerConfig "github.com/ethereum-optimism/optimism/op-challenger/config" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" +) + +func minimalFromRuntime(t devtest.T, runtime *sysgo.SingleChainRuntime) *Minimal { + l1ChainID := runtime.L1Network.ChainID() + l2ChainID := runtime.L2Network.ChainID() + + l1Network := newPresetL1Network(t, "l1", runtime.L1Network.ChainConfig()) + l1EL := newL1ELFrontend(t, "l1", l1ChainID, runtime.L1EL.UserRPC()) + l1CL := newL1CLFrontend(t, "l1", l1ChainID, runtime.L1CL.BeaconHTTPAddr(), runtime.L1CL.FakePoS()) + l1Network.AddL1ELNode(l1EL) + l1Network.AddL1CLNode(l1CL) + + l2Chain := newPresetL2Network( + t, + "l2a", + runtime.L2Network.ChainConfig(), + runtime.L2Network.RollupConfig(), + runtime.L2Network.Deployment(), + newKeyring(runtime.Keys, t.Require()), + l1Network, + ) + l2EL := newL2ELFrontend(t, "sequencer", l2ChainID, runtime.L2EL.UserRPC(), runtime.L2EL.EngineRPC(), runtime.L2EL.JWTPath(), runtime.L2Network.RollupConfig()) + l2CL := newL2CLFrontend(t, "sequencer", l2ChainID, runtime.L2CL.UserRPC(), runtime.L2CL) + l2CL.attachEL(l2EL) + l2Batcher := newL2BatcherFrontend(t, "main", l2ChainID, runtime.L2Batcher.UserRPC()) + l2Chain.AddL2ELNode(l2EL) + l2Chain.AddL2CLNode(l2CL) + l2Chain.AddL2Batcher(l2Batcher) + + var challengerCfg *challengerConfig.Config + if runtime.L2Challenger != nil { + challengerCfg = runtime.L2Challenger.Config() + } + if challengerCfg != nil { + l2Chain.AddL2Challenger(newPresetL2Challenger(t, "main", l2ChainID, challengerCfg)) + } + + faucetL1Frontend := newFaucetFrontendForChain(t, runtime.FaucetService, l1ChainID) + faucetL2Frontend := newFaucetFrontendForChain(t, runtime.FaucetService, l2ChainID) + l1Network.AddFaucet(faucetL1Frontend) + l2Chain.AddFaucet(faucetL2Frontend) + faucetL1 := dsl.NewFaucet(faucetL1Frontend) + faucetL2 := dsl.NewFaucet(faucetL2Frontend) + + l1ELDSL := dsl.NewL1ELNode(l1EL) + l1CLDSL := dsl.NewL1CLNode(l1CL) + l2ELDSL := dsl.NewL2ELNode(l2EL) + l2CLDSL := dsl.NewL2CLNode(l2CL) + + out := &Minimal{ + Log: t.Logger(), + T: t, + timeTravel: runtime.TimeTravel, + L1Network: dsl.NewL1Network(l1Network, l1ELDSL, l1CLDSL), + L1EL: l1ELDSL, + L1CL: l1CLDSL, + L2Chain: dsl.NewL2Network(l2Chain, l2ELDSL, l2CLDSL, l1ELDSL, nil, nil), + L2Batcher: dsl.NewL2Batcher(l2Batcher), + L2EL: l2ELDSL, + L2CL: l2CLDSL, + Wallet: dsl.NewRandomHDWallet(t, 30), // Random for test isolation + FaucetL1: faucetL1, + FaucetL2: faucetL2, + challengerConfig: challengerCfg, + } + out.FunderL1 = dsl.NewFunder(out.Wallet, out.FaucetL1, out.L1EL) + out.FunderL2 = dsl.NewFunder(out.Wallet, out.FaucetL2, out.L2EL) + return out +} diff --git a/op-devstack/presets/minimal_with_conductors.go b/op-devstack/presets/minimal_with_conductors.go new file mode 100644 index 00000000000..cab721da079 --- /dev/null +++ b/op-devstack/presets/minimal_with_conductors.go @@ -0,0 +1,25 @@ +package presets + +import ( + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +type MinimalWithConductors struct { + *Minimal + + ConductorSets map[eth.ChainID]dsl.ConductorSet +} + +// NewMinimalWithConductors creates a fresh MinimalWithConductors target for the current +// test. +// +// The target is created from the runtime plus any additional preset options. +func NewMinimalWithConductors(t devtest.T, opts ...Option) *MinimalWithConductors { + presetCfg, presetOpts := collectSupportedPresetConfig(t, "NewMinimalWithConductors", opts, minimalWithConductorsPresetSupportedOptionKinds) + out := minimalWithConductorsFromRuntime(t, sysgo.NewMinimalWithConductorsRuntimeWithConfig(t, presetCfg)) + presetOpts.applyPreset(out) + return out +} diff --git a/op-devstack/presets/mixed_frontends.go b/op-devstack/presets/mixed_frontends.go new file mode 100644 index 00000000000..c2a969e6cc7 --- /dev/null +++ b/op-devstack/presets/mixed_frontends.go @@ -0,0 +1,132 @@ +package presets + +import ( + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/ethereum-optimism/optimism/op-service/client" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +type MixedSingleChainNodeFrontends struct { + Spec sysgo.MixedSingleChainNodeSpec + EL *dsl.L2ELNode + CL *dsl.L2CLNode +} + +type MixedSingleChainFrontends struct { + L1Network *dsl.L1Network + L1EL *dsl.L1ELNode + L1CL *dsl.L1CLNode + L2Network *dsl.L2Network + L2Batcher *dsl.L2Batcher + FaucetL1 *dsl.Faucet + FaucetL2 *dsl.Faucet + TestSequencer *dsl.TestSequencer + Nodes []MixedSingleChainNodeFrontends +} + +func newFaucetFrontendByName(t devtest.T, name string, chainID eth.ChainID, faucetRPC string) *faucetFrontend { + rpcCl, err := client.NewRPC(t.Ctx(), t.Logger(), faucetRPC, client.WithLazyDial()) + t.Require().NoError(err) + t.Cleanup(rpcCl.Close) + + return newPresetFaucet(t, name, chainID, rpcCl) +} + +func NewMixedSingleChainFrontends(t devtest.T, runtime *sysgo.MixedSingleChainRuntime) *MixedSingleChainFrontends { + l1Backend := runtime.L1Network + l2Backend := runtime.L2Network + l1ChainID := eth.ChainIDFromBig(l1Backend.ChainConfig().ChainID) + l2ChainID := eth.ChainIDFromBig(l2Backend.ChainConfig().ChainID) + + l1Network := newPresetL1Network(t, "l1", l1Backend.ChainConfig()) + l1EL := newL1ELFrontend(t, "l1", l1ChainID, runtime.L1EL.UserRPC()) + l1CL := newL1CLFrontend(t, "l1", l1ChainID, runtime.L1CL.BeaconHTTPAddr(), runtime.L1CL.FakePoS()) + l1Network.AddL1ELNode(l1EL) + l1Network.AddL1CLNode(l1CL) + + l2Network := newPresetL2Network( + t, + "l2a", + l2Backend.ChainConfig(), + l2Backend.RollupConfig(), + l2Backend.Deployment(), + newKeyring(l2Backend.Keys(), t.Require()), + l1Network, + ) + l2BatcherBackend := runtime.L2Batcher + l2Batcher := newL2BatcherFrontend(t, "main", l2ChainID, l2BatcherBackend.UserRPC()) + l2Network.AddL2Batcher(l2Batcher) + + l1ELDSL := dsl.NewL1ELNode(l1EL) + l1CLDSL := dsl.NewL1CLNode(l1CL) + + nodes := make([]MixedSingleChainNodeFrontends, 0, len(runtime.Nodes)) + var primaryL2EL *dsl.L2ELNode + var primaryL2CL *dsl.L2CLNode + for _, node := range runtime.Nodes { + l2EL := newL2ELFrontend( + t, + node.Spec.ELKey, + l2ChainID, + node.EL.UserRPC(), + node.EL.EngineRPC(), + node.EL.JWTPath(), + l2Backend.RollupConfig(), + node.EL, + ) + l2CL := newL2CLFrontend(t, node.Spec.CLKey, l2ChainID, node.CL.UserRPC(), node.CL) + l2CL.attachEL(l2EL) + l2Network.AddL2ELNode(l2EL) + l2Network.AddL2CLNode(l2CL) + l2ELDSL := dsl.NewL2ELNode(l2EL) + l2CLDSL := dsl.NewL2CLNode(l2CL) + if primaryL2EL == nil && node.Spec.IsSequencer { + primaryL2EL = l2ELDSL + primaryL2CL = l2CLDSL + } + nodes = append(nodes, MixedSingleChainNodeFrontends{ + Spec: node.Spec, + EL: l2ELDSL, + CL: l2CLDSL, + }) + } + t.Require().NotNil(primaryL2EL, "missing primary mixed L2 EL") + t.Require().NotNil(primaryL2CL, "missing primary mixed L2 CL") + + l1FaucetName, l1FaucetRPC, ok := defaultFaucetForChain(runtime.FaucetService, l1ChainID) + t.Require().Truef(ok, "missing default faucet for chain %s", l1ChainID) + l2FaucetName, l2FaucetRPC, ok := defaultFaucetForChain(runtime.FaucetService, l2ChainID) + t.Require().Truef(ok, "missing default faucet for chain %s", l2ChainID) + faucetL1Frontend := newFaucetFrontendByName(t, l1FaucetName, l1ChainID, l1FaucetRPC) + faucetL2Frontend := newFaucetFrontendByName(t, l2FaucetName, l2ChainID, l2FaucetRPC) + l1Network.AddFaucet(faucetL1Frontend) + l2Network.AddFaucet(faucetL2Frontend) + faucetL1 := dsl.NewFaucet(faucetL1Frontend) + faucetL2 := dsl.NewFaucet(faucetL2Frontend) + + var testSequencer *dsl.TestSequencer + if backend := runtime.TestSequencer; backend != nil { + t.Require().NotEmpty(backend.Name, "expected test sequencer name") + testSequencer = dsl.NewTestSequencer(newTestSequencerFrontend( + t, + backend.Name, + backend.AdminRPC, + backend.ControlRPC, + backend.JWTSecret, + )) + } + + return &MixedSingleChainFrontends{ + L1Network: dsl.NewL1Network(l1Network, l1ELDSL, l1CLDSL), + L1EL: l1ELDSL, + L1CL: l1CLDSL, + L2Network: dsl.NewL2Network(l2Network, primaryL2EL, primaryL2CL, l1ELDSL, nil, nil), + L2Batcher: dsl.NewL2Batcher(l2Batcher), + FaucetL1: faucetL1, + FaucetL2: faucetL2, + TestSequencer: testSequencer, + Nodes: nodes, + } +} diff --git a/op-devstack/presets/networks.go b/op-devstack/presets/networks.go new file mode 100644 index 00000000000..2467fd230bb --- /dev/null +++ b/op-devstack/presets/networks.go @@ -0,0 +1,328 @@ +package presets + +import ( + "slices" + "sort" + + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/params" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/locks" + "github.com/ethereum-optimism/optimism/op-service/testreq" +) + +type presetCommon struct { + log log.Logger + t devtest.T + req *testreq.Assertions + labels *locks.RWMap[string, string] + name string +} + +func newPresetCommon(t devtest.T, name string) presetCommon { + return presetCommon{ + log: t.Logger(), + t: t, + req: t.Require(), + labels: new(locks.RWMap[string, string]), + name: name, + } +} + +func (c *presetCommon) T() devtest.T { + return c.t +} + +func (c *presetCommon) Logger() log.Logger { + return c.log +} + +func (c *presetCommon) Name() string { + return c.name +} + +func (c *presetCommon) Label(key string) string { + out, _ := c.labels.Get(key) + return out +} + +func (c *presetCommon) SetLabel(key, value string) { + c.labels.Set(key, value) +} + +func (c *presetCommon) require() *testreq.Assertions { + return c.req +} + +type presetNetworkBase struct { + presetCommon + chainCfg *params.ChainConfig + chainID eth.ChainID + + faucets []*faucetFrontend + syncTesters []*syncTesterFrontend +} + +func (n *presetNetworkBase) ChainID() eth.ChainID { + return n.chainID +} + +func (n *presetNetworkBase) ChainConfig() *params.ChainConfig { + return n.chainCfg +} + +func (n *presetNetworkBase) Faucets() []stack.Faucet { + return mapSlice(sortByNameFunc(n.faucets), func(v *faucetFrontend) stack.Faucet { return v }) +} + +func (n *presetNetworkBase) AddFaucet(v *faucetFrontend) { + n.require().Equal(n.chainID, v.ChainID(), "faucet %s must be on chain %s", v.Name(), n.chainID) + _, exists := componentByName(n.faucets, v.Name()) + n.require().False(exists, "faucet %s must not already exist", v.Name()) + n.faucets = append(n.faucets, v) +} + +func (n *presetNetworkBase) SyncTesters() []stack.SyncTester { + return mapSlice(sortByNameFunc(n.syncTesters), func(v *syncTesterFrontend) stack.SyncTester { return v }) +} + +func (n *presetNetworkBase) AddSyncTester(v *syncTesterFrontend) { + n.require().Equal(n.chainID, v.ChainID(), "sync tester %s must be on chain %s", v.Name(), n.chainID) + _, exists := componentByName(n.syncTesters, v.Name()) + n.require().False(exists, "sync tester %s must not already exist", v.Name()) + n.syncTesters = append(n.syncTesters, v) +} + +type presetL1Network struct { + presetNetworkBase + + l1ELNodes []*l1ELFrontend + l1CLNodes []*l1CLFrontend +} + +var _ stack.L1Network = (*presetL1Network)(nil) + +func newPresetL1Network(t devtest.T, name string, chainCfg *params.ChainConfig) *presetL1Network { + chainID := eth.ChainIDFromBig(chainCfg.ChainID) + t = t.WithCtx(stack.ContextWithChainID(t.Ctx(), chainID)) + t.Require().NotEmpty(name, "l1 network name must not be empty") + return &presetL1Network{ + presetNetworkBase: presetNetworkBase{ + presetCommon: newPresetCommon(t, name), + chainCfg: chainCfg, + chainID: chainID, + }, + } +} + +func (n *presetL1Network) AddL1ELNode(v *l1ELFrontend) { + n.require().Equal(n.chainID, v.ChainID(), "l1 EL node %s must be on chain %s", v.Name(), n.chainID) + _, exists := componentByName(n.l1ELNodes, v.Name()) + n.require().False(exists, "l1 EL node %s must not already exist", v.Name()) + n.l1ELNodes = append(n.l1ELNodes, v) +} + +func (n *presetL1Network) AddL1CLNode(v *l1CLFrontend) { + n.require().Equal(n.chainID, v.ChainID(), "l1 CL node %s must be on chain %s", v.Name(), n.chainID) + _, exists := componentByName(n.l1CLNodes, v.Name()) + n.require().False(exists, "l1 CL node %s must not already exist", v.Name()) + n.l1CLNodes = append(n.l1CLNodes, v) +} + +func (n *presetL1Network) L1ELNodes() []stack.L1ELNode { + return mapSlice(sortByNameFunc(n.l1ELNodes), func(v *l1ELFrontend) stack.L1ELNode { return v }) +} + +func (n *presetL1Network) L1CLNodes() []stack.L1CLNode { + return mapSlice(sortByNameFunc(n.l1CLNodes), func(v *l1CLFrontend) stack.L1CLNode { return v }) +} + +type presetL2Network struct { + presetNetworkBase + + rollupCfg *rollup.Config + deployment stack.L2Deployment + keys *keyringImpl + + l1 *presetL1Network + + l2Batchers []*l2BatcherFrontend + l2Proposers []*l2ProposerFrontend + l2Challengers []*l2ChallengerFrontend + l2CLNodes []*l2CLFrontend + l2ELNodes []*l2ELFrontend + conductors []*conductorFrontend + rollupBoostNodes []*rollupBoostFrontend + oprBuilderNodes []*oprBuilderFrontend +} + +var _ stack.L2Network = (*presetL2Network)(nil) + +func newPresetL2Network( + t devtest.T, + name string, + chainCfg *params.ChainConfig, + rollupCfg *rollup.Config, + deployment stack.L2Deployment, + keys *keyringImpl, + l1 *presetL1Network, +) *presetL2Network { + chainID := eth.ChainIDFromBig(chainCfg.ChainID) + t = t.WithCtx(stack.ContextWithChainID(t.Ctx(), chainID)) + t.Require().NotEmpty(name, "l2 network name must not be empty") + t.Require().Equal(l1.ChainID(), eth.ChainIDFromBig(rollupCfg.L1ChainID), "rollup config must match expected L1 chain") + t.Require().Equal(chainID, eth.ChainIDFromBig(rollupCfg.L2ChainID), "rollup config must match expected L2 chain") + return &presetL2Network{ + presetNetworkBase: presetNetworkBase{ + presetCommon: newPresetCommon(t, name), + chainCfg: chainCfg, + chainID: chainID, + }, + rollupCfg: rollupCfg, + deployment: deployment, + keys: keys, + l1: l1, + } +} + +func (n *presetL2Network) RollupConfig() *rollup.Config { + n.require().NotNil(n.rollupCfg, "l2 chain %s must have a rollup config", n.Name()) + return n.rollupCfg +} + +func (n *presetL2Network) Deployment() stack.L2Deployment { + n.require().NotNil(n.deployment, "l2 chain %s must have a deployment", n.Name()) + return n.deployment +} + +func (n *presetL2Network) Keys() stack.Keys { + n.require().NotNil(n.keys, "l2 chain %s must have keys", n.Name()) + return n.keys +} + +func (n *presetL2Network) L1() stack.L1Network { + n.require().NotNil(n.l1, "l2 chain %s must have an L1 chain", n.Name()) + return n.l1 +} + +func (n *presetL2Network) AddL2Batcher(v *l2BatcherFrontend) { + n.require().Equal(n.chainID, v.ChainID(), "l2 batcher %s must be on chain %s", v.Name(), n.chainID) + _, exists := componentByName(n.l2Batchers, v.Name()) + n.require().False(exists, "l2 batcher %s must not already exist", v.Name()) + n.l2Batchers = append(n.l2Batchers, v) +} + +func (n *presetL2Network) AddL2Proposer(v *l2ProposerFrontend) { + n.require().Equal(n.chainID, v.ChainID(), "l2 proposer %s must be on chain %s", v.Name(), n.chainID) + _, exists := componentByName(n.l2Proposers, v.Name()) + n.require().False(exists, "l2 proposer %s must not already exist", v.Name()) + n.l2Proposers = append(n.l2Proposers, v) +} + +func (n *presetL2Network) AddL2Challenger(v *l2ChallengerFrontend) { + n.require().Equal(n.chainID, v.ChainID(), "l2 challenger %s must be on chain %s", v.Name(), n.chainID) + _, exists := componentByName(n.l2Challengers, v.Name()) + n.require().False(exists, "l2 challenger %s must not already exist", v.Name()) + n.l2Challengers = append(n.l2Challengers, v) +} + +func (n *presetL2Network) AddL2CLNode(v *l2CLFrontend) { + n.require().Equal(n.chainID, v.ChainID(), "l2 CL node %s must be on chain %s", v.Name(), n.chainID) + _, exists := componentByName(n.l2CLNodes, v.Name()) + n.require().False(exists, "l2 CL node %s must not already exist", v.Name()) + n.l2CLNodes = append(n.l2CLNodes, v) +} + +func (n *presetL2Network) AddL2ELNode(v *l2ELFrontend) { + n.require().Equal(n.chainID, v.ChainID(), "l2 EL node %s must be on chain %s", v.Name(), n.chainID) + _, exists := componentByName(n.l2ELNodes, v.Name()) + n.require().False(exists, "l2 EL node %s must not already exist", v.Name()) + n.l2ELNodes = append(n.l2ELNodes, v) +} + +func (n *presetL2Network) AddConductor(v *conductorFrontend) { + n.require().Equal(n.chainID, v.ChainID(), "conductor %s must be on chain %s", v.Name(), n.chainID) + _, exists := componentByName(n.conductors, v.Name()) + n.require().False(exists, "conductor %s must not already exist", v.Name()) + n.conductors = append(n.conductors, v) +} + +func (n *presetL2Network) AddRollupBoostNode(v *rollupBoostFrontend) { + n.require().Equal(n.chainID, v.ChainID(), "rollup boost node %s must be on chain %s", v.Name(), n.chainID) + _, exists := componentByName(n.rollupBoostNodes, v.Name()) + n.require().False(exists, "rollup boost node %s must not already exist", v.Name()) + n.rollupBoostNodes = append(n.rollupBoostNodes, v) +} + +func (n *presetL2Network) AddOPRBuilderNode(v *oprBuilderFrontend) { + n.require().Equal(n.chainID, v.ChainID(), "OPR builder node %s must be on chain %s", v.Name(), n.chainID) + _, exists := componentByName(n.oprBuilderNodes, v.Name()) + n.require().False(exists, "OPR builder node %s must not already exist", v.Name()) + n.oprBuilderNodes = append(n.oprBuilderNodes, v) +} + +func (n *presetL2Network) L2Batchers() []stack.L2Batcher { + return mapSlice(sortByNameFunc(n.l2Batchers), func(v *l2BatcherFrontend) stack.L2Batcher { return v }) +} + +func (n *presetL2Network) L2Proposers() []stack.L2Proposer { + return mapSlice(sortByNameFunc(n.l2Proposers), func(v *l2ProposerFrontend) stack.L2Proposer { return v }) +} + +func (n *presetL2Network) L2Challengers() []stack.L2Challenger { + return mapSlice(sortByNameFunc(n.l2Challengers), func(v *l2ChallengerFrontend) stack.L2Challenger { return v }) +} + +func (n *presetL2Network) L2CLNodes() []stack.L2CLNode { + return mapSlice(sortByNameFunc(n.l2CLNodes), func(v *l2CLFrontend) stack.L2CLNode { return v }) +} + +func (n *presetL2Network) L2ELNodes() []stack.L2ELNode { + return mapSlice(sortByNameFunc(n.l2ELNodes), func(v *l2ELFrontend) stack.L2ELNode { return v }) +} + +func (n *presetL2Network) Conductors() []stack.Conductor { + return mapSlice(sortByNameFunc(n.conductors), func(v *conductorFrontend) stack.Conductor { return v }) +} + +func (n *presetL2Network) RollupBoostNodes() []stack.RollupBoostNode { + return mapSlice(sortByNameFunc(n.rollupBoostNodes), func(v *rollupBoostFrontend) stack.RollupBoostNode { return v }) +} + +func (n *presetL2Network) OPRBuilderNodes() []stack.OPRBuilderNode { + return mapSlice(sortByNameFunc(n.oprBuilderNodes), func(v *oprBuilderFrontend) stack.OPRBuilderNode { return v }) +} + +type named interface { + Name() string +} + +func componentByName[T named](components []T, name string) (T, bool) { + for _, component := range components { + if component.Name() == name { + return component, true + } + } + var zero T + return zero, false +} + +func sortByNameFunc[T named](components []T) []T { + out := slices.Clone(components) + sort.Slice(out, func(i, j int) bool { + return out[i].Name() < out[j].Name() + }) + return out +} + +func mapSlice[T any, U any](items []T, mapFn func(T) U) []U { + out := make([]U, len(items)) + for i, item := range items { + out[i] = mapFn(item) + } + return out +} diff --git a/op-devstack/presets/option_validation.go b/op-devstack/presets/option_validation.go new file mode 100644 index 00000000000..793229dcd55 --- /dev/null +++ b/op-devstack/presets/option_validation.go @@ -0,0 +1,161 @@ +package presets + +import ( + "fmt" + "strings" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" +) + +type optionKinds uint64 + +const ( + optionKindDeployer optionKinds = 1 << iota + optionKindBatcher + optionKindProposer + optionKindOPRBuilder + optionKindGlobalL2CL + optionKindGlobalSyncTesterEL + optionKindL1EL + optionKindAddedGameType + optionKindRespectedGameType + optionKindChallengerCannonKona + optionKindTimeTravel + optionKindMaxSequencingWindow + optionKindRequireInteropNotAtGen + optionKindAfterBuild + optionKindProofValidation +) + +const allOptionKinds = optionKindDeployer | + optionKindBatcher | + optionKindProposer | + optionKindOPRBuilder | + optionKindGlobalL2CL | + optionKindGlobalSyncTesterEL | + optionKindL1EL | + optionKindAddedGameType | + optionKindRespectedGameType | + optionKindChallengerCannonKona | + optionKindTimeTravel | + optionKindMaxSequencingWindow | + optionKindRequireInteropNotAtGen | + optionKindAfterBuild | + optionKindProofValidation + +var optionKindLabels = []struct { + kind optionKinds + label string +}{ + {kind: optionKindDeployer, label: "deployer options"}, + {kind: optionKindBatcher, label: "batcher options"}, + {kind: optionKindProposer, label: "proposer options"}, + {kind: optionKindOPRBuilder, label: "builder options"}, + {kind: optionKindGlobalL2CL, label: "L2 CL options"}, + {kind: optionKindGlobalSyncTesterEL, label: "sync tester EL options"}, + {kind: optionKindL1EL, label: "L1 EL options"}, + {kind: optionKindAddedGameType, label: "added game types"}, + {kind: optionKindRespectedGameType, label: "respected game types"}, + {kind: optionKindChallengerCannonKona, label: "challenger cannon-kona"}, + {kind: optionKindTimeTravel, label: "time travel"}, + {kind: optionKindMaxSequencingWindow, label: "max sequencing window"}, + {kind: optionKindRequireInteropNotAtGen, label: "interop-not-at-genesis"}, + {kind: optionKindAfterBuild, label: "after-build hooks"}, + {kind: optionKindProofValidation, label: "proof-validation hooks"}, +} + +func (k optionKinds) String() string { + if k == 0 { + return "none" + } + + names := make([]string, 0, len(optionKindLabels)) + for _, label := range optionKindLabels { + if k&label.kind == 0 { + continue + } + names = append(names, label.label) + } + if unknown := k &^ allOptionKinds; unknown != 0 { + names = append(names, fmt.Sprintf("unknown(%#x)", uint64(unknown))) + } + return strings.Join(names, ", ") +} + +func unsupportedPresetOptionKinds(opts Option, supported optionKinds) optionKinds { + if opts == nil { + return 0 + } + return opts.optionKinds() &^ supported +} + +func collectSupportedPresetConfig(t devtest.T, presetName string, opts []Option, supported optionKinds) (sysgo.PresetConfig, CombinedOption) { + cfg, combined := collectPresetConfig(opts) + if unsupported := unsupportedPresetOptionKinds(combined, supported); unsupported != 0 { + t.Require().FailNowf("%s does not support preset options: %s", presetName, unsupported) + } + return cfg, combined +} + +const minimalPresetSupportedOptionKinds = optionKindDeployer | + optionKindBatcher | + optionKindProposer | + optionKindGlobalL2CL | + optionKindL1EL | + optionKindAddedGameType | + optionKindRespectedGameType | + optionKindChallengerCannonKona | + optionKindTimeTravel | + optionKindAfterBuild | + optionKindProofValidation + +const minimalWithConductorsPresetSupportedOptionKinds = optionKindDeployer | + optionKindBatcher | + optionKindProposer | + optionKindGlobalL2CL | + optionKindL1EL | + optionKindAddedGameType | + optionKindRespectedGameType | + optionKindTimeTravel | + optionKindAfterBuild | + optionKindProofValidation + +const simpleWithSyncTesterPresetSupportedOptionKinds = minimalPresetSupportedOptionKinds | + optionKindGlobalSyncTesterEL + +const singleChainInteropPresetSupportedOptionKinds = optionKindDeployer | + optionKindBatcher | + optionKindProposer | + optionKindGlobalL2CL | + optionKindL1EL | + optionKindAddedGameType | + optionKindRespectedGameType | + optionKindTimeTravel | + optionKindMaxSequencingWindow | + optionKindRequireInteropNotAtGen | + optionKindAfterBuild | + optionKindProofValidation + +const simpleInteropSuperProofsPresetSupportedOptionKinds = optionKindDeployer | + optionKindBatcher | + optionKindProposer | + optionKindGlobalL2CL | + optionKindL1EL | + optionKindChallengerCannonKona | + optionKindTimeTravel | + optionKindMaxSequencingWindow | + optionKindRequireInteropNotAtGen + +const supernodeProofsPresetSupportedOptionKinds = optionKindChallengerCannonKona | + optionKindL1EL + +const twoL2SupernodePresetSupportedOptionKinds = optionKindDeployer | + optionKindL1EL + +const twoL2SupernodeInteropPresetSupportedOptionKinds = optionKindDeployer | + optionKindTimeTravel | + optionKindL1EL + +const singleChainWithFlashblocksPresetSupportedOptionKinds = optionKindDeployer | + optionKindOPRBuilder diff --git a/op-devstack/presets/options.go b/op-devstack/presets/options.go new file mode 100644 index 00000000000..37dceaaacac --- /dev/null +++ b/op-devstack/presets/options.go @@ -0,0 +1,283 @@ +package presets + +import ( + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +type Option interface { + applyConfig(cfg *sysgo.PresetConfig) + applyPreset(target any) + optionKinds() optionKinds +} + +type option struct { + applyFn func(cfg *sysgo.PresetConfig) + applyPresetFn func(target any) + kinds optionKinds +} + +func (o option) applyConfig(cfg *sysgo.PresetConfig) { + if o.applyFn == nil { + return + } + o.applyFn(cfg) +} + +func (o option) applyPreset(target any) { + if o.applyPresetFn != nil { + o.applyPresetFn(target) + } +} + +func (o option) optionKinds() optionKinds { + return o.kinds +} + +type CombinedOption []Option + +func Combine(opts ...Option) CombinedOption { + return CombinedOption(opts) +} + +func (c CombinedOption) applyConfig(cfg *sysgo.PresetConfig) { + for _, opt := range c { + if opt == nil { + continue + } + opt.applyConfig(cfg) + } +} + +func (c CombinedOption) applyPreset(target any) { + for _, opt := range c { + if opt == nil { + continue + } + opt.applyPreset(target) + } +} + +func (c CombinedOption) optionKinds() optionKinds { + var kinds optionKinds + for _, opt := range c { + if opt == nil { + continue + } + kinds |= opt.optionKinds() + } + return kinds +} + +func AfterBuild(fn func(target any)) Option { + var kinds optionKinds + if fn != nil { + kinds = optionKindAfterBuild + } + return option{applyPresetFn: fn, kinds: kinds} +} + +func collectPresetConfig(opts []Option) (sysgo.PresetConfig, CombinedOption) { + cfg := sysgo.NewPresetConfig() + combined := Combine(opts...) + combined.applyConfig(&cfg) + return cfg, combined +} + +func WithDeployerOptions(opts ...sysgo.DeployerOption) Option { + var kinds optionKinds + for _, opt := range opts { + if opt != nil { + kinds = optionKindDeployer + break + } + } + return option{ + kinds: kinds, + applyFn: func(cfg *sysgo.PresetConfig) { + cfg.DeployerOptions = append(cfg.DeployerOptions, opts...) + }, + } +} + +// WithLocalContractSourcesAt configures a preset to load local contracts-bedrock +// artifacts from the supplied directory instead of resolving them relative to +// the process working directory. +func WithLocalContractSourcesAt(path string) Option { + var kinds optionKinds + if path != "" { + kinds = optionKindDeployer + } + return option{ + kinds: kinds, + applyFn: func(cfg *sysgo.PresetConfig) { + if path == "" { + return + } + cfg.LocalContractArtifactsPath = path + }, + } +} + +func WithBatcherOption(opt sysgo.BatcherOption) Option { + var kinds optionKinds + if opt != nil { + kinds = optionKindBatcher + } + return option{ + kinds: kinds, + applyFn: func(cfg *sysgo.PresetConfig) { + if opt == nil { + return + } + cfg.BatcherOptions = append(cfg.BatcherOptions, opt) + }, + } +} + +func WithGlobalL2CLOption(opt sysgo.L2CLOption) Option { + var kinds optionKinds + if opt != nil { + kinds = optionKindGlobalL2CL + } + return option{ + kinds: kinds, + applyFn: func(cfg *sysgo.PresetConfig) { + if opt == nil { + return + } + cfg.GlobalL2CLOptions = append(cfg.GlobalL2CLOptions, opt) + }, + } +} + +func WithGlobalSyncTesterELOption(opt sysgo.SyncTesterELOption) Option { + var kinds optionKinds + if opt != nil { + kinds = optionKindGlobalSyncTesterEL + } + return option{ + kinds: kinds, + applyFn: func(cfg *sysgo.PresetConfig) { + if opt == nil { + return + } + cfg.GlobalSyncTesterELOptions = append(cfg.GlobalSyncTesterELOptions, opt) + }, + } +} + +func WithL1Geth(execPath string) Option { + return option{ + kinds: optionKindL1EL, + applyFn: func(cfg *sysgo.PresetConfig) { + cfg.L1ELKind = "geth" + cfg.L1GethExecPath = execPath + }, + } +} + +func WithProposerOption(opt sysgo.ProposerOption) Option { + var kinds optionKinds + if opt != nil { + kinds = optionKindProposer + } + return option{ + kinds: kinds, + applyFn: func(cfg *sysgo.PresetConfig) { + if opt == nil { + return + } + cfg.ProposerOptions = append(cfg.ProposerOptions, opt) + }, + } +} + +func WithOPRBuilderOption(opt sysgo.OPRBuilderNodeOption) Option { + var kinds optionKinds + if opt != nil { + kinds = optionKindOPRBuilder + } + return option{ + kinds: kinds, + applyFn: func(cfg *sysgo.PresetConfig) { + if opt == nil { + return + } + cfg.OPRBuilderOptions = append(cfg.OPRBuilderOptions, opt) + }, + } +} + +func WithGameTypeAdded(gameType gameTypes.GameType) Option { + return option{ + kinds: optionKindAddedGameType, + applyFn: func(cfg *sysgo.PresetConfig) { + cfg.AddedGameTypes = append(cfg.AddedGameTypes, gameType) + }, + } +} + +func WithRespectedGameTypeOverride(gameType gameTypes.GameType) Option { + return option{ + kinds: optionKindRespectedGameType, + applyFn: func(cfg *sysgo.PresetConfig) { + cfg.RespectedGameTypes = append(cfg.RespectedGameTypes, gameType) + }, + } +} + +func WithCannonKonaGameTypeAdded() Option { + return option{ + kinds: optionKindAddedGameType | optionKindChallengerCannonKona, + applyFn: func(cfg *sysgo.PresetConfig) { + cfg.EnableCannonKonaForChall = true + cfg.AddedGameTypes = append(cfg.AddedGameTypes, gameTypes.CannonKonaGameType) + }, + } +} + +func WithChallengerCannonKonaEnabled() Option { + return option{ + kinds: optionKindChallengerCannonKona, + applyFn: func(cfg *sysgo.PresetConfig) { + cfg.EnableCannonKonaForChall = true + }, + } +} + +func WithTimeTravelEnabled() Option { + return option{ + kinds: optionKindTimeTravel, + applyFn: func(cfg *sysgo.PresetConfig) { + cfg.EnableTimeTravel = true + }, + } +} + +func WithMaxSequencingWindow(max uint64) Option { + return option{ + kinds: optionKindMaxSequencingWindow, + applyFn: func(cfg *sysgo.PresetConfig) { + v := max + cfg.MaxSequencingWindow = &v + }, + } +} + +func WithRequireInteropNotAtGenesis() Option { + return option{ + kinds: optionKindRequireInteropNotAtGen, + applyFn: func(cfg *sysgo.PresetConfig) { + cfg.RequireInteropNotAtGen = true + }, + } +} + +// WithL2BlockTimes configures per-chain L2 block times via the deployer. +// The blockTimes map keys are L2 chain IDs and values are the desired block +// time in seconds for that chain. +func WithL2BlockTimes(blockTimes map[eth.ChainID]uint64) Option { + return WithDeployerOptions(sysgo.WithL2BlockTimes(blockTimes)) +} diff --git a/op-devstack/presets/options_test.go b/op-devstack/presets/options_test.go new file mode 100644 index 00000000000..9b0ab678159 --- /dev/null +++ b/op-devstack/presets/options_test.go @@ -0,0 +1,141 @@ +package presets + +import ( + "testing" + + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/stretchr/testify/require" +) + +func TestOptionKindsFromCompositeOptions(t *testing.T) { + t.Run("WithSequencingWindow", func(t *testing.T) { + require.Equal(t, + optionKindDeployer|optionKindMaxSequencingWindow, + WithSequencingWindow(12, 24).optionKinds(), + ) + }) + + t.Run("WithCannonKonaGameTypeAdded", func(t *testing.T) { + require.Equal(t, + optionKindAddedGameType|optionKindChallengerCannonKona, + WithCannonKonaGameTypeAdded().optionKinds(), + ) + }) + + t.Run("WithL1Geth", func(t *testing.T) { + require.Equal(t, + optionKindL1EL, + WithL1Geth("/tmp/geth").optionKinds(), + ) + }) + + t.Run("RequireGameTypePresent", func(t *testing.T) { + require.Equal(t, + optionKindAfterBuild|optionKindProofValidation, + RequireGameTypePresent(gameTypes.CannonGameType).optionKinds(), + ) + }) + + t.Run("nil adapters do not claim support kinds", func(t *testing.T) { + require.Zero(t, WithDeployerOptions(nil).optionKinds()) + require.Zero(t, WithLocalContractSourcesAt("").optionKinds()) + require.Zero(t, WithBatcherOption(nil).optionKinds()) + require.Zero(t, WithGlobalL2CLOption(nil).optionKinds()) + require.Zero(t, WithGlobalSyncTesterELOption(nil).optionKinds()) + require.Zero(t, WithProposerOption(nil).optionKinds()) + require.Zero(t, WithOPRBuilderOption(nil).optionKinds()) + require.Zero(t, AfterBuild(nil).optionKinds()) + }) +} + +func TestWithLocalContractSourcesAt(t *testing.T) { + cfg, _ := collectPresetConfig([]Option{WithLocalContractSourcesAt("/tmp/contracts-bedrock")}) + require.Equal(t, "/tmp/contracts-bedrock", cfg.LocalContractArtifactsPath) +} + +func TestUnsupportedPresetOptionKinds(t *testing.T) { + builderOpt := sysgo.OPRBuilderNodeOptionFn(func(devtest.CommonT, sysgo.ComponentTarget, *sysgo.OPRBuilderNodeConfig) {}) + + tests := []struct { + name string + supported optionKinds + opts Option + want optionKinds + }{ + { + name: "minimal allows proof validation hooks", + supported: minimalPresetSupportedOptionKinds, + opts: Combine( + WithTimeTravelEnabled(), + RequireGameTypePresent(gameTypes.CannonGameType), + ), + want: 0, + }, + { + name: "minimal allows l1 EL override", + supported: minimalPresetSupportedOptionKinds, + opts: WithL1Geth("/tmp/geth"), + want: 0, + }, + { + name: "minimal with conductors rejects challenger toggle", + supported: minimalWithConductorsPresetSupportedOptionKinds, + opts: WithChallengerCannonKonaEnabled(), + want: optionKindChallengerCannonKona, + }, + { + name: "flashblocks allows builder and deployer adapters", + supported: singleChainWithFlashblocksPresetSupportedOptionKinds, + opts: Combine( + WithLocalContractSourcesAt("/tmp/contracts-bedrock"), + WithOPRBuilderOption(builderOpt), + WithTimeTravelEnabled(), + ), + want: optionKindTimeTravel, + }, + { + name: "simple interop super proofs reject builder and proof hooks", + supported: simpleInteropSuperProofsPresetSupportedOptionKinds, + opts: Combine( + WithOPRBuilderOption(builderOpt), + RequireGameTypePresent(gameTypes.CannonGameType), + ), + want: optionKindOPRBuilder | optionKindAfterBuild | optionKindProofValidation, + }, + { + name: "supernode proofs only allow challenger toggle", + supported: supernodeProofsPresetSupportedOptionKinds, + opts: Combine( + WithChallengerCannonKonaEnabled(), + WithTimeTravelEnabled(), + ), + want: optionKindTimeTravel, + }, + { + name: "two l2 supernode rejects time travel", + supported: twoL2SupernodePresetSupportedOptionKinds, + opts: WithTimeTravelEnabled(), + want: optionKindTimeTravel, + }, + { + name: "two l2 supernode interop accepts time travel", + supported: twoL2SupernodeInteropPresetSupportedOptionKinds, + opts: WithTimeTravelEnabled(), + want: 0, + }, + { + name: "unsupported proof validation is called out separately from generic after build", + supported: optionKindAfterBuild, + opts: RequireGameTypePresent(gameTypes.CannonGameType), + want: optionKindProofValidation, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, unsupportedPresetOptionKinds(tt.opts, tt.supported)) + }) + } +} diff --git a/op-devstack/presets/proof.go b/op-devstack/presets/proof.go new file mode 100644 index 00000000000..509c2f0af9c --- /dev/null +++ b/op-devstack/presets/proof.go @@ -0,0 +1,106 @@ +package presets + +import ( + gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/dsl/contract" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + ps "github.com/ethereum-optimism/optimism/op-proposer/proposer" + "github.com/ethereum-optimism/optimism/op-service/txintent/bindings" + "github.com/ethereum-optimism/optimism/op-service/txintent/contractio" +) + +type proofValidationTarget interface { + proofValidationContext() (devtest.T, *dsl.L1ELNode, []*dsl.L2Network) +} + +func afterBuildProofValidation(fn func(t devtest.T, elNode *dsl.L1ELNode, l2Networks []*dsl.L2Network)) Option { + var kinds optionKinds + if fn != nil { + kinds = optionKindAfterBuild | optionKindProofValidation + } + return option{ + kinds: kinds, + applyPresetFn: func(target any) { + if fn == nil { + return + } + proofTarget, ok := target.(proofValidationTarget) + if !ok { + return + } + t, elNode, l2Networks := proofTarget.proofValidationContext() + fn(t, elNode, l2Networks) + }, + } +} + +func WithRespectedGameType(gameType gameTypes.GameType) Option { + opts := WithProposerGameType(gameType) + opts = Combine(opts, + WithRespectedGameTypeOverride(gameType), + RequireRespectedGameType(gameType), + ) + return opts +} + +func RequireGameTypePresent(gameType gameTypes.GameType) Option { + return afterBuildProofValidation(func(t devtest.T, elNode *dsl.L1ELNode, l2Networks []*dsl.L2Network) { + for _, l2Network := range l2Networks { + dgf := bindings.NewBindings[bindings.DisputeGameFactory]( + bindings.WithClient(elNode.EthClient()), + bindings.WithTo(l2Network.Escape().Deployment().DisputeGameFactoryProxyAddr()), + bindings.WithTest(t), + ) + gameImpl := contract.Read(dgf.GameImpls(uint32(gameType))) + t.Gate().NotZerof(gameImpl, "Dispute game factory must have a game implementation for %s", gameType) + } + }) +} + +func RequireRespectedGameType(gameType gameTypes.GameType) Option { + return afterBuildProofValidation(func(t devtest.T, elNode *dsl.L1ELNode, l2Networks []*dsl.L2Network) { + for _, l2Network := range l2Networks { + l1PortalAddr := l2Network.Escape().RollupConfig().DepositContractAddress + l1Portal := bindings.NewBindings[bindings.OptimismPortal2]( + bindings.WithClient(elNode.EthClient()), + bindings.WithTo(l1PortalAddr), + bindings.WithTest(t)) + + respectedGameType, err := contractio.Read(l1Portal.RespectedGameType(), t.Ctx()) + t.Require().NoError(err, "Failed to read respected game type") + t.Gate().EqualValuesf(gameType, respectedGameType, "Respected game type must be %s", gameType) + } + }) +} + +func WithProposerGameType(gameType gameTypes.GameType) Option { + return WithProposerOption(func(id sysgo.ComponentTarget, cfg *ps.CLIConfig) { + cfg.DisputeGameType = uint32(gameType) + }) +} + +func WithGuardianMatchL1PAO() Option { + return WithDeployerOptions( + sysgo.WithGuardianMatchL1PAO(), + ) +} + +func WithFinalizationPeriodSeconds(n uint64) Option { + return WithDeployerOptions( + sysgo.WithFinalizationPeriodSeconds(n), + ) +} + +func WithProofMaturityDelaySeconds(seconds uint64) Option { + return WithDeployerOptions( + sysgo.WithProofMaturityDelaySeconds(seconds), + ) +} + +func WithDisputeGameFinalityDelaySeconds(seconds uint64) Option { + return WithDeployerOptions( + sysgo.WithDisputeGameFinalityDelaySeconds(seconds), + ) +} diff --git a/op-devstack/presets/rpc_frontends.go b/op-devstack/presets/rpc_frontends.go new file mode 100644 index 00000000000..85d43b2eac5 --- /dev/null +++ b/op-devstack/presets/rpc_frontends.go @@ -0,0 +1,598 @@ +package presets + +import ( + "crypto/ecdsa" + "time" + + "github.com/ethereum/go-ethereum/common" + gethrpc "github.com/ethereum/go-ethereum/rpc" + + "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" + challengerConfig "github.com/ethereum-optimism/optimism/op-challenger/config" + conductorRpc "github.com/ethereum-optimism/optimism/op-conductor/rpc" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/apis" + opclient "github.com/ethereum-optimism/optimism/op-service/client" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/locks" + "github.com/ethereum-optimism/optimism/op-service/sources" + "github.com/ethereum-optimism/optimism/op-service/testreq" + "github.com/ethereum-optimism/optimism/op-sync-tester/synctester" +) + +type keyringImpl struct { + keys devkeys.Keys + require *testreq.Assertions +} + +var _ stack.Keys = (*keyringImpl)(nil) + +func newKeyring(keys devkeys.Keys, req *testreq.Assertions) *keyringImpl { + return &keyringImpl{ + keys: keys, + require: req, + } +} + +func (k *keyringImpl) Secret(key devkeys.Key) *ecdsa.PrivateKey { + pk, err := k.keys.Secret(key) + k.require.NoError(err) + return pk +} + +func (k *keyringImpl) Address(key devkeys.Key) common.Address { + addr, err := k.keys.Address(key) + k.require.NoError(err) + return addr +} + +type rpcELNode struct { + presetCommon + + client opclient.RPC + ethClient *sources.EthClient + chainID eth.ChainID + txTimeout time.Duration +} + +var _ stack.ELNode = (*rpcELNode)(nil) + +func newRPCELNode(t devtest.T, name string, chainID eth.ChainID, rpcCl opclient.RPC, timeout time.Duration) rpcELNode { + t = t.WithCtx(stack.ContextWithChainID(t.Ctx(), chainID)) + ethCl, err := sources.NewEthClient(rpcCl, t.Logger(), nil, sources.DefaultEthClientConfig(10)) + t.Require().NoError(err) + if timeout == 0 { + timeout = 30 * time.Second + } + return rpcELNode{ + presetCommon: newPresetCommon(t, name), + client: rpcCl, + ethClient: ethCl, + chainID: chainID, + txTimeout: timeout, + } +} + +func (r *rpcELNode) ChainID() eth.ChainID { + return r.chainID +} + +func (r *rpcELNode) EthClient() apis.EthClient { + return r.ethClient +} + +func (r *rpcELNode) TransactionTimeout() time.Duration { + return r.txTimeout +} + +type l1ELFrontend struct { + rpcELNode +} + +var _ stack.L1ELNode = (*l1ELFrontend)(nil) + +func newPresetL1ELNode(t devtest.T, name string, chainID eth.ChainID, rpcCl opclient.RPC) *l1ELFrontend { + return &l1ELFrontend{ + rpcELNode: newRPCELNode(t, name, chainID, rpcCl, 0), + } +} + +type l1CLFrontend struct { + presetCommon + chainID eth.ChainID + client apis.BeaconClient + lifecycle stack.Lifecycle +} + +var _ stack.L1CLNode = (*l1CLFrontend)(nil) + +func newPresetL1CLNode(t devtest.T, name string, chainID eth.ChainID, httpCl opclient.HTTP) *l1CLFrontend { + t = t.WithCtx(stack.ContextWithChainID(t.Ctx(), chainID)) + return &l1CLFrontend{ + presetCommon: newPresetCommon(t, name), + chainID: chainID, + client: sources.NewBeaconHTTPClient(httpCl), + } +} + +func (r *l1CLFrontend) ChainID() eth.ChainID { + return r.chainID +} + +func (r *l1CLFrontend) BeaconClient() apis.BeaconClient { + return r.client +} + +func (r *l1CLFrontend) Start() { + r.require().NotNil(r.lifecycle, "L1CL node %s is not lifecycle-controllable", r.Name()) + r.lifecycle.Start() +} + +func (r *l1CLFrontend) Stop() { + r.require().NotNil(r.lifecycle, "L1CL node %s is not lifecycle-controllable", r.Name()) + r.lifecycle.Stop() +} + +type l2ELFrontend struct { + rpcELNode + l2Client *sources.L2Client + l2EngineClient *sources.EngineClient + lifecycle stack.Lifecycle +} + +var _ stack.L2ELNode = (*l2ELFrontend)(nil) + +func newPresetL2ELNode(t devtest.T, name string, chainID eth.ChainID, userRPCCl opclient.RPC, engineRPCCl opclient.RPC, rollupCfg *rollup.Config) *l2ELFrontend { + t.Require().NotNil(rollupCfg, "rollup config must be configured") + l2Client, err := sources.NewL2Client(userRPCCl, t.Logger(), nil, sources.L2ClientSimpleConfig(rollupCfg, false, 10, 10)) + t.Require().NoError(err) + engineClientCfg := &sources.EngineClientConfig{ + L2ClientConfig: *sources.L2ClientSimpleConfig(rollupCfg, false, 10, 10), + } + engineClient, err := sources.NewEngineClient(engineRPCCl, t.Logger(), nil, engineClientCfg) + t.Require().NoError(err) + return &l2ELFrontend{ + rpcELNode: newRPCELNode(t, name, chainID, userRPCCl, 0), + l2Client: l2Client, + l2EngineClient: engineClient, + } +} + +func (r *l2ELFrontend) L2EthClient() apis.L2EthClient { + return r.l2Client +} + +func (r *l2ELFrontend) L2EngineClient() apis.EngineClient { + return r.l2EngineClient.EngineAPIClient +} + +func (r *l2ELFrontend) Start() { + r.require().NotNil(r.lifecycle, "L2EL node %s is not lifecycle-controllable", r.Name()) + r.lifecycle.Start() +} + +func (r *l2ELFrontend) Stop() { + r.require().NotNil(r.lifecycle, "L2EL node %s is not lifecycle-controllable", r.Name()) + r.lifecycle.Stop() +} + +type l2CLFrontend struct { + presetCommon + chainID eth.ChainID + client opclient.RPC + rollupClient apis.RollupClient + p2pClient apis.P2PClient + els locks.RWMap[string, *l2ELFrontend] + rollupBoostNodes locks.RWMap[string, *rollupBoostFrontend] + oprBuilderNodes locks.RWMap[string, *oprBuilderFrontend] + userRPC string + interopEndpoint string + interopJWTSecret eth.Bytes32 + lifecycle stack.Lifecycle +} + +var _ stack.L2CLNode = (*l2CLFrontend)(nil) + +func newPresetL2CLNode(t devtest.T, name string, chainID eth.ChainID, rpcCl opclient.RPC, userRPC, interopEndpoint string, interopJWTSecret eth.Bytes32) *l2CLFrontend { + t = t.WithCtx(stack.ContextWithChainID(t.Ctx(), chainID)) + return &l2CLFrontend{ + presetCommon: newPresetCommon(t, name), + chainID: chainID, + client: rpcCl, + rollupClient: sources.NewRollupClient(rpcCl), + p2pClient: sources.NewP2PClient(rpcCl), + userRPC: userRPC, + interopEndpoint: interopEndpoint, + interopJWTSecret: interopJWTSecret, + } +} + +func (r *l2CLFrontend) ClientRPC() opclient.RPC { + return r.client +} + +func (r *l2CLFrontend) ChainID() eth.ChainID { + return r.chainID +} + +func (r *l2CLFrontend) RollupAPI() apis.RollupClient { + return r.rollupClient +} + +func (r *l2CLFrontend) P2PAPI() apis.P2PClient { + return r.p2pClient +} + +func (r *l2CLFrontend) InteropRPC() (endpoint string, jwtSecret eth.Bytes32) { + return r.interopEndpoint, r.interopJWTSecret +} + +func (r *l2CLFrontend) UserRPC() string { + return r.userRPC +} + +func (r *l2CLFrontend) attachEL(el *l2ELFrontend) { + r.els.Set(el.Name(), el) +} + +func (r *l2CLFrontend) attachRollupBoostNode(node *rollupBoostFrontend) { + r.rollupBoostNodes.Set(node.Name(), node) +} + +func (r *l2CLFrontend) attachOPRBuilderNode(node *oprBuilderFrontend) { + r.oprBuilderNodes.Set(node.Name(), node) +} + +func (r *l2CLFrontend) ELs() []stack.L2ELNode { + return mapSlice(sortByNameFunc(r.els.Values()), func(v *l2ELFrontend) stack.L2ELNode { return v }) +} + +func (r *l2CLFrontend) RollupBoostNodes() []stack.RollupBoostNode { + return mapSlice(sortByNameFunc(r.rollupBoostNodes.Values()), func(v *rollupBoostFrontend) stack.RollupBoostNode { return v }) +} + +func (r *l2CLFrontend) OPRBuilderNodes() []stack.OPRBuilderNode { + return mapSlice(sortByNameFunc(r.oprBuilderNodes.Values()), func(v *oprBuilderFrontend) stack.OPRBuilderNode { return v }) +} + +func (r *l2CLFrontend) ELClient() apis.EthClient { + if els := sortByNameFunc(r.els.Values()); len(els) > 0 { + return els[0].EthClient() + } + if nodes := sortByNameFunc(r.rollupBoostNodes.Values()); len(nodes) > 0 { + return nodes[0].EthClient() + } + if nodes := sortByNameFunc(r.oprBuilderNodes.Values()); len(nodes) > 0 { + return nodes[0].EthClient() + } + return nil +} + +func (r *l2CLFrontend) Start() { + r.require().NotNil(r.lifecycle, "L2CL node %s is not lifecycle-controllable", r.Name()) + r.lifecycle.Start() +} + +func (r *l2CLFrontend) Stop() { + r.require().NotNil(r.lifecycle, "L2CL node %s is not lifecycle-controllable", r.Name()) + r.lifecycle.Stop() +} + +type l2BatcherFrontend struct { + presetCommon + chainID eth.ChainID + client *sources.BatcherAdminClient +} + +var _ stack.L2Batcher = (*l2BatcherFrontend)(nil) + +func newPresetL2Batcher(t devtest.T, name string, chainID eth.ChainID, rpcCl opclient.RPC) *l2BatcherFrontend { + t = t.WithCtx(stack.ContextWithChainID(t.Ctx(), chainID)) + return &l2BatcherFrontend{ + presetCommon: newPresetCommon(t, name), + chainID: chainID, + client: sources.NewBatcherAdminClient(rpcCl), + } +} + +func (r *l2BatcherFrontend) ChainID() eth.ChainID { + return r.chainID +} + +func (r *l2BatcherFrontend) ActivityAPI() apis.BatcherActivity { + return r.client +} + +type l2ProposerFrontend struct { + presetCommon + chainID eth.ChainID +} + +var _ stack.L2Proposer = (*l2ProposerFrontend)(nil) + +func (r *l2ProposerFrontend) ChainID() eth.ChainID { + return r.chainID +} + +type l2ChallengerFrontend struct { + presetCommon + chainID eth.ChainID + config *challengerConfig.Config +} + +var _ stack.L2Challenger = (*l2ChallengerFrontend)(nil) + +func newPresetL2Challenger(t devtest.T, name string, chainID eth.ChainID, cfg *challengerConfig.Config) *l2ChallengerFrontend { + t = t.WithCtx(stack.ContextWithChainID(t.Ctx(), chainID)) + return &l2ChallengerFrontend{ + presetCommon: newPresetCommon(t, name), + chainID: chainID, + config: cfg, + } +} + +func (r *l2ChallengerFrontend) ChainID() eth.ChainID { + return r.chainID +} + +func (r *l2ChallengerFrontend) Config() *challengerConfig.Config { + return r.config +} + +type oprBuilderFrontend struct { + rpcELNode + engineClient *sources.EngineClient + flashblocksClient *opclient.WSClient + lifecycle stack.Lifecycle + updateRuleSet func(rulesYaml string) error +} + +var _ stack.OPRBuilderNode = (*oprBuilderFrontend)(nil) + +func newPresetOPRBuilderNode(t devtest.T, name string, chainID eth.ChainID, rpcCl opclient.RPC, rollupCfg *rollup.Config, flashblocksCl *opclient.WSClient, updateRuleSet func(string) error) *oprBuilderFrontend { + engineClient, err := sources.NewEngineClient(rpcCl, t.Logger(), nil, sources.EngineClientDefaultConfig(rollupCfg)) + t.Require().NoError(err) + return &oprBuilderFrontend{ + rpcELNode: newRPCELNode(t, name, chainID, rpcCl, 0), + engineClient: engineClient, + flashblocksClient: flashblocksCl, + updateRuleSet: updateRuleSet, + } +} + +func (r *oprBuilderFrontend) L2EthClient() apis.L2EthClient { + return r.engineClient.L2Client +} + +func (r *oprBuilderFrontend) L2EngineClient() apis.EngineClient { + return r.engineClient.EngineAPIClient +} + +func (r *oprBuilderFrontend) FlashblocksClient() *opclient.WSClient { + return r.flashblocksClient +} + +func (r *oprBuilderFrontend) UpdateRuleSet(rulesYaml string) error { + return r.updateRuleSet(rulesYaml) +} + +func (r *oprBuilderFrontend) Start() { + r.require().NotNil(r.lifecycle, "OPR builder node %s is not lifecycle-controllable", r.Name()) + r.lifecycle.Start() +} + +func (r *oprBuilderFrontend) Stop() { + r.require().NotNil(r.lifecycle, "OPR builder node %s is not lifecycle-controllable", r.Name()) + r.lifecycle.Stop() +} + +type rollupBoostFrontend struct { + rpcELNode + engineClient *sources.EngineClient + flashblocksClient *opclient.WSClient + lifecycle stack.Lifecycle +} + +var _ stack.RollupBoostNode = (*rollupBoostFrontend)(nil) + +func newPresetRollupBoostNode(t devtest.T, name string, chainID eth.ChainID, rpcCl opclient.RPC, rollupCfg *rollup.Config, flashblocksCl *opclient.WSClient) *rollupBoostFrontend { + engineClient, err := sources.NewEngineClient(rpcCl, t.Logger(), nil, sources.EngineClientDefaultConfig(rollupCfg)) + t.Require().NoError(err) + return &rollupBoostFrontend{ + rpcELNode: newRPCELNode(t, name, chainID, rpcCl, 0), + engineClient: engineClient, + flashblocksClient: flashblocksCl, + } +} + +func (r *rollupBoostFrontend) L2EthClient() apis.L2EthClient { + return r.engineClient.L2Client +} + +func (r *rollupBoostFrontend) L2EngineClient() apis.EngineClient { + return r.engineClient.EngineAPIClient +} + +func (r *rollupBoostFrontend) FlashblocksClient() *opclient.WSClient { + return r.flashblocksClient +} + +func (r *rollupBoostFrontend) Start() { + r.require().NotNil(r.lifecycle, "rollup boost node %s is not lifecycle-controllable", r.Name()) + r.lifecycle.Start() +} + +func (r *rollupBoostFrontend) Stop() { + r.require().NotNil(r.lifecycle, "rollup boost node %s is not lifecycle-controllable", r.Name()) + r.lifecycle.Stop() +} + +type supervisorFrontend struct { + presetCommon + api apis.SupervisorAPI + lifecycle stack.Lifecycle +} + +var _ stack.Supervisor = (*supervisorFrontend)(nil) + +func newPresetSupervisor(t devtest.T, name string, rpcCl opclient.RPC) *supervisorFrontend { + return &supervisorFrontend{ + presetCommon: newPresetCommon(t, name), + api: sources.NewSupervisorClient(rpcCl), + } +} + +func (r *supervisorFrontend) AdminAPI() apis.SupervisorAdminAPI { + return r.api +} + +func (r *supervisorFrontend) QueryAPI() apis.SupervisorQueryAPI { + return r.api +} + +func (r *supervisorFrontend) Start() { + r.require().NotNil(r.lifecycle, "supervisor %s is not lifecycle-controllable", r.Name()) + r.lifecycle.Start() +} + +func (r *supervisorFrontend) Stop() { + r.require().NotNil(r.lifecycle, "supervisor %s is not lifecycle-controllable", r.Name()) + r.lifecycle.Stop() +} + +type supernodeFrontend struct { + presetCommon + api apis.SupernodeQueryAPI +} + +var _ stack.Supernode = (*supernodeFrontend)(nil) + +func newPresetSupernode(t devtest.T, name string, rpcCl opclient.RPC) *supernodeFrontend { + return &supernodeFrontend{ + presetCommon: newPresetCommon(t, name), + api: sources.NewSuperNodeClient(rpcCl), + } +} + +func (r *supernodeFrontend) QueryAPI() apis.SupernodeQueryAPI { + return r.api +} + +type conductorFrontend struct { + presetCommon + chainID eth.ChainID + api conductorRpc.API +} + +var _ stack.Conductor = (*conductorFrontend)(nil) + +func newPresetConductor(t devtest.T, name string, chainID eth.ChainID, rpcCl *gethrpc.Client) *conductorFrontend { + t = t.WithCtx(stack.ContextWithChainID(t.Ctx(), chainID)) + return &conductorFrontend{ + presetCommon: newPresetCommon(t, name), + chainID: chainID, + api: conductorRpc.NewAPIClient(rpcCl), + } +} + +func (r *conductorFrontend) ChainID() eth.ChainID { + return r.chainID +} + +func (r *conductorFrontend) RpcAPI() conductorRpc.API { + return r.api +} + +type faucetFrontend struct { + presetCommon + chainID eth.ChainID + client *sources.FaucetClient +} + +var _ stack.Faucet = (*faucetFrontend)(nil) + +func newPresetFaucet(t devtest.T, name string, chainID eth.ChainID, rpcCl opclient.RPC) *faucetFrontend { + t = t.WithCtx(stack.ContextWithChainID(t.Ctx(), chainID)) + return &faucetFrontend{ + presetCommon: newPresetCommon(t, name), + chainID: chainID, + client: sources.NewFaucetClient(rpcCl), + } +} + +func (r *faucetFrontend) ChainID() eth.ChainID { + return r.chainID +} + +func (r *faucetFrontend) API() apis.Faucet { + return r.client +} + +type testSequencerFrontend struct { + presetCommon + api apis.TestSequencerAPI + controls map[eth.ChainID]apis.TestSequencerControlAPI +} + +var _ stack.TestSequencer = (*testSequencerFrontend)(nil) + +func newPresetTestSequencer(t devtest.T, name string, adminRPCCl opclient.RPC, controlRPCs map[eth.ChainID]opclient.RPC) *testSequencerFrontend { + s := &testSequencerFrontend{ + presetCommon: newPresetCommon(t, name), + api: sources.NewBuilderClient(adminRPCCl), + controls: make(map[eth.ChainID]apis.TestSequencerControlAPI, len(controlRPCs)), + } + for chainID, rpcCl := range controlRPCs { + s.controls[chainID] = sources.NewControlClient(rpcCl) + } + return s +} + +func (r *testSequencerFrontend) AdminAPI() apis.TestSequencerAdminAPI { + return r.api +} + +func (r *testSequencerFrontend) BuildAPI() apis.TestSequencerBuildAPI { + return r.api +} + +func (r *testSequencerFrontend) ControlAPI(chainID eth.ChainID) apis.TestSequencerControlAPI { + return r.controls[chainID] +} + +type syncTesterFrontend struct { + presetCommon + chainID eth.ChainID + addr string + client *sources.SyncTesterClient +} + +var _ stack.SyncTester = (*syncTesterFrontend)(nil) + +func newPresetSyncTester(t devtest.T, name string, chainID eth.ChainID, addr string, rpcCl opclient.RPC) *syncTesterFrontend { + t = t.WithCtx(stack.ContextWithChainID(t.Ctx(), chainID)) + return &syncTesterFrontend{ + presetCommon: newPresetCommon(t, name), + chainID: chainID, + addr: addr, + client: sources.NewSyncTesterClient(rpcCl), + } +} + +func (r *syncTesterFrontend) ChainID() eth.ChainID { + return r.chainID +} + +func (r *syncTesterFrontend) API() apis.SyncTester { + return r.client +} + +func (r *syncTesterFrontend) APIWithSession(sessionID string) apis.SyncTester { + require := r.T().Require() + require.NoError(synctester.IsValidSessionID(sessionID)) + rpcCl, err := opclient.NewRPC(r.T().Ctx(), r.Logger(), r.addr+"/"+sessionID, opclient.WithLazyDial()) + require.NoError(err, "sync tester failed to initialize rpc per session") + return sources.NewSyncTesterClient(rpcCl) +} diff --git a/op-devstack/presets/simple_with_synctester.go b/op-devstack/presets/simple_with_synctester.go new file mode 100644 index 00000000000..1a46d7cb3bc --- /dev/null +++ b/op-devstack/presets/simple_with_synctester.go @@ -0,0 +1,31 @@ +package presets + +import ( + "github.com/ethereum-optimism/optimism/op-core/forks" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" +) + +type SimpleWithSyncTester struct { + Minimal + + SyncTester *dsl.SyncTester + SyncTesterL2EL *dsl.L2ELNode + L2CL2 *dsl.L2CLNode +} + +// NewSimpleWithSyncTester creates a fresh SimpleWithSyncTester target for the current +// test. +// +// The target is created from the runtime plus any additional preset options. +func NewSimpleWithSyncTester(t devtest.T, opts ...Option) *SimpleWithSyncTester { + presetCfg, presetOpts := collectSupportedPresetConfig(t, "NewSimpleWithSyncTester", opts, simpleWithSyncTesterPresetSupportedOptionKinds) + out := simpleWithSyncTesterFromRuntime(t, sysgo.NewSimpleWithSyncTesterRuntimeWithConfig(t, presetCfg)) + presetOpts.applyPreset(out) + return out +} + +func WithHardforkSequentialActivation(startFork, endFork forks.Name, delta uint64) Option { + return WithDeployerOptions(sysgo.WithHardforkSequentialActivation(startFork, endFork, &delta)) +} diff --git a/op-devstack/presets/singlechain_from_runtime.go b/op-devstack/presets/singlechain_from_runtime.go new file mode 100644 index 00000000000..7f19ab67b81 --- /dev/null +++ b/op-devstack/presets/singlechain_from_runtime.go @@ -0,0 +1,187 @@ +package presets + +import ( + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" +) + +func singleChainMultiNodeFromRuntime(t devtest.T, runtime *sysgo.SingleChainRuntime, runSyncChecks bool) *SingleChainMultiNode { + minimal := minimalFromRuntime(t, runtime) + l2ChainID := runtime.L2Network.ChainID() + nodeB := runtime.Nodes["b"] + t.Require().NotNil(nodeB, "missing single-chain node b") + + l2ELB := newL2ELFrontend( + t, + "b", + l2ChainID, + nodeB.EL.UserRPC(), + nodeB.EL.EngineRPC(), + nodeB.EL.JWTPath(), + runtime.L2Network.RollupConfig(), + nodeB.EL, + ) + l2CLB := newL2CLFrontend( + t, + "b", + l2ChainID, + nodeB.CL.UserRPC(), + nodeB.CL, + ) + l2CLB.attachEL(l2ELB) + l2Net, ok := minimal.L2Chain.Escape().(*presetL2Network) + t.Require().True(ok, "expected preset L2 network") + l2Net.AddL2ELNode(l2ELB) + l2Net.AddL2CLNode(l2CLB) + + preset := &SingleChainMultiNode{ + Minimal: *minimal, + L2ELB: dsl.NewL2ELNode(l2ELB), + L2CLB: dsl.NewL2CLNode(l2CLB), + } + if runtime.P2PEnabled { + preset.L2CLB.ManagePeer(preset.L2CL) + } + if runSyncChecks { + // Ensure the follower node is in sync with the sequencer before starting tests. + dsl.CheckAll(t, + preset.L2CLB.MatchedFn(preset.L2CL, types.CrossSafe, 30), + preset.L2CLB.MatchedFn(preset.L2CL, types.LocalUnsafe, 30), + ) + } + return preset +} + +func singleChainMultiNodeWithTestSeqFromRuntime(t devtest.T, runtime *sysgo.SingleChainRuntime) *SingleChainMultiNodeWithTestSeq { + preset := singleChainMultiNodeFromRuntime(t, runtime, false) + testSequencer := newTestSequencerFrontend( + t, + runtime.TestSequencer.Name, + runtime.TestSequencer.AdminRPC, + runtime.TestSequencer.ControlRPC, + runtime.TestSequencer.JWTSecret, + ) + return &SingleChainMultiNodeWithTestSeq{ + SingleChainMultiNode: *preset, + TestSequencer: dsl.NewTestSequencer(testSequencer), + } +} + +func singleChainTwoVerifiersFromRuntime(t devtest.T, runtime *sysgo.SingleChainRuntime) *SingleChainTwoVerifiers { + base := singleChainMultiNodeFromRuntime(t, runtime, false) + l2ChainID := runtime.L2Network.ChainID() + nodeC := runtime.Nodes["c"] + t.Require().NotNil(nodeC, "missing single-chain node c") + + l2ELC := newL2ELFrontend( + t, + "c", + l2ChainID, + nodeC.EL.UserRPC(), + nodeC.EL.EngineRPC(), + nodeC.EL.JWTPath(), + runtime.L2Network.RollupConfig(), + nodeC.EL, + ) + l2CLC := newL2CLFrontend( + t, + "c", + l2ChainID, + nodeC.CL.UserRPC(), + nodeC.CL, + ) + l2CLC.attachEL(l2ELC) + l2Net, ok := base.L2Chain.Escape().(*presetL2Network) + t.Require().True(ok, "expected preset L2 network") + l2Net.AddL2ELNode(l2ELC) + l2Net.AddL2CLNode(l2CLC) + testSequencer := newTestSequencerFrontend( + t, + runtime.TestSequencer.Name, + runtime.TestSequencer.AdminRPC, + runtime.TestSequencer.ControlRPC, + runtime.TestSequencer.JWTSecret, + ) + preset := &SingleChainTwoVerifiers{ + SingleChainMultiNode: *base, + L2ELC: dsl.NewL2ELNode(l2ELC), + L2CLC: dsl.NewL2CLNode(l2CLC), + TestSequencer: dsl.NewTestSequencer(testSequencer), + } + preset.L2CLC.ManagePeer(preset.L2CL) + preset.L2CLC.ManagePeer(preset.L2CLB) + return preset +} + +func simpleWithSyncTesterFromRuntime(t devtest.T, runtime *sysgo.SingleChainRuntime) *SimpleWithSyncTester { + minimal := minimalFromRuntime(t, runtime) + l2ChainID := runtime.L2Network.ChainID() + t.Require().NotNil(runtime.SyncTester, "missing sync tester support") + t.Require().NotNil(runtime.SyncTester.Node, "missing sync tester node") + + syncTesterName, syncTesterRPC, ok := runtime.SyncTester.Service.DefaultEndpoint(runtime.L2Network.ChainID()) + t.Require().Truef(ok, "missing sync tester for chain %s", runtime.L2Network.ChainID()) + syncTester := newSyncTesterFrontend(t, syncTesterName, l2ChainID, syncTesterRPC) + + syncTesterL2EL := newL2ELFrontend( + t, + "sync-tester-el", + l2ChainID, + runtime.SyncTester.Node.EL.UserRPC(), + runtime.SyncTester.Node.EL.EngineRPC(), + runtime.SyncTester.Node.EL.JWTPath(), + runtime.L2Network.RollupConfig(), + ) + l2CL2 := newL2CLFrontend( + t, + "verifier", + l2ChainID, + runtime.SyncTester.Node.CL.UserRPC(), + runtime.SyncTester.Node.CL, + ) + l2CL2.attachEL(syncTesterL2EL) + l2Net, ok := minimal.L2Chain.Escape().(*presetL2Network) + t.Require().True(ok, "expected preset L2 network") + l2Net.AddSyncTester(syncTester) + l2Net.AddL2ELNode(syncTesterL2EL) + l2Net.AddL2CLNode(l2CL2) + + preset := &SimpleWithSyncTester{ + Minimal: *minimal, + SyncTester: dsl.NewSyncTester(syncTester), + SyncTesterL2EL: dsl.NewL2ELNode(syncTesterL2EL), + L2CL2: dsl.NewL2CLNode(l2CL2), + } + preset.L2CL2.ManagePeer(preset.L2CL) + return preset +} + +func minimalWithConductorsFromRuntime(t devtest.T, runtime *sysgo.SingleChainRuntime) *MinimalWithConductors { + minimal := minimalFromRuntime(t, runtime) + l2ChainID := runtime.L2Network.ChainID() + t.Require().NotNil(runtime.Conductors, "missing conductor support") + + cAName := "sequencer" + cBName := "b" + cCName := "c" + cA := newConductorFrontend(t, cAName, l2ChainID, runtime.Conductors[cAName].HTTPEndpoint()) + cB := newConductorFrontend(t, cBName, l2ChainID, runtime.Conductors[cBName].HTTPEndpoint()) + cC := newConductorFrontend(t, cCName, l2ChainID, runtime.Conductors[cCName].HTTPEndpoint()) + l2Net, ok := minimal.L2Chain.Escape().(*presetL2Network) + t.Require().True(ok, "expected preset L2 network") + l2Net.AddConductor(cA) + l2Net.AddConductor(cB) + l2Net.AddConductor(cC) + + conductors := []stack.Conductor{cA, cB, cC} + return &MinimalWithConductors{ + Minimal: minimal, + ConductorSets: map[eth.ChainID]dsl.ConductorSet{ + l2ChainID: dsl.NewConductorSet(conductors), + }, + } +} diff --git a/op-devstack/presets/singlechain_multinode.go b/op-devstack/presets/singlechain_multinode.go new file mode 100644 index 00000000000..79c7ee69c89 --- /dev/null +++ b/op-devstack/presets/singlechain_multinode.go @@ -0,0 +1,65 @@ +package presets + +import ( + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" +) + +type SingleChainMultiNode struct { + Minimal + + L2ELB *dsl.L2ELNode + L2CLB *dsl.L2CLNode +} + +// NewSingleChainMultiNode creates a fresh SingleChainMultiNode target for the current +// test. +// +// The target is created from the runtime plus any additional preset options. +func NewSingleChainMultiNode(t devtest.T, opts ...Option) *SingleChainMultiNode { + presetCfg, presetOpts := collectSupportedPresetConfig(t, "NewSingleChainMultiNode", opts, minimalPresetSupportedOptionKinds) + out := singleChainMultiNodeFromRuntime(t, sysgo.NewSingleChainMultiNodeRuntimeWithConfig(t, true, presetCfg), true) + presetOpts.applyPreset(out) + return out +} + +// NewSingleChainMultiNodeWithoutCheck creates a fresh SingleChainMultiNode target for the +// current test, without running the initial verifier sync checks. +// +// The target is created from the runtime plus any additional preset options. +func NewSingleChainMultiNodeWithoutCheck(t devtest.T, opts ...Option) *SingleChainMultiNode { + presetCfg, presetOpts := collectSupportedPresetConfig(t, "NewSingleChainMultiNodeWithoutCheck", opts, minimalPresetSupportedOptionKinds) + out := singleChainMultiNodeFromRuntime(t, sysgo.NewSingleChainMultiNodeRuntimeWithConfig(t, true, presetCfg), false) + presetOpts.applyPreset(out) + return out +} + +// NewSingleChainMultiNodeWithoutP2PWithoutCheck creates a fresh SingleChainMultiNode +// target without preconfigured sequencer/verifier P2P links and without running initial sync +// checks. +// +// The target is created from the runtime plus any additional preset options. +func NewSingleChainMultiNodeWithoutP2PWithoutCheck(t devtest.T, opts ...Option) *SingleChainMultiNode { + presetCfg, presetOpts := collectSupportedPresetConfig(t, "NewSingleChainMultiNodeWithoutP2PWithoutCheck", opts, minimalPresetSupportedOptionKinds) + out := singleChainMultiNodeFromRuntime(t, sysgo.NewSingleChainMultiNodeRuntimeWithConfig(t, false, presetCfg), false) + presetOpts.applyPreset(out) + return out +} + +type SingleChainMultiNodeWithTestSeq struct { + SingleChainMultiNode + + TestSequencer *dsl.TestSequencer +} + +// NewSingleChainMultiNodeWithTestSeq creates a fresh +// SingleChainMultiNodeWithTestSeq target for the current test. +// +// The target is created from the runtime plus any additional preset options. +func NewSingleChainMultiNodeWithTestSeq(t devtest.T, opts ...Option) *SingleChainMultiNodeWithTestSeq { + presetCfg, presetOpts := collectSupportedPresetConfig(t, "NewSingleChainMultiNodeWithTestSeq", opts, minimalPresetSupportedOptionKinds) + out := singleChainMultiNodeWithTestSeqFromRuntime(t, sysgo.NewSingleChainMultiNodeRuntimeWithConfig(t, true, presetCfg)) + presetOpts.applyPreset(out) + return out +} diff --git a/op-devstack/presets/singlechain_twoverifiers.go b/op-devstack/presets/singlechain_twoverifiers.go new file mode 100644 index 00000000000..4de57d3c2b9 --- /dev/null +++ b/op-devstack/presets/singlechain_twoverifiers.go @@ -0,0 +1,27 @@ +package presets + +import ( + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" +) + +type SingleChainTwoVerifiers struct { + SingleChainMultiNode + + L2ELC *dsl.L2ELNode + L2CLC *dsl.L2CLNode + + TestSequencer *dsl.TestSequencer +} + +// NewSingleChainTwoVerifiersWithoutCheck creates a fresh +// SingleChainTwoVerifiers target for the current test. +// +// The target is created from the runtime plus any additional preset options. +func NewSingleChainTwoVerifiersWithoutCheck(t devtest.T, opts ...Option) *SingleChainTwoVerifiers { + presetCfg, presetOpts := collectSupportedPresetConfig(t, "NewSingleChainTwoVerifiersWithoutCheck", opts, minimalPresetSupportedOptionKinds) + out := singleChainTwoVerifiersFromRuntime(t, sysgo.NewSingleChainTwoVerifiersRuntimeWithConfig(t, presetCfg)) + presetOpts.applyPreset(out) + return out +} diff --git a/op-devstack/presets/superproofs_from_runtime.go b/op-devstack/presets/superproofs_from_runtime.go new file mode 100644 index 00000000000..cf178b13e4a --- /dev/null +++ b/op-devstack/presets/superproofs_from_runtime.go @@ -0,0 +1,156 @@ +package presets + +import ( + challengerConfig "github.com/ethereum-optimism/optimism/op-challenger/config" + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +func attachChallenger(t devtest.T, l2Net *dsl.L2Network, name string, chainID eth.ChainID, cfg *challengerConfig.Config) { + if cfg == nil { + return + } + net, ok := l2Net.Escape().(*presetL2Network) + t.Require().True(ok, "expected preset L2 network") + net.AddL2Challenger(newPresetL2Challenger(t, name, chainID, cfg)) +} + +func simpleInteropFromSupernodeProofsRuntime(t devtest.T, runtime *sysgo.MultiChainRuntime) *SimpleInterop { + chainA := runtime.Chains["l2a"] + chainB := runtime.Chains["l2b"] + t.Require().NotNil(chainA, "missing l2a superproofs chain") + t.Require().NotNil(chainB, "missing l2b superproofs chain") + twoL2, components := twoL2FromRuntime(t, runtime) + + supernodeFrontend := newSupernodeFrontend(t, "supernode-two-l2-system", runtime.Supernode.UserRPC()) + testSequencer := newTestSequencerFrontend( + t, + runtime.TestSequencer.Name, + runtime.TestSequencer.AdminRPC, + runtime.TestSequencer.ControlRPC, + runtime.TestSequencer.JWTSecret, + ) + + out := &SimpleInterop{ + SingleChainInterop: SingleChainInterop{ + Log: t.Logger(), + T: t, + timeTravel: nil, + Supervisor: nil, + SuperRoots: dsl.NewSupernodeWithTestControl(supernodeFrontend, runtime.Supernode), + TestSequencer: dsl.NewTestSequencer(testSequencer), + L1Network: twoL2.L1Network, + L1EL: twoL2.L1EL, + L1CL: twoL2.L1CL, + L2ChainA: twoL2.L2A, + L2BatcherA: dsl.NewL2Batcher(components.l2ABatcher), + L2ELA: dsl.NewL2ELNode(components.l2AEL), + L2CLA: twoL2.L2ACL, + Wallet: dsl.NewRandomHDWallet(t, 30), + FaucetA: components.faucetA, + FaucetL1: dsl.NewFaucet(newFaucetFrontendForChain(t, runtime.FaucetService, runtime.L1Network.ChainID())), + challengerConfig: runtime.L2ChallengerConfig, + }, + L2ChainB: twoL2.L2B, + L2BatcherB: dsl.NewL2Batcher(components.l2BBatcher), + L2ELB: dsl.NewL2ELNode(components.l2BEL), + L2CLB: twoL2.L2BCL, + FaucetB: components.faucetB, + } + out.FunderL1 = dsl.NewFunder(out.Wallet, out.FaucetL1, out.L1EL) + out.FunderA = dsl.NewFunder(out.Wallet, out.FaucetA, out.L2ELA) + out.FunderB = dsl.NewFunder(out.Wallet, out.FaucetB, out.L2ELB) + l1Net, ok := out.L1Network.Escape().(*presetL1Network) + t.Require().True(ok, "expected preset L1 network") + l1Net.AddFaucet(out.FaucetL1.Escape().(*faucetFrontend)) + + attachChallenger(t, out.L2ChainA, "main", chainA.Network.ChainID(), out.challengerConfig) + attachChallenger(t, out.L2ChainB, "main", chainB.Network.ChainID(), out.challengerConfig) + return out +} + +func singleChainInteropFromSupernodeProofsRuntime(t devtest.T, runtime *sysgo.MultiChainRuntime) *SingleChainInterop { + chainA := runtime.Chains["l2a"] + t.Require().NotNil(chainA, "missing l2a superproofs chain") + l1ChainID := runtime.L1Network.ChainID() + l2ChainID := chainA.Network.ChainID() + + l1Network := newPresetL1Network(t, "l1", runtime.L1Network.ChainConfig()) + l1EL := newL1ELFrontend(t, "l1", l1ChainID, runtime.L1EL.UserRPC()) + l1CL := newL1CLFrontend(t, "l1", l1ChainID, runtime.L1CL.BeaconHTTPAddr(), runtime.L1CL.FakePoS()) + l1Network.AddL1ELNode(l1EL) + l1Network.AddL1CLNode(l1CL) + + l2Chain := newPresetL2Network( + t, + "l2a", + chainA.Network.ChainConfig(), + chainA.Network.RollupConfig(), + chainA.Network.Deployment(), + newKeyring(runtime.Keys, t.Require()), + l1Network, + ) + l2EL := newL2ELFrontend( + t, + "sequencer", + l2ChainID, + chainA.EL.UserRPC(), + chainA.EL.EngineRPC(), + chainA.EL.JWTPath(), + chainA.Network.RollupConfig(), + chainA.EL, + ) + l2CL := newL2CLFrontend(t, "sequencer", l2ChainID, chainA.CL.UserRPC(), chainA.CL) + l2CL.attachEL(l2EL) + l2Batcher := newL2BatcherFrontend(t, "main", l2ChainID, chainA.Batcher.UserRPC()) + l2Chain.AddL2ELNode(l2EL) + l2Chain.AddL2CLNode(l2CL) + l2Chain.AddL2Batcher(l2Batcher) + + challengerCfg := runtime.L2ChallengerConfig + if challengerCfg != nil { + l2Chain.AddL2Challenger(newPresetL2Challenger(t, "main", l2ChainID, challengerCfg)) + } + + supernodeFrontend := newSupernodeFrontend(t, "supernode-single-system-proofs", runtime.Supernode.UserRPC()) + testSequencer := newTestSequencerFrontend( + t, + runtime.TestSequencer.Name, + runtime.TestSequencer.AdminRPC, + runtime.TestSequencer.ControlRPC, + runtime.TestSequencer.JWTSecret, + ) + l1ELDSL := dsl.NewL1ELNode(l1EL) + l1CLDSL := dsl.NewL1CLNode(l1CL) + l2ELDSL := dsl.NewL2ELNode(l2EL) + l2CLDSL := dsl.NewL2CLNode(l2CL) + faucetAFrontend := newFaucetFrontendForChain(t, runtime.FaucetService, l2ChainID) + faucetL1Frontend := newFaucetFrontendForChain(t, runtime.FaucetService, l1ChainID) + + out := &SingleChainInterop{ + Log: t.Logger(), + T: t, + timeTravel: nil, + Supervisor: nil, + SuperRoots: dsl.NewSupernodeWithTestControl(supernodeFrontend, runtime.Supernode), + TestSequencer: dsl.NewTestSequencer(testSequencer), + L1Network: dsl.NewL1Network(l1Network, l1ELDSL, l1CLDSL), + L1EL: l1ELDSL, + L1CL: l1CLDSL, + L2ChainA: dsl.NewL2Network(l2Chain, l2ELDSL, l2CLDSL, l1ELDSL, nil, nil), + L2BatcherA: dsl.NewL2Batcher(l2Batcher), + L2ELA: l2ELDSL, + L2CLA: l2CLDSL, + Wallet: dsl.NewRandomHDWallet(t, 30), + FaucetA: dsl.NewFaucet(faucetAFrontend), + FaucetL1: dsl.NewFaucet(faucetL1Frontend), + challengerConfig: challengerCfg, + } + l1Network.AddFaucet(faucetL1Frontend) + l2Chain.AddFaucet(faucetAFrontend) + out.FunderL1 = dsl.NewFunder(out.Wallet, out.FaucetL1, out.L1EL) + out.FunderA = dsl.NewFunder(out.Wallet, out.FaucetA, out.L2ELA) + return out +} diff --git a/op-devstack/presets/sysgo_runtime.go b/op-devstack/presets/sysgo_runtime.go new file mode 100644 index 00000000000..9dee4db2fd7 --- /dev/null +++ b/op-devstack/presets/sysgo_runtime.go @@ -0,0 +1,175 @@ +package presets + +import ( + "os" + "strings" + + "github.com/ethereum/go-ethereum/common/hexutil" + gn "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/client" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +func newL1ELFrontend(t devtest.T, name string, chainID eth.ChainID, userRPC string) *l1ELFrontend { + rpcCl, err := client.NewRPC(t.Ctx(), t.Logger(), userRPC, client.WithLazyDial()) + t.Require().NoError(err) + t.Cleanup(rpcCl.Close) + return newPresetL1ELNode(t, name, chainID, rpcCl) +} + +func newL1CLFrontend(t devtest.T, name string, chainID eth.ChainID, beaconHTTPAddr string, lifecycle ...stack.Lifecycle) *l1CLFrontend { + beaconCl := client.NewBasicHTTPClient(beaconHTTPAddr, t.Logger()) + l1CL := newPresetL1CLNode(t, name, chainID, beaconCl) + if len(lifecycle) > 0 { + l1CL.lifecycle = lifecycle[0] + } + return l1CL +} + +func newL2ELFrontend(t devtest.T, name string, chainID eth.ChainID, userRPC string, engineRPC string, jwtPath string, rollupCfg *rollup.Config, lifecycle ...stack.Lifecycle) *l2ELFrontend { + userRPCCl, err := client.NewRPC(t.Ctx(), t.Logger(), userRPC, client.WithLazyDial()) + t.Require().NoError(err) + t.Cleanup(userRPCCl.Close) + jwtSecret := readJWTSecret(t, jwtPath) + engineRPCCl, err := client.NewRPC( + t.Ctx(), + t.Logger(), + engineRPC, + client.WithLazyDial(), + client.WithGethRPCOptions(rpc.WithHTTPAuth(gn.NewJWTAuth(jwtSecret))), + ) + t.Require().NoError(err) + t.Cleanup(engineRPCCl.Close) + l2EL := newPresetL2ELNode(t, name, chainID, userRPCCl, engineRPCCl, rollupCfg) + if len(lifecycle) > 0 { + l2EL.lifecycle = lifecycle[0] + } + return l2EL +} + +func readJWTSecret(t devtest.T, jwtPath string) [32]byte { + t.Require().NotEmpty(jwtPath, "missing jwt path") + content, err := os.ReadFile(jwtPath) + t.Require().NoError(err, "failed to read jwt path %s", jwtPath) + raw, err := hexutil.Decode(strings.TrimSpace(string(content))) + t.Require().NoError(err, "failed to decode jwt secret from %s", jwtPath) + t.Require().Len(raw, 32, "invalid jwt secret length from %s", jwtPath) + var secret [32]byte + copy(secret[:], raw) + return secret +} + +func newL2CLFrontend(t devtest.T, name string, chainID eth.ChainID, userRPC string, node sysgo.L2CLNode) *l2CLFrontend { + rpcCl, err := client.NewRPC(t.Ctx(), t.Logger(), userRPC, client.WithLazyDial()) + t.Require().NoError(err) + t.Cleanup(rpcCl.Close) + interopEndpoint, interopJWT := node.InteropRPC() + l2CL := newPresetL2CLNode(t, name, chainID, rpcCl, userRPC, interopEndpoint, interopJWT) + if lifecycle, ok := any(node).(stack.Lifecycle); ok { + l2CL.lifecycle = lifecycle + } + return l2CL +} + +func newL2BatcherFrontend(t devtest.T, name string, chainID eth.ChainID, rpcEndpoint string) *l2BatcherFrontend { + rpcCl, err := client.NewRPC(t.Ctx(), t.Logger(), rpcEndpoint, client.WithLazyDial()) + t.Require().NoError(err) + t.Cleanup(rpcCl.Close) + return newPresetL2Batcher(t, name, chainID, rpcCl) +} + +func newOPRBuilderFrontend(t devtest.T, name string, chainID eth.ChainID, userRPC string, flashblocksWSURL string, updateRuleSet func(string) error, rollupCfg *rollup.Config, lifecycle ...stack.Lifecycle) *oprBuilderFrontend { + rpcCl, err := client.NewRPC(t.Ctx(), t.Logger(), userRPC, client.WithLazyDial()) + t.Require().NoError(err) + t.Cleanup(rpcCl.Close) + + t.Require().NotEmpty(flashblocksWSURL, "missing flashblocks ws url for %s", name) + wsCl, err := client.DialWS(t.Ctx(), client.WSConfig{ + URL: flashblocksWSURL, + Log: t.Logger(), + }) + t.Require().NoError(err) + + oprb := newPresetOPRBuilderNode(t, name, chainID, rpcCl, rollupCfg, wsCl, updateRuleSet) + if len(lifecycle) > 0 { + oprb.lifecycle = lifecycle[0] + } + return oprb +} + +func newRollupBoostFrontend(t devtest.T, name string, chainID eth.ChainID, userRPC string, flashblocksWSURL string, rollupCfg *rollup.Config, lifecycle ...stack.Lifecycle) *rollupBoostFrontend { + rpcCl, err := client.NewRPC(t.Ctx(), t.Logger(), userRPC, client.WithLazyDial()) + t.Require().NoError(err) + t.Cleanup(rpcCl.Close) + + t.Require().NotEmpty(flashblocksWSURL, "missing flashblocks ws url for %s", name) + wsCl, err := client.DialWS(t.Ctx(), client.WSConfig{ + URL: flashblocksWSURL, + Log: t.Logger(), + }) + t.Require().NoError(err) + + rollupBoost := newPresetRollupBoostNode(t, name, chainID, rpcCl, rollupCfg, wsCl) + if len(lifecycle) > 0 { + rollupBoost.lifecycle = lifecycle[0] + } + return rollupBoost +} + +func newSupervisorFrontend(t devtest.T, name string, userRPC string, lifecycle ...stack.Lifecycle) *supervisorFrontend { + rpcCl, err := client.NewRPC(t.Ctx(), t.Logger(), userRPC, client.WithLazyDial()) + t.Require().NoError(err) + t.Cleanup(rpcCl.Close) + supervisor := newPresetSupervisor(t, name, rpcCl) + if len(lifecycle) > 0 { + supervisor.lifecycle = lifecycle[0] + } + return supervisor +} + +func newSupernodeFrontend(t devtest.T, name string, userRPC string) *supernodeFrontend { + rpcCl, err := client.NewRPC(t.Ctx(), t.Logger(), userRPC, client.WithLazyDial()) + t.Require().NoError(err) + t.Cleanup(rpcCl.Close) + return newPresetSupernode(t, name, rpcCl) +} + +func newConductorFrontend(t devtest.T, name string, chainID eth.ChainID, rpcEndpoint string) *conductorFrontend { + rpcCl, err := rpc.DialContext(t.Ctx(), rpcEndpoint) + t.Require().NoError(err) + t.Cleanup(rpcCl.Close) + return newPresetConductor(t, name, chainID, rpcCl) +} + +func newTestSequencerFrontend(t devtest.T, name string, adminRPC string, controlRPCs map[eth.ChainID]string, jwtSecret [32]byte) *testSequencerFrontend { + opts := []client.RPCOption{ + client.WithLazyDial(), + client.WithGethRPCOptions(rpc.WithHTTPAuth(gn.NewJWTAuth(jwtSecret))), + } + + adminRPCCl, err := client.NewRPC(t.Ctx(), t.Logger(), adminRPC, opts...) + t.Require().NoError(err) + t.Cleanup(adminRPCCl.Close) + + controlClients := make(map[eth.ChainID]client.RPC, len(controlRPCs)) + for chainID, endpoint := range controlRPCs { + rpcCl, err := client.NewRPC(t.Ctx(), t.Logger(), endpoint, opts...) + t.Require().NoErrorf(err, "failed to create control RPC client for chain %s", chainID) + t.Cleanup(rpcCl.Close) + controlClients[chainID] = rpcCl + } + return newPresetTestSequencer(t, name, adminRPCCl, controlClients) +} + +func newSyncTesterFrontend(t devtest.T, name string, chainID eth.ChainID, syncTesterRPC string) *syncTesterFrontend { + rpcCl, err := client.NewRPC(t.Ctx(), t.Logger(), syncTesterRPC, client.WithLazyDial()) + t.Require().NoError(err) + t.Cleanup(rpcCl.Close) + return newPresetSyncTester(t, name, chainID, syncTesterRPC, rpcCl) +} diff --git a/op-devstack/presets/timetravel.go b/op-devstack/presets/timetravel.go new file mode 100644 index 00000000000..5e548019af6 --- /dev/null +++ b/op-devstack/presets/timetravel.go @@ -0,0 +1,5 @@ +package presets + +func WithTimeTravel() Option { + return WithTimeTravelEnabled() +} diff --git a/op-devstack/presets/twol2.go b/op-devstack/presets/twol2.go new file mode 100644 index 00000000000..f383289fa14 --- /dev/null +++ b/op-devstack/presets/twol2.go @@ -0,0 +1,303 @@ +package presets + +import ( + "math/rand" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/clock" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/txplan" +) + +// TwoL2 represents a two-L2 setup without interop considerations. +// It is useful for testing components which bridge multiple L2s without necessarily using interop. +type TwoL2 struct { + Log log.Logger + T devtest.T + + L1Network *dsl.L1Network + L1EL *dsl.L1ELNode + L1CL *dsl.L1CLNode + + L2A *dsl.L2Network + L2B *dsl.L2Network + L2ACL *dsl.L2CLNode + L2BCL *dsl.L2CLNode +} + +// NewTwoL2Supernode creates a fresh TwoL2 target backed by a shared supernode for the +// current test. +func NewTwoL2Supernode(t devtest.T, opts ...Option) *TwoL2 { + presetCfg, _ := collectSupportedPresetConfig(t, "NewTwoL2Supernode", opts, twoL2SupernodePresetSupportedOptionKinds) + return twoL2SupernodeFromRuntime(t, sysgo.NewTwoL2SupernodeRuntimeWithConfig(t, presetCfg)) +} + +// TwoL2SupernodeInterop represents a two-L2 setup with a shared supernode that has interop enabled. +// This allows testing of cross-chain message verification at each timestamp. +// Use delaySeconds=0 for interop at genesis, or a positive value to test the transition. +type TwoL2SupernodeInterop struct { + TwoL2 + + // Supernode provides access to the shared supernode for interop operations + Supernode *dsl.Supernode + + // TestSequencer provides deterministic block building on both L2 chains. + // Unlike the regular sequencer which uses wall-clock time, the TestSequencer + // builds blocks at parent.Time + blockTime, making it ideal for same-timestamp tests. + TestSequencer *dsl.TestSequencer + + // L2ELA and L2ELB provide access to the EL nodes for transaction submission + L2ELA *dsl.L2ELNode + L2ELB *dsl.L2ELNode + + // L2BatcherA and L2BatcherB provide access to the batchers for pausing/resuming + L2BatcherA *dsl.L2Batcher + L2BatcherB *dsl.L2Batcher + + // Faucets for funding test accounts + FaucetA *dsl.Faucet + FaucetB *dsl.Faucet + + // Wallet for test account management + Wallet *dsl.HDWallet + + // Funders for creating funded EOAs + FunderA *dsl.Funder + FunderB *dsl.Funder + + // GenesisTime is the genesis timestamp of the L2 chains + GenesisTime uint64 + + // InteropActivationTime is the timestamp when interop becomes active + InteropActivationTime uint64 + + // DelaySeconds is the delay from genesis to interop activation + DelaySeconds uint64 + + timeTravel *clock.AdvancingClock +} + +// AdvanceTime advances the time-travel clock if enabled. +func (s *TwoL2SupernodeInterop) AdvanceTime(amount time.Duration) { + s.T.Require().NotNil(s.timeTravel, "attempting to advance time on incompatible system") + s.timeTravel.AdvanceTime(amount) +} + +// SuperNodeClient returns an API for calling supernode-specific RPC methods +// like superroot_atTimestamp. +func (s *TwoL2SupernodeInterop) SuperNodeClient() apis.SupernodeQueryAPI { + return s.Supernode.QueryAPI() +} + +// NewTwoL2SupernodeInterop creates a fresh TwoL2SupernodeInterop target for the current +// test. +func NewTwoL2SupernodeInterop(t devtest.T, delaySeconds uint64, opts ...Option) *TwoL2SupernodeInterop { + presetCfg, _ := collectSupportedPresetConfig(t, "NewTwoL2SupernodeInterop", opts, twoL2SupernodeInteropPresetSupportedOptionKinds) + return twoL2SupernodeInteropFromRuntime(t, sysgo.NewTwoL2SupernodeInteropRuntimeWithConfig(t, delaySeconds, presetCfg)) +} + +// ============================================================================= +// Same-Timestamp Test Setup +// ============================================================================= + +// SameTimestampTestSetup provides a simplified setup for same-timestamp interop testing. +// It handles all the chain synchronization, sequencer control, and interop pausing +// needed to create blocks at the same timestamp on both chains. +type SameTimestampTestSetup struct { + *TwoL2SupernodeInterop + t devtest.T + + // Alice is a funded EOA on chain A + Alice *dsl.EOA + // Bob is a funded EOA on chain B + Bob *dsl.EOA + + // EventLoggerA is the EventLogger contract address on chain A + EventLoggerA common.Address + // EventLoggerB is the EventLogger contract address on chain B + EventLoggerB common.Address + + // NextTimestamp is the timestamp that will be used for the next blocks + NextTimestamp uint64 + // ExpectedBlockNumA is the expected block number on chain A + ExpectedBlockNumA uint64 + // ExpectedBlockNumB is the expected block number on chain B + ExpectedBlockNumB uint64 +} + +// ForSameTimestampTesting sets up the system for same-timestamp interop testing. +// It syncs the chains, pauses interop, stops sequencers, and calculates expected positions. +// After calling this, you can use PrepareInitA/B to create same-timestamp message pairs. +func (s *TwoL2SupernodeInterop) ForSameTimestampTesting(t devtest.T) *SameTimestampTestSetup { + // Create funded EOAs + alice := s.FunderA.NewFundedEOA(eth.OneEther) + bob := s.FunderB.NewFundedEOA(eth.OneEther) + + // Deploy event loggers + eventLoggerA := alice.DeployEventLogger() + eventLoggerB := bob.DeployEventLogger() + + // Sync chains and pause interop + s.L2B.CatchUpTo(s.L2A) + s.L2A.CatchUpTo(s.L2B) + s.Supernode.EnsureInteropPaused(s.L2ACL, s.L2BCL, 10) + + // Stop sequencers + s.L2ACL.StopSequencer() + s.L2BCL.StopSequencer() + + // Get current state and synchronize timestamps + unsafeA := s.L2ELA.BlockRefByLabel(eth.Unsafe) + unsafeB := s.L2ELB.BlockRefByLabel(eth.Unsafe) + unsafeA, unsafeB = synchronizeChainsToSameTimestamp(t, s, unsafeA, unsafeB) + + blockTime := s.L2A.Escape().RollupConfig().BlockTime + + return &SameTimestampTestSetup{ + TwoL2SupernodeInterop: s, + t: t, + Alice: alice, + Bob: bob, + EventLoggerA: eventLoggerA, + EventLoggerB: eventLoggerB, + NextTimestamp: unsafeA.Time + blockTime, + ExpectedBlockNumA: unsafeA.Number + 1, + ExpectedBlockNumB: unsafeB.Number + 1, + } +} + +// PrepareInitA creates a precomputed init message for chain A at the next timestamp. +func (s *SameTimestampTestSetup) PrepareInitA(rng *rand.Rand, logIdx uint32) *dsl.SameTimestampPair { + return s.Alice.PrepareSameTimestampInit(rng, s.EventLoggerA, s.ExpectedBlockNumA, logIdx, s.NextTimestamp) +} + +// PrepareInitB creates a precomputed init message for chain B at the next timestamp. +func (s *SameTimestampTestSetup) PrepareInitB(rng *rand.Rand, logIdx uint32) *dsl.SameTimestampPair { + return s.Bob.PrepareSameTimestampInit(rng, s.EventLoggerB, s.ExpectedBlockNumB, logIdx, s.NextTimestamp) +} + +// IncludeAndValidate builds blocks with deterministic timestamps using the TestSequencer, +// then validates interop and checks for expected reorgs. +// +// Unlike the regular sequencer which uses wall-clock time, the TestSequencer builds blocks +// at exactly parent.Time + blockTime, ensuring the blocks are at NextTimestamp. +func (s *SameTimestampTestSetup) IncludeAndValidate(txsA, txsB []*txplan.PlannedTx, expectReplacedA, expectReplacedB bool) { + ctx := s.t.Ctx() + + require.NotNil(s.t, s.TestSequencer, "TestSequencer is required for deterministic timestamp tests") + + // Assign nonces deterministically within each same-timestamp block. Relying on + // mempool-visible pending nonces is racy across clients, and op-reth is stricter + // about underpriced replacement transactions than op-geth. + baseNonceA := s.Alice.PendingNonce() + for i, ptx := range txsA { + txplan.WithStaticNonce(baseNonceA + uint64(i))(ptx) + } + baseNonceB := s.Bob.PendingNonce() + for i, ptx := range txsB { + txplan.WithStaticNonce(baseNonceB + uint64(i))(ptx) + } + + // Get parent blocks and chain IDs + parentA := s.L2ELA.BlockRefByLabel(eth.Unsafe) + parentB := s.L2ELB.BlockRefByLabel(eth.Unsafe) + chainIDA := s.L2A.Escape().ChainID() + chainIDB := s.L2B.Escape().ChainID() + + // Extract signed transaction bytes for chain A + var rawTxsA [][]byte + var txHashesA []common.Hash + for _, ptx := range txsA { + signedTx, err := ptx.Signed.Eval(ctx) + require.NoError(s.t, err, "failed to sign transaction for chain A") + rawBytes, err := signedTx.MarshalBinary() + require.NoError(s.t, err, "failed to marshal transaction for chain A") + rawTxsA = append(rawTxsA, rawBytes) + txHashesA = append(txHashesA, signedTx.Hash()) + } + + // Extract signed transaction bytes for chain B + var rawTxsB [][]byte + var txHashesB []common.Hash + for _, ptx := range txsB { + signedTx, err := ptx.Signed.Eval(ctx) + require.NoError(s.t, err, "failed to sign transaction for chain B") + rawBytes, err := signedTx.MarshalBinary() + require.NoError(s.t, err, "failed to marshal transaction for chain B") + rawTxsB = append(rawTxsB, rawBytes) + txHashesB = append(txHashesB, signedTx.Hash()) + } + + // Build blocks at deterministic timestamps using TestSequencer + // Block timestamp will be parent.Time + blockTime = NextTimestamp + s.TestSequencer.SequenceBlockWithTxs(s.t, chainIDA, parentA.Hash, rawTxsA) + s.TestSequencer.SequenceBlockWithTxs(s.t, chainIDB, parentB.Hash, rawTxsB) + + // Get block refs by looking up the tx receipts + var blockA, blockB eth.L2BlockRef + for _, txHash := range txHashesA { + receipt := s.L2ELA.WaitForReceipt(txHash) + blockA = s.L2ELA.BlockRefByHash(receipt.BlockHash) + } + for _, txHash := range txHashesB { + receipt := s.L2ELB.WaitForReceipt(txHash) + blockB = s.L2ELB.BlockRefByHash(receipt.BlockHash) + } + + // Verify same-timestamp property: both blocks at expected timestamp + require.Equal(s.t, s.NextTimestamp, blockA.Time, + "Chain A block must be at the precomputed NextTimestamp (init message identifier uses this)") + require.Equal(s.t, s.NextTimestamp, blockB.Time, + "Chain B block must be at the precomputed NextTimestamp (exec references init at this timestamp)") + require.Equal(s.t, blockA.Time, blockB.Time, "blocks must be at same timestamp") + + // Resume interop and wait for validation + s.Supernode.ResumeInterop() + s.Supernode.AwaitValidatedTimestamp(blockA.Time) + + // Check reorg expectations + currentA := s.L2ELA.BlockRefByNumber(blockA.Number) + currentB := s.L2ELB.BlockRefByNumber(blockB.Number) + + if expectReplacedA { + require.NotEqual(s.t, blockA.Hash, currentA.Hash, "Chain A should be replaced") + } else { + require.Equal(s.t, blockA.Hash, currentA.Hash, "Chain A should NOT be replaced") + } + + if expectReplacedB { + require.NotEqual(s.t, blockB.Hash, currentB.Hash, "Chain B should be replaced") + } else { + require.Equal(s.t, blockB.Hash, currentB.Hash, "Chain B should NOT be replaced") + } +} + +// synchronizeChainsToSameTimestamp ensures both chains are at the same timestamp. +func synchronizeChainsToSameTimestamp(t devtest.T, sys *TwoL2SupernodeInterop, unsafeA, unsafeB eth.L2BlockRef) (eth.L2BlockRef, eth.L2BlockRef) { + for i := 0; i < 10; i++ { + if unsafeA.Time == unsafeB.Time { + return unsafeA, unsafeB + } + if unsafeA.Time < unsafeB.Time { + sys.L2ACL.StartSequencer() + sys.L2ELA.WaitForTime(unsafeB.Time) + sys.L2ACL.StopSequencer() + unsafeA = sys.L2ELA.BlockRefByLabel(eth.Unsafe) + } else { + sys.L2BCL.StartSequencer() + sys.L2ELB.WaitForTime(unsafeA.Time) + sys.L2BCL.StopSequencer() + unsafeB = sys.L2ELB.BlockRefByLabel(eth.Unsafe) + } + } + require.Equal(t, unsafeA.Time, unsafeB.Time, "failed to synchronize chains") + return unsafeA, unsafeB +} diff --git a/op-devstack/presets/twol2_follow_l2.go b/op-devstack/presets/twol2_follow_l2.go new file mode 100644 index 00000000000..39645c22b70 --- /dev/null +++ b/op-devstack/presets/twol2_follow_l2.go @@ -0,0 +1,25 @@ +package presets + +import ( + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" +) + +// TwoL2SupernodeFollowL2 extends TwoL2SupernodeInterop with one follow-source +// follower per chain. +type TwoL2SupernodeFollowL2 struct { + TwoL2SupernodeInterop + + L2AFollowEL *dsl.L2ELNode + L2AFollowCL *dsl.L2CLNode + L2BFollowEL *dsl.L2ELNode + L2BFollowCL *dsl.L2CLNode +} + +// NewTwoL2SupernodeFollowL2 creates a fresh follow-source variant of the two-L2 +// supernode interop preset for the current test. +func NewTwoL2SupernodeFollowL2(t devtest.T, delaySeconds uint64, opts ...Option) *TwoL2SupernodeFollowL2 { + presetCfg, _ := collectSupportedPresetConfig(t, "NewTwoL2SupernodeFollowL2", opts, twoL2SupernodeInteropPresetSupportedOptionKinds) + return twoL2SupernodeFollowL2FromRuntime(t, sysgo.NewTwoL2SupernodeFollowL2RuntimeWithConfig(t, delaySeconds, presetCfg)) +} diff --git a/op-devstack/presets/twol2_from_runtime.go b/op-devstack/presets/twol2_from_runtime.go new file mode 100644 index 00000000000..9770b3b2f05 --- /dev/null +++ b/op-devstack/presets/twol2_from_runtime.go @@ -0,0 +1,209 @@ +package presets + +import ( + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/sysgo" +) + +type twoL2RuntimeComponents struct { + l2AEL *l2ELFrontend + l2BEL *l2ELFrontend + + l2ABatcher *l2BatcherFrontend + l2BBatcher *l2BatcherFrontend + + faucetA *dsl.Faucet + faucetB *dsl.Faucet +} + +func twoL2SupernodeFromRuntime(t devtest.T, runtime *sysgo.MultiChainRuntime) *TwoL2 { + preset, _ := twoL2FromRuntime(t, runtime) + return preset +} + +func twoL2FromRuntime(t devtest.T, runtime *sysgo.MultiChainRuntime) (*TwoL2, *twoL2RuntimeComponents) { + chainA := runtime.Chains["l2a"] + chainB := runtime.Chains["l2b"] + t.Require().NotNil(chainA, "missing l2a runtime chain") + t.Require().NotNil(chainB, "missing l2b runtime chain") + l1ChainID := runtime.L1Network.ChainID() + l2AChainID := chainA.Network.ChainID() + l2BChainID := chainB.Network.ChainID() + + l1Network := newPresetL1Network(t, "l1", runtime.L1Network.ChainConfig()) + l1EL := newL1ELFrontend(t, "l1", l1ChainID, runtime.L1EL.UserRPC()) + l1CL := newL1CLFrontend(t, "l1", l1ChainID, runtime.L1CL.BeaconHTTPAddr(), runtime.L1CL.FakePoS()) + l1Network.AddL1ELNode(l1EL) + l1Network.AddL1CLNode(l1CL) + + l2A := newPresetL2Network( + t, + "l2a", + chainA.Network.ChainConfig(), + chainA.Network.RollupConfig(), + chainA.Network.Deployment(), + newKeyring(runtime.Keys, t.Require()), + l1Network, + ) + l2AEL := newL2ELFrontend(t, "sequencer", l2AChainID, chainA.EL.UserRPC(), chainA.EL.EngineRPC(), chainA.EL.JWTPath(), chainA.Network.RollupConfig(), chainA.EL) + l2ACL := newL2CLFrontend(t, "sequencer", l2AChainID, chainA.CL.UserRPC(), chainA.CL) + l2ACL.attachEL(l2AEL) + l2ABatcher := newL2BatcherFrontend(t, "main", l2AChainID, chainA.Batcher.UserRPC()) + l2A.AddL2ELNode(l2AEL) + l2A.AddL2CLNode(l2ACL) + l2A.AddL2Batcher(l2ABatcher) + + l2B := newPresetL2Network( + t, + "l2b", + chainB.Network.ChainConfig(), + chainB.Network.RollupConfig(), + chainB.Network.Deployment(), + newKeyring(runtime.Keys, t.Require()), + l1Network, + ) + l2BEL := newL2ELFrontend(t, "sequencer", l2BChainID, chainB.EL.UserRPC(), chainB.EL.EngineRPC(), chainB.EL.JWTPath(), chainB.Network.RollupConfig(), chainB.EL) + l2BCL := newL2CLFrontend(t, "sequencer", l2BChainID, chainB.CL.UserRPC(), chainB.CL) + l2BCL.attachEL(l2BEL) + l2BBatcher := newL2BatcherFrontend(t, "main", l2BChainID, chainB.Batcher.UserRPC()) + l2B.AddL2ELNode(l2BEL) + l2B.AddL2CLNode(l2BCL) + l2B.AddL2Batcher(l2BBatcher) + + faucetAFrontend := newFaucetFrontendForChain(t, runtime.FaucetService, l2AChainID) + faucetBFrontend := newFaucetFrontendForChain(t, runtime.FaucetService, l2BChainID) + l2A.AddFaucet(faucetAFrontend) + l2B.AddFaucet(faucetBFrontend) + faucetA := dsl.NewFaucet(faucetAFrontend) + faucetB := dsl.NewFaucet(faucetBFrontend) + + l1ELDSL := dsl.NewL1ELNode(l1EL) + l1CLDSL := dsl.NewL1CLNode(l1CL) + l2AELDSL := dsl.NewL2ELNode(l2AEL) + l2ACLDSL := dsl.NewL2CLNode(l2ACL) + l2BELDSL := dsl.NewL2ELNode(l2BEL) + l2BCLDSL := dsl.NewL2CLNode(l2BCL) + + preset := &TwoL2{ + Log: t.Logger(), + T: t, + L1Network: dsl.NewL1Network(l1Network, l1ELDSL, l1CLDSL), + L1EL: l1ELDSL, + L1CL: l1CLDSL, + L2A: dsl.NewL2Network(l2A, l2AELDSL, l2ACLDSL, l1ELDSL, nil, nil), + L2B: dsl.NewL2Network(l2B, l2BELDSL, l2BCLDSL, l1ELDSL, nil, nil), + L2ACL: l2ACLDSL, + L2BCL: l2BCLDSL, + } + return preset, &twoL2RuntimeComponents{ + l2AEL: l2AEL, + l2BEL: l2BEL, + l2ABatcher: l2ABatcher, + l2BBatcher: l2BBatcher, + faucetA: faucetA, + faucetB: faucetB, + } +} + +func twoL2SupernodeInteropFromRuntime(t devtest.T, runtime *sysgo.MultiChainRuntime) *TwoL2SupernodeInterop { + twoL2, components := twoL2FromRuntime(t, runtime) + + supernode := newSupernodeFrontend(t, "supernode-two-l2-system", runtime.Supernode.UserRPC()) + testSequencer := newTestSequencerFrontend( + t, + runtime.TestSequencer.Name, + runtime.TestSequencer.AdminRPC, + runtime.TestSequencer.ControlRPC, + runtime.TestSequencer.JWTSecret, + ) + + genesisTime := twoL2.L2A.Escape().RollupConfig().Genesis.L2Time + preset := &TwoL2SupernodeInterop{ + TwoL2: TwoL2{ + Log: twoL2.Log, + T: twoL2.T, + L1Network: twoL2.L1Network, + L1EL: twoL2.L1EL, + L1CL: twoL2.L1CL, + L2A: twoL2.L2A, + L2B: twoL2.L2B, + L2ACL: twoL2.L2ACL, + L2BCL: twoL2.L2BCL, + }, + Supernode: dsl.NewSupernodeWithTestControl(supernode, runtime.Supernode), + TestSequencer: dsl.NewTestSequencer(testSequencer), + L2ELA: dsl.NewL2ELNode(components.l2AEL), + L2ELB: dsl.NewL2ELNode(components.l2BEL), + L2BatcherA: dsl.NewL2Batcher(components.l2ABatcher), + L2BatcherB: dsl.NewL2Batcher(components.l2BBatcher), + FaucetA: components.faucetA, + FaucetB: components.faucetB, + Wallet: dsl.NewRandomHDWallet(t, 30), + GenesisTime: genesisTime, + InteropActivationTime: genesisTime + runtime.DelaySeconds, + DelaySeconds: runtime.DelaySeconds, + timeTravel: runtime.TimeTravel, + } + preset.FunderA = dsl.NewFunder(preset.Wallet, preset.FaucetA, preset.L2ELA) + preset.FunderB = dsl.NewFunder(preset.Wallet, preset.FaucetB, preset.L2ELB) + return preset +} + +func twoL2SupernodeFollowL2FromRuntime(t devtest.T, runtime *sysgo.MultiChainRuntime) *TwoL2SupernodeFollowL2 { + base := twoL2SupernodeInteropFromRuntime(t, runtime) + chainA := runtime.Chains["l2a"] + chainB := runtime.Chains["l2b"] + t.Require().NotNil(chainA, "missing l2a supernode chain") + t.Require().NotNil(chainB, "missing l2b supernode chain") + t.Require().NotNil(chainA.Followers, "missing l2a followers") + t.Require().NotNil(chainB.Followers, "missing l2b followers") + followerA := chainA.Followers["follower"] + followerB := chainB.Followers["follower"] + t.Require().NotNil(followerA, "missing l2a follower") + t.Require().NotNil(followerB, "missing l2b follower") + + l2AFollowEL := newL2ELFrontend( + t, + followerA.Name, + chainA.Network.ChainID(), + followerA.EL.UserRPC(), + followerA.EL.EngineRPC(), + followerA.EL.JWTPath(), + chainA.Network.RollupConfig(), + followerA.EL, + ) + l2AFollowCL := newL2CLFrontend(t, followerA.Name, chainA.Network.ChainID(), followerA.CL.UserRPC(), followerA.CL) + l2AFollowCL.attachEL(l2AFollowEL) + + l2BFollowEL := newL2ELFrontend( + t, + followerB.Name, + chainB.Network.ChainID(), + followerB.EL.UserRPC(), + followerB.EL.EngineRPC(), + followerB.EL.JWTPath(), + chainB.Network.RollupConfig(), + followerB.EL, + ) + l2BFollowCL := newL2CLFrontend(t, followerB.Name, chainB.Network.ChainID(), followerB.CL.UserRPC(), followerB.CL) + l2BFollowCL.attachEL(l2BFollowEL) + + l2ANet, ok := base.L2A.Escape().(*presetL2Network) + t.Require().True(ok, "expected preset L2 network A") + l2ANet.AddL2ELNode(l2AFollowEL) + l2ANet.AddL2CLNode(l2AFollowCL) + + l2BNet, ok := base.L2B.Escape().(*presetL2Network) + t.Require().True(ok, "expected preset L2 network B") + l2BNet.AddL2ELNode(l2BFollowEL) + l2BNet.AddL2CLNode(l2BFollowCL) + + return &TwoL2SupernodeFollowL2{ + TwoL2SupernodeInterop: *base, + L2AFollowEL: dsl.NewL2ELNode(l2AFollowEL), + L2AFollowCL: dsl.NewL2CLNode(l2AFollowCL), + L2BFollowEL: dsl.NewL2ELNode(l2BFollowEL), + L2BFollowCL: dsl.NewL2CLNode(l2BFollowCL), + } +} diff --git a/op-devstack/stack/common.go b/op-devstack/stack/common.go new file mode 100644 index 00000000000..a4408fbefee --- /dev/null +++ b/op-devstack/stack/common.go @@ -0,0 +1,21 @@ +package stack + +import ( + "github.com/ethereum/go-ethereum/log" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" +) + +type Common interface { + T() devtest.T + Logger() log.Logger + Name() string + + // Label retrieves a label by key. + // If the label does not exist, it returns an empty string. + Label(key string) string + + // SetLabel sets a label by key. + // Note that labels added by tests are not visible to other tests against the same backend. + SetLabel(key, value string) +} diff --git a/op-devstack/stack/conductor.go b/op-devstack/stack/conductor.go new file mode 100644 index 00000000000..e92aa62840a --- /dev/null +++ b/op-devstack/stack/conductor.go @@ -0,0 +1,13 @@ +package stack + +import ( + conductorRpc "github.com/ethereum-optimism/optimism/op-conductor/rpc" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +type Conductor interface { + Common + ChainID() eth.ChainID + + RpcAPI() conductorRpc.API +} diff --git a/op-devstack/stack/context.go b/op-devstack/stack/context.go new file mode 100644 index 00000000000..ab46b35f90c --- /dev/null +++ b/op-devstack/stack/context.go @@ -0,0 +1,26 @@ +package stack + +import ( + "context" + + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/log/logfilter" +) + +// ContextWithChainID annotates the context with the given chainID of service +func ContextWithChainID(ctx context.Context, chainID eth.ChainID) context.Context { + return logfilter.AddLogAttrToContext(ctx, "chainID", chainID) +} + +// ChainIDFromContext extracts the chain ID from the context. +func ChainIDFromContext(ctx context.Context) eth.ChainID { + v, _ := logfilter.ValueFromContext[eth.ChainID](ctx, "chainID") + return v +} + +// ChainIDSelector creates a log-filter that applies the given inner log-filter only if it matches the given chainID. +// This can be composed with logfilter package utils like logfilter.MuteAll or logfilter.Level +// to adjust logging for a specific chain ID. +func ChainIDSelector(chainID eth.ChainID) logfilter.Selector { + return logfilter.Select("chainID", chainID) +} diff --git a/op-devstack/stack/context_test.go b/op-devstack/stack/context_test.go new file mode 100644 index 00000000000..72f950fc239 --- /dev/null +++ b/op-devstack/stack/context_test.go @@ -0,0 +1,32 @@ +package stack + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/log" + + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/tri" +) + +func TestContext(t *testing.T) { + ctx := context.Background() + chainA := eth.ChainIDFromUInt64(900) + chainB := eth.ChainIDFromUInt64(901) + require.Equal(t, eth.ChainID{}, ChainIDFromContext(ctx), "none") + require.Equal(t, chainA, ChainIDFromContext(ContextWithChainID(ctx, chainA)), "lookup") + require.Equal(t, chainB, ChainIDFromContext(ContextWithChainID(ContextWithChainID(ctx, chainA), chainB)), "priority") +} + +func TestLogFilter(t *testing.T) { + ctx := context.Background() + chainA := eth.ChainIDFromUInt64(900) + chainB := eth.ChainIDFromUInt64(901) + fn := ChainIDSelector(chainA).Mute() + require.Equal(t, tri.Undefined, fn(ctx, log.LevelDebug), "regular context should be false") + require.Equal(t, tri.False, fn(ContextWithChainID(ctx, chainA), log.LevelDebug), "detected chain should be muted") + require.Equal(t, tri.Undefined, fn(ContextWithChainID(ctx, chainB), log.LevelDebug), "different chain should be shown") +} diff --git a/op-devstack/stack/el.go b/op-devstack/stack/el.go new file mode 100644 index 00000000000..d81fafbd5cb --- /dev/null +++ b/op-devstack/stack/el.go @@ -0,0 +1,15 @@ +package stack + +import ( + "time" + + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +type ELNode interface { + Common + ChainID() eth.ChainID + EthClient() apis.EthClient + TransactionTimeout() time.Duration +} diff --git a/op-devstack/stack/faucet.go b/op-devstack/stack/faucet.go new file mode 100644 index 00000000000..10ebf48e237 --- /dev/null +++ b/op-devstack/stack/faucet.go @@ -0,0 +1,12 @@ +package stack + +import ( + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +type Faucet interface { + Common + ChainID() eth.ChainID + API() apis.Faucet +} diff --git a/op-devstack/stack/l1_cl.go b/op-devstack/stack/l1_cl.go new file mode 100644 index 00000000000..49face44aa7 --- /dev/null +++ b/op-devstack/stack/l1_cl.go @@ -0,0 +1,15 @@ +package stack + +import ( + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +// L1CLNode is a L1 ethereum consensus-layer node, aka Beacon node. +// This node may not be a full beacon node, and instead run a mock L1 consensus node. +type L1CLNode interface { + Common + ChainID() eth.ChainID + + BeaconClient() apis.BeaconClient +} diff --git a/op-devstack/stack/l1_el.go b/op-devstack/stack/l1_el.go new file mode 100644 index 00000000000..da4441bbc1a --- /dev/null +++ b/op-devstack/stack/l1_el.go @@ -0,0 +1,6 @@ +package stack + +// L1ELNode is a L1 ethereum execution-layer node +type L1ELNode interface { + ELNode +} diff --git a/op-devstack/stack/l1_network.go b/op-devstack/stack/l1_network.go new file mode 100644 index 00000000000..60b4857f14f --- /dev/null +++ b/op-devstack/stack/l1_network.go @@ -0,0 +1,9 @@ +package stack + +// L1Network represents a L1 chain, a collection of configuration and node resources. +type L1Network interface { + Network + + L1ELNodes() []L1ELNode + L1CLNodes() []L1CLNode +} diff --git a/op-devstack/stack/l2_batcher.go b/op-devstack/stack/l2_batcher.go new file mode 100644 index 00000000000..12ddee248cc --- /dev/null +++ b/op-devstack/stack/l2_batcher.go @@ -0,0 +1,13 @@ +package stack + +import ( + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +// L2Batcher represents an L2 batch-submission service, posting L2 data of an L2 to L1. +type L2Batcher interface { + Common + ChainID() eth.ChainID + ActivityAPI() apis.BatcherActivity +} diff --git a/op-devstack/stack/l2_challenger.go b/op-devstack/stack/l2_challenger.go new file mode 100644 index 00000000000..c4d524c1245 --- /dev/null +++ b/op-devstack/stack/l2_challenger.go @@ -0,0 +1,12 @@ +package stack + +import ( + "github.com/ethereum-optimism/optimism/op-challenger/config" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +type L2Challenger interface { + Common + ChainID() eth.ChainID + Config() *config.Config +} diff --git a/op-devstack/stack/l2_cl.go b/op-devstack/stack/l2_cl.go new file mode 100644 index 00000000000..b4c64fdd9f4 --- /dev/null +++ b/op-devstack/stack/l2_cl.go @@ -0,0 +1,27 @@ +package stack + +import ( + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/client" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +// L2CLNode is a L2 ethereum consensus-layer node +type L2CLNode interface { + Common + ChainID() eth.ChainID + + ClientRPC() client.RPC + RollupAPI() apis.RollupClient + P2PAPI() apis.P2PClient + InteropRPC() (endpoint string, jwtSecret eth.Bytes32) + UserRPC() string + + // ELs returns the engine(s) that this L2CLNode is connected to. + // This may be empty, if the L2CL is not connected to any. + ELs() []L2ELNode + RollupBoostNodes() []RollupBoostNode + OPRBuilderNodes() []OPRBuilderNode + + ELClient() apis.EthClient +} diff --git a/op-devstack/stack/l2_el.go b/op-devstack/stack/l2_el.go new file mode 100644 index 00000000000..861f15b808d --- /dev/null +++ b/op-devstack/stack/l2_el.go @@ -0,0 +1,13 @@ +package stack + +import ( + "github.com/ethereum-optimism/optimism/op-service/apis" +) + +// L2ELNode is a L2 ethereum execution-layer node +type L2ELNode interface { + L2EthClient() apis.L2EthClient + L2EngineClient() apis.EngineClient + + ELNode +} diff --git a/op-devstack/stack/l2_network.go b/op-devstack/stack/l2_network.go new file mode 100644 index 00000000000..025d94bcb84 --- /dev/null +++ b/op-devstack/stack/l2_network.go @@ -0,0 +1,41 @@ +package stack + +import ( + "crypto/ecdsa" + + "github.com/ethereum/go-ethereum/common" + + "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" + "github.com/ethereum-optimism/optimism/op-node/rollup" +) + +type L2Deployment interface { + SystemConfigProxyAddr() common.Address + DisputeGameFactoryProxyAddr() common.Address + L1StandardBridgeProxyAddr() common.Address + // Other addresses will be added here later +} + +type Keys interface { + Secret(key devkeys.Key) *ecdsa.PrivateKey + Address(key devkeys.Key) common.Address +} + +// L2Network represents a L2 chain, a collection of configuration and node resources. +type L2Network interface { + Network + RollupConfig() *rollup.Config + Deployment() L2Deployment + Keys() Keys + + L1() L1Network + + L2Batchers() []L2Batcher + L2Proposers() []L2Proposer + L2Challengers() []L2Challenger + L2CLNodes() []L2CLNode + L2ELNodes() []L2ELNode + Conductors() []Conductor + RollupBoostNodes() []RollupBoostNode + OPRBuilderNodes() []OPRBuilderNode +} diff --git a/op-devstack/stack/l2_proposer.go b/op-devstack/stack/l2_proposer.go new file mode 100644 index 00000000000..27eb7fb4c97 --- /dev/null +++ b/op-devstack/stack/l2_proposer.go @@ -0,0 +1,9 @@ +package stack + +import "github.com/ethereum-optimism/optimism/op-service/eth" + +// L2Proposer is a L2 output proposer, posting claims of L2 state to L1. +type L2Proposer interface { + Common + ChainID() eth.ChainID +} diff --git a/op-devstack/stack/lifecycle.go b/op-devstack/stack/lifecycle.go new file mode 100644 index 00000000000..3aee741c34d --- /dev/null +++ b/op-devstack/stack/lifecycle.go @@ -0,0 +1,6 @@ +package stack + +type Lifecycle interface { + Start() + Stop() +} diff --git a/op-devstack/stack/network.go b/op-devstack/stack/network.go new file mode 100644 index 00000000000..aa410ac2b0d --- /dev/null +++ b/op-devstack/stack/network.go @@ -0,0 +1,22 @@ +package stack + +import ( + "github.com/ethereum/go-ethereum/params" + + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +// Network is an interface to an ethereum chain and its resources, with common properties between L1 and L2. +// For L1 or L2 specifics, see L1Network and L2Network extensions. +// A network hosts configuration resources and tracks participating nodes. +type Network interface { + Common + + ChainID() eth.ChainID + + ChainConfig() *params.ChainConfig + + Faucets() []Faucet + + SyncTesters() []SyncTester +} diff --git a/op-devstack/stack/op_rbuilder.go b/op-devstack/stack/op_rbuilder.go new file mode 100644 index 00000000000..a063acab78a --- /dev/null +++ b/op-devstack/stack/op_rbuilder.go @@ -0,0 +1,16 @@ +package stack + +import ( + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/client" +) + +// OPRBuilderNode is a L2 ethereum execution-layer node +type OPRBuilderNode interface { + L2EthClient() apis.L2EthClient + L2EngineClient() apis.EngineClient + FlashblocksClient() *client.WSClient + UpdateRuleSet(rulesYaml string) error + + ELNode +} diff --git a/op-devstack/stack/rollup_boost.go b/op-devstack/stack/rollup_boost.go new file mode 100644 index 00000000000..44fd9165e80 --- /dev/null +++ b/op-devstack/stack/rollup_boost.go @@ -0,0 +1,15 @@ +package stack + +import ( + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/client" +) + +// RollupBoostNode is a shim service between an L2 consensus-layer node and an L2 ethereum execution-layer node +type RollupBoostNode interface { + L2EthClient() apis.L2EthClient + L2EngineClient() apis.EngineClient + FlashblocksClient() *client.WSClient + + ELNode +} diff --git a/op-devstack/stack/superchain.go b/op-devstack/stack/superchain.go new file mode 100644 index 00000000000..7f90d16dd25 --- /dev/null +++ b/op-devstack/stack/superchain.go @@ -0,0 +1,10 @@ +package stack + +import ( + "github.com/ethereum/go-ethereum/common" +) + +type SuperchainDeployment interface { + ProtocolVersionsAddr() common.Address + SuperchainConfigAddr() common.Address +} diff --git a/op-devstack/stack/supernode.go b/op-devstack/stack/supernode.go new file mode 100644 index 00000000000..d9f2a84c24f --- /dev/null +++ b/op-devstack/stack/supernode.go @@ -0,0 +1,21 @@ +package stack + +import "github.com/ethereum-optimism/optimism/op-service/apis" + +type Supernode interface { + Common + QueryAPI() apis.SupernodeQueryAPI +} + +// InteropTestControl provides integration test control methods for the interop activity. +// This interface is for integration test control only. +type InteropTestControl interface { + // PauseInteropActivity pauses the interop activity at the given timestamp. + // When the interop activity attempts to process this timestamp, it returns early. + // This function is for integration test control only. + PauseInteropActivity(ts uint64) + + // ResumeInteropActivity clears any pause on the interop activity, allowing normal processing. + // This function is for integration test control only. + ResumeInteropActivity() +} diff --git a/op-devstack/stack/supervisor.go b/op-devstack/stack/supervisor.go new file mode 100644 index 00000000000..8ca5f458439 --- /dev/null +++ b/op-devstack/stack/supervisor.go @@ -0,0 +1,13 @@ +package stack + +import ( + "github.com/ethereum-optimism/optimism/op-service/apis" +) + +// Supervisor is an interop service, used to cross-verify messages between chains. +type Supervisor interface { + Common + + AdminAPI() apis.SupervisorAdminAPI + QueryAPI() apis.SupervisorQueryAPI +} diff --git a/op-devstack/stack/sync_tester.go b/op-devstack/stack/sync_tester.go new file mode 100644 index 00000000000..32239ba6f51 --- /dev/null +++ b/op-devstack/stack/sync_tester.go @@ -0,0 +1,14 @@ +package stack + +import ( + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +type SyncTester interface { + Common + ChainID() eth.ChainID + API() apis.SyncTester + + APIWithSession(sessionID string) apis.SyncTester +} diff --git a/op-devstack/stack/test_sequencer.go b/op-devstack/stack/test_sequencer.go new file mode 100644 index 00000000000..94aae09187f --- /dev/null +++ b/op-devstack/stack/test_sequencer.go @@ -0,0 +1,15 @@ +package stack + +import ( + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +// TestSequencer +type TestSequencer interface { + Common + + AdminAPI() apis.TestSequencerAdminAPI + BuildAPI() apis.TestSequencerBuildAPI + ControlAPI(chainID eth.ChainID) apis.TestSequencerControlAPI +}