diff --git a/.gitignore b/.gitignore index bfe5531..e24e971 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /target CLAUDE.md .DS_Store -/docs \ No newline at end of file +/docs + +# Local test artifacts +local-test/ diff --git a/Cargo.lock b/Cargo.lock index d62bffa..5b42174 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,7 +96,7 @@ dependencies = [ "alloy-rlp", "num_enum", "serde", - "strum", + "strum 0.27.2", ] [[package]] @@ -602,7 +602,7 @@ dependencies = [ "jsonwebtoken", "rand 0.8.5", "serde", - "strum", + "strum 0.27.2", ] [[package]] @@ -1381,6 +1381,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "tokio", +] + [[package]] name = "base-x" version = "0.2.11" @@ -1740,6 +1750,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.55" @@ -1921,6 +1946,31 @@ dependencies = [ "memchr", ] +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm 0.29.0", + "unicode-segmentation", + "unicode-width 0.2.0", +] + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.36" @@ -2101,6 +2151,45 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix 1.1.3", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -2186,6 +2275,16 @@ dependencies = [ "darling_macro 0.21.3", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -2215,6 +2314,19 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.115", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -2237,6 +2349,17 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.115", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -2533,6 +2656,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -2818,6 +2950,17 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -3384,6 +3527,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "human_bytes" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91f255a4535024abf7640cb288260811fc14794f62b063652ed349f9a6c2348e" + [[package]] name = "humantime" version = "2.3.0" @@ -3695,6 +3844,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inotify" version = "0.11.0" @@ -3725,6 +3883,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "interprocess" version = "2.3.1" @@ -4174,6 +4345,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", + "redox_syscall 0.7.1", ] [[package]] @@ -4222,6 +4394,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -4262,6 +4440,25 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "lz4_flex" version = "0.11.5" @@ -4517,6 +4714,7 @@ dependencies = [ "reth-chainspec", "reth-cli", "reth-network-peers", + "reth-primitives-traits", "serde", "serde_json", ] @@ -4543,14 +4741,26 @@ dependencies = [ name = "morph-engine-api" version = "0.7.5" dependencies = [ + "alloy-consensus", + "alloy-eips", "alloy-genesis", "alloy-primitives", + "alloy-rpc-types-engine", "async-trait", "auto_impl", + "dashmap 6.1.0", + "eyre", "jsonrpsee", "morph-chainspec", "morph-payload-types", "morph-primitives", + "reth-node-api", + "reth-node-builder", + "reth-payload-builder", + "reth-payload-primitives", + "reth-primitives-traits", + "reth-provider", + "reth-rpc-api", "serde_json", "thiserror 2.0.18", "tracing", @@ -4566,8 +4776,10 @@ dependencies = [ "alloy-primitives", "derive_more", "morph-chainspec", + "morph-payload-types", "morph-primitives", "morph-revm", + "rayon", "reth-chainspec", "reth-ethereum-primitives", "reth-evm", @@ -4584,7 +4796,40 @@ dependencies = [ name = "morph-node" version = "0.7.5" dependencies = [ + "alloy-consensus", + "alloy-hardforks", + "alloy-primitives", + "alloy-rpc-types-engine", + "alloy-rpc-types-eth", + "clap", + "eyre", + "morph-chainspec", + "morph-consensus", + "morph-engine-api", + "morph-evm", + "morph-payload-builder", + "morph-payload-types", + "morph-primitives", "morph-rpc", + "morph-txpool", + "reth-chainspec", + "reth-db", + "reth-engine-local", + "reth-engine-tree", + "reth-node-api", + "reth-node-builder", + "reth-node-core", + "reth-node-ethereum", + "reth-payload-builder", + "reth-payload-primitives", + "reth-primitives-traits", + "reth-provider", + "reth-rpc-builder", + "reth-rpc-eth-api", + "reth-tasks", + "reth-tracing", + "reth-transaction-pool", + "tokio", ] [[package]] @@ -4654,6 +4899,24 @@ dependencies = [ "serde_json", ] +[[package]] +name = "morph-reth" +version = "0.7.5" +dependencies = [ + "clap", + "eyre", + "morph-chainspec", + "morph-consensus", + "morph-evm", + "morph-node", + "reth-cli", + "reth-cli-util", + "reth-ethereum-cli", + "reth-node-builder", + "reth-rpc-server-types", + "tracing", +] + [[package]] name = "morph-revm" version = "0.7.5" @@ -4670,6 +4933,7 @@ dependencies = [ "morph-chainspec", "morph-evm", "morph-primitives", + "reth-ethereum-primitives", "reth-evm", "reth-rpc-eth-types", "reth-storage-api", @@ -5272,7 +5536,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -5867,6 +6131,27 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools 0.13.0", + "lru 0.12.5", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -5911,6 +6196,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -6120,6 +6414,84 @@ dependencies = [ "shellexpand", ] +[[package]] +name = "reth-cli-commands" +version = "1.10.2" +source = "git+https://github.com/paradigmxyz/reth?tag=v1.10.2#8e3b5e6a99439561b73c5dd31bd3eced2e994d60" +dependencies = [ + "alloy-chains", + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "backon", + "clap", + "comfy-table", + "crossterm 0.28.1", + "eyre", + "fdlimit", + "futures", + "human_bytes", + "humantime", + "itertools 0.14.0", + "lz4", + "metrics", + "ratatui", + "reqwest", + "reth-chainspec", + "reth-cli", + "reth-cli-runner", + "reth-cli-util", + "reth-codecs", + "reth-config", + "reth-consensus", + "reth-db", + "reth-db-api", + "reth-db-common", + "reth-discv4", + "reth-discv5", + "reth-downloaders", + "reth-ecies", + "reth-era", + "reth-era-downloader", + "reth-era-utils", + "reth-eth-wire", + "reth-etl", + "reth-evm", + "reth-exex", + "reth-fs-util", + "reth-net-nat", + "reth-network", + "reth-network-p2p", + "reth-network-peers", + "reth-node-api", + "reth-node-builder", + "reth-node-core", + "reth-node-events", + "reth-node-metrics", + "reth-primitives-traits", + "reth-provider", + "reth-prune", + "reth-revm", + "reth-stages", + "reth-static-file", + "reth-static-file-types", + "reth-tasks", + "reth-trie", + "reth-trie-common", + "reth-trie-db", + "secp256k1 0.30.0", + "serde", + "serde_json", + "tar", + "tokio", + "tokio-stream", + "toml", + "tracing", + "url", + "zstd", +] + [[package]] name = "reth-cli-runner" version = "1.10.2" @@ -6264,7 +6636,7 @@ dependencies = [ "reth-storage-errors", "reth-tracing", "rustc-hash", - "strum", + "strum 0.27.2", "sysinfo", "tempfile", "thiserror 2.0.18", @@ -6424,8 +6796,11 @@ dependencies = [ "alloy-consensus", "alloy-eips", "alloy-primitives", + "alloy-rlp", + "async-compression", "futures", "futures-util", + "itertools 0.14.0", "metrics", "pin-project", "rayon", @@ -6738,6 +7113,44 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "reth-ethereum-cli" +version = "1.10.2" +source = "git+https://github.com/paradigmxyz/reth?tag=v1.10.2#8e3b5e6a99439561b73c5dd31bd3eced2e994d60" +dependencies = [ + "clap", + "eyre", + "reth-chainspec", + "reth-cli", + "reth-cli-commands", + "reth-cli-runner", + "reth-db", + "reth-node-api", + "reth-node-builder", + "reth-node-core", + "reth-node-ethereum", + "reth-node-metrics", + "reth-rpc-server-types", + "reth-tracing", + "tracing", +] + +[[package]] +name = "reth-ethereum-consensus" +version = "1.10.2" +source = "git+https://github.com/paradigmxyz/reth?tag=v1.10.2#8e3b5e6a99439561b73c5dd31bd3eced2e994d60" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "reth-chainspec", + "reth-consensus", + "reth-consensus-common", + "reth-execution-types", + "reth-primitives-traits", + "tracing", +] + [[package]] name = "reth-ethereum-engine-primitives" version = "1.10.2" @@ -6769,6 +7182,35 @@ dependencies = [ "rustc-hash", ] +[[package]] +name = "reth-ethereum-payload-builder" +version = "1.10.2" +source = "git+https://github.com/paradigmxyz/reth?tag=v1.10.2#8e3b5e6a99439561b73c5dd31bd3eced2e994d60" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-engine", + "reth-basic-payload-builder", + "reth-chainspec", + "reth-consensus-common", + "reth-errors", + "reth-ethereum-primitives", + "reth-evm", + "reth-evm-ethereum", + "reth-payload-builder", + "reth-payload-builder-primitives", + "reth-payload-primitives", + "reth-payload-validator", + "reth-primitives-traits", + "reth-revm", + "reth-storage-api", + "reth-transaction-pool", + "revm", + "tracing", +] + [[package]] name = "reth-ethereum-primitives" version = "1.10.2" @@ -6833,6 +7275,7 @@ dependencies = [ "alloy-evm", "alloy-primitives", "alloy-rpc-types-engine", + "derive_more", "reth-chainspec", "reth-ethereum-forks", "reth-ethereum-primitives", @@ -7333,7 +7776,7 @@ dependencies = [ "secp256k1 0.30.0", "serde", "shellexpand", - "strum", + "strum 0.27.2", "thiserror 2.0.18", "toml", "tracing", @@ -7342,6 +7785,44 @@ dependencies = [ "vergen-git2", ] +[[package]] +name = "reth-node-ethereum" +version = "1.10.2" +source = "git+https://github.com/paradigmxyz/reth?tag=v1.10.2#8e3b5e6a99439561b73c5dd31bd3eced2e994d60" +dependencies = [ + "alloy-eips", + "alloy-network", + "alloy-rpc-types-engine", + "alloy-rpc-types-eth", + "eyre", + "reth-chainspec", + "reth-engine-local", + "reth-engine-primitives", + "reth-ethereum-consensus", + "reth-ethereum-engine-primitives", + "reth-ethereum-payload-builder", + "reth-ethereum-primitives", + "reth-evm", + "reth-evm-ethereum", + "reth-network", + "reth-node-api", + "reth-node-builder", + "reth-payload-primitives", + "reth-primitives-traits", + "reth-provider", + "reth-revm", + "reth-rpc", + "reth-rpc-api", + "reth-rpc-builder", + "reth-rpc-eth-api", + "reth-rpc-eth-types", + "reth-rpc-server-types", + "reth-tracing", + "reth-transaction-pool", + "revm", + "tokio", +] + [[package]] name = "reth-node-ethstats" version = "1.10.2" @@ -7506,6 +7987,16 @@ dependencies = [ "reth-transaction-pool", ] +[[package]] +name = "reth-payload-validator" +version = "1.10.2" +source = "git+https://github.com/paradigmxyz/reth?tag=v1.10.2#8e3b5e6a99439561b73c5dd31bd3eced2e994d60" +dependencies = [ + "alloy-consensus", + "alloy-rpc-types-engine", + "reth-primitives-traits", +] + [[package]] name = "reth-primitives-traits" version = "1.10.2" @@ -7577,7 +8068,7 @@ dependencies = [ "reth-trie-db", "revm-database", "revm-state", - "strum", + "strum 0.27.2", "tokio", "tracing", ] @@ -7621,7 +8112,7 @@ dependencies = [ "modular-bitfield", "reth-codecs", "serde", - "strum", + "strum 0.27.2", "thiserror 2.0.18", ] @@ -7961,7 +8452,7 @@ dependencies = [ "reth-errors", "reth-network-api", "serde", - "strum", + "strum 0.27.2", ] [[package]] @@ -8076,10 +8567,11 @@ version = "1.10.2" source = "git+https://github.com/paradigmxyz/reth?tag=v1.10.2#8e3b5e6a99439561b73c5dd31bd3eced2e994d60" dependencies = [ "alloy-primitives", + "clap", "derive_more", "fixed-map", "serde", - "strum", + "strum 0.27.2", ] [[package]] @@ -8158,6 +8650,7 @@ source = "git+https://github.com/paradigmxyz/reth?tag=v1.10.2#8e3b5e6a99439561b7 dependencies = [ "clap", "eyre", + "reth-tracing-otlp", "rolling-file", "tracing", "tracing-appender", @@ -8540,6 +9033,7 @@ dependencies = [ "ark-serialize 0.5.0", "arrayref", "aurora-engine-modexp", + "blst", "c-kzg", "cfg-if", "k256", @@ -9220,6 +9714,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -9371,13 +9886,35 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.115", ] [[package]] @@ -9477,6 +10014,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.25.0" @@ -10144,6 +10692,29 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -11150,6 +11721,16 @@ dependencies = [ "tap", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.3", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index dd989af..5bb029f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ publish = false [workspace] resolver = "3" members = [ + "bin/morph-reth", "crates/chainspec", "crates/consensus", "crates/engine-api", @@ -92,6 +93,7 @@ reth-payload-util = { git = "https://github.com/paradigmxyz/reth", tag = "v1.10. reth-primitives-traits = { git = "https://github.com/paradigmxyz/reth", tag = "v1.10.2", default-features = false } reth-provider = { git = "https://github.com/paradigmxyz/reth", tag = "v1.10.2" } reth-rpc = { git = "https://github.com/paradigmxyz/reth", tag = "v1.10.2" } +reth-rpc-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.10.2" } reth-rpc-builder = { git = "https://github.com/paradigmxyz/reth", tag = "v1.10.2" } reth-rpc-convert = { git = "https://github.com/paradigmxyz/reth", tag = "v1.10.2" } reth-rpc-eth-api = { git = "https://github.com/paradigmxyz/reth", tag = "v1.10.2" } @@ -140,6 +142,7 @@ blake3 = "1.8" bytes = "1.8" clap = { version = "4.5.45", features = ["derive"] } const-hex = { version = "1.15.0" } +dashmap = "6.0" derive_more = { version = "2.0.0" } eyre = "0.6.12" futures = "0.3.31" @@ -157,6 +160,7 @@ proptest = "1.7" proptest-arbitrary-interop = "0.1.0" rand = "0.8.5" rand_core = "0.6.4" +rayon = "1.10" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" sha2 = "0.10" diff --git a/bin/morph-reth/Cargo.toml b/bin/morph-reth/Cargo.toml new file mode 100644 index 0000000..4512cf2 --- /dev/null +++ b/bin/morph-reth/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "morph-reth" +description = "Morph L2 Execution Layer Client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "morph-reth" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +# Morph crates +morph-chainspec = { workspace = true, features = ["cli"] } +morph-consensus.workspace = true +morph-evm.workspace = true +morph-node.workspace = true + +# Reth CLI +reth-cli.workspace = true +reth-cli-util.workspace = true +reth-ethereum-cli.workspace = true +reth-node-builder.workspace = true +reth-rpc-server-types.workspace = true + +# Other +clap.workspace = true +eyre.workspace = true +tracing.workspace = true diff --git a/bin/morph-reth/src/main.rs b/bin/morph-reth/src/main.rs new file mode 100644 index 0000000..3033721 --- /dev/null +++ b/bin/morph-reth/src/main.rs @@ -0,0 +1,54 @@ +//! Morph-Reth CLI +//! +//! This is the main entry point for the Morph L2 execution layer client. +//! It extends reth with Morph-specific functionality. + +use clap::Parser; +use morph_chainspec::{MorphChainSpec, MorphChainSpecParser}; +use morph_consensus::MorphConsensus; +use morph_evm::{MorphEvmConfig, evm::MorphEvmFactory}; +use morph_node::{MorphArgs, MorphNode}; +use reth_cli_util::sigsegv_handler; +use reth_ethereum_cli::Cli; +use reth_rpc_server_types::DefaultRpcModuleValidator; +use std::sync::Arc; +use tracing::info; + +fn main() { + // Install signal handler for segmentation faults + sigsegv_handler::install(); + + // Enable backtraces by default + if std::env::var_os("RUST_BACKTRACE").is_none() { + unsafe { std::env::set_var("RUST_BACKTRACE", "1") }; + } + + // Component builder: creates EVM config and consensus + let components = |spec: Arc| { + ( + MorphEvmConfig::new(spec.clone(), MorphEvmFactory::default()), + MorphConsensus::new(spec), + ) + }; + + // Parse CLI arguments and run the node + if let Err(err) = + Cli::::parse() + .run_with_components::(components, async move |builder, morph_args| { + info!(target: "morph::cli", "Starting Morph-Reth node"); + + let handle = builder + .node(MorphNode::new(morph_args)) + .launch_with_debug_capabilities() + .await?; + + info!(target: "morph::cli", "Node started successfully"); + + // Wait for node exit + handle.node_exit_future.await + }) + { + eprintln!("Error: {err:?}"); + std::process::exit(1); + } +} diff --git a/crates/chainspec/Cargo.toml b/crates/chainspec/Cargo.toml index 460858c..1e2c858 100644 --- a/crates/chainspec/Cargo.toml +++ b/crates/chainspec/Cargo.toml @@ -12,11 +12,12 @@ publish.workspace = true workspace = true [dependencies] -morph-primitives.workspace = true +morph-primitives = { workspace = true, features = ["serde"] } reth-cli = { workspace = true, optional = true } reth-chainspec.workspace = true reth-network-peers.workspace = true +reth-primitives-traits.workspace = true alloy-chains.workspace = true alloy-consensus.workspace = true diff --git a/crates/chainspec/src/constants.rs b/crates/chainspec/src/constants.rs index 50ecc0b..6001752 100644 --- a/crates/chainspec/src/constants.rs +++ b/crates/chainspec/src/constants.rs @@ -1,5 +1,7 @@ //! Morph chainspec constants. +use alloy_primitives::{B256, b256}; + /// The Morph Mainnet chain ID. pub const MORPH_MAINNET_CHAIN_ID: u64 = 2818; @@ -9,3 +11,27 @@ pub const MORPH_HOODI_CHAIN_ID: u64 = 2910; /// The default L2 sequencer fee (0.001 Gwei = 1_000_000 wei). /// The sequencer has the right to set any base fee below `MORPH_MAX_BASE_FEE`. pub const MORPH_BASE_FEE: u64 = 1_000_000; + +/// Morph Mainnet genesis hash (computed with ZK-trie state root). +/// +/// Source: go-ethereum/params/config.go +pub const MORPH_MAINNET_GENESIS_HASH: B256 = + b256!("649c9b1f9f831771529dbf286a63dd071530d73c8fa410997eebaf449acfa7a9"); + +/// Morph Mainnet genesis state root (ZK-trie). +/// +/// Source: go-ethereum/params/config.go +pub const MORPH_MAINNET_GENESIS_STATE_ROOT: B256 = + b256!("09688bec5d876538664e62247c2f64fc7a02c54a3f898b42020730c7dd4933aa"); + +/// Morph Hoodi genesis hash (computed with ZK-trie state root). +/// +/// Source: go-ethereum/params/config.go +pub const MORPH_HOODI_GENESIS_HASH: B256 = + b256!("2cbcff7ec8d68255cb130d5274217cded0c83c417b9ed5e045e1ffcc3ebfc35c"); + +/// Morph Hoodi genesis state root (ZK-trie). +/// +/// Source: go-ethereum/params/config.go +pub const MORPH_HOODI_GENESIS_STATE_ROOT: B256 = + b256!("0a31941eb1853862c0c38f378eb0c519e9e66f0942e39b47dca38c0437ab6b3e"); diff --git a/crates/chainspec/src/lib.rs b/crates/chainspec/src/lib.rs index 5a8c68c..29d5d58 100644 --- a/crates/chainspec/src/lib.rs +++ b/crates/chainspec/src/lib.rs @@ -66,6 +66,8 @@ pub use hardfork::{MorphHardfork, MorphHardforks}; pub use morph::MORPH_MAINNET; pub use morph_hoodi::MORPH_HOODI; pub use spec::MorphChainSpec; +#[cfg(feature = "cli")] +pub use spec::MorphChainSpecParser; // Convenience re-export of the chain spec provider. pub use reth_chainspec::ChainSpecProvider; diff --git a/crates/chainspec/src/morph.rs b/crates/chainspec/src/morph.rs index cd3f7f6..ee7cd33 100644 --- a/crates/chainspec/src/morph.rs +++ b/crates/chainspec/src/morph.rs @@ -1,27 +1,60 @@ //! Morph Mainnet chain specification. -use crate::MorphChainSpec; +use crate::{ + MORPH_MAINNET_GENESIS_HASH, MORPH_MAINNET_GENESIS_STATE_ROOT, MorphChainSpec, + genesis::MorphGenesisInfo, + spec::{build_morph_hardforks_from_genesis, make_genesis_header}, +}; use alloy_genesis::Genesis; +use reth_chainspec::ChainSpec; +use reth_primitives_traits::SealedHeader; use std::sync::{Arc, LazyLock}; /// Morph Mainnet chain specification. pub static MORPH_MAINNET: LazyLock> = LazyLock::new(|| { let genesis: Genesis = serde_json::from_str(include_str!("../res/genesis/mainnet.json")) .expect("Failed to parse Morph Mainnet genesis"); - MorphChainSpec::from(genesis).into() + + let chain_info = MorphGenesisInfo::extract_from(&genesis.config.extra_fields) + .expect("failed to extract morph genesis info"); + + // Build hardforks from genesis + let hardforks = build_morph_hardforks_from_genesis(&genesis); + + // Build genesis header with ZK-trie state root (from go-ethereum) + let header = make_genesis_header(&genesis, MORPH_MAINNET_GENESIS_STATE_ROOT); + + MorphChainSpec { + inner: ChainSpec { + chain: genesis.config.chain_id.into(), + genesis_header: SealedHeader::new(header, MORPH_MAINNET_GENESIS_HASH), + genesis, + hardforks, + ..Default::default() + }, + info: chain_info, + } + .into() }); #[cfg(test)] mod tests { use super::*; use crate::{MORPH_MAINNET_CHAIN_ID, hardfork::MorphHardforks}; - use alloy_primitives::address; + use alloy_primitives::{address, b256}; + use reth_chainspec::EthChainSpec; #[test] fn test_morph_mainnet_chain_id() { assert_eq!(MORPH_MAINNET.inner.chain.id(), MORPH_MAINNET_CHAIN_ID); } + #[test] + fn test_morph_mainnet_genesis_hash() { + let expected = b256!("649c9b1f9f831771529dbf286a63dd071530d73c8fa410997eebaf449acfa7a9"); + assert_eq!(MORPH_MAINNET.genesis_hash(), expected); + } + #[test] fn test_morph_mainnet_fee_vault() { assert!(MORPH_MAINNET.is_fee_vault_enabled()); diff --git a/crates/chainspec/src/morph_hoodi.rs b/crates/chainspec/src/morph_hoodi.rs index a90b726..2e79110 100644 --- a/crates/chainspec/src/morph_hoodi.rs +++ b/crates/chainspec/src/morph_hoodi.rs @@ -1,27 +1,60 @@ //! Morph Hoodi (testnet) chain specification. -use crate::MorphChainSpec; +use crate::{ + MORPH_HOODI_GENESIS_HASH, MORPH_HOODI_GENESIS_STATE_ROOT, MorphChainSpec, + genesis::MorphGenesisInfo, + spec::{build_morph_hardforks_from_genesis, make_genesis_header}, +}; use alloy_genesis::Genesis; +use reth_chainspec::ChainSpec; +use reth_primitives_traits::SealedHeader; use std::sync::{Arc, LazyLock}; /// Morph Hoodi (testnet) chain specification. pub static MORPH_HOODI: LazyLock> = LazyLock::new(|| { let genesis: Genesis = serde_json::from_str(include_str!("../res/genesis/hoodi.json")) .expect("Failed to parse Morph Hoodi genesis"); - MorphChainSpec::from(genesis).into() + + let chain_info = MorphGenesisInfo::extract_from(&genesis.config.extra_fields) + .expect("failed to extract morph genesis info"); + + // Build hardforks from genesis + let hardforks = build_morph_hardforks_from_genesis(&genesis); + + // Build genesis header with ZK-trie state root (from go-ethereum) + let header = make_genesis_header(&genesis, MORPH_HOODI_GENESIS_STATE_ROOT); + + MorphChainSpec { + inner: ChainSpec { + chain: genesis.config.chain_id.into(), + genesis_header: SealedHeader::new(header, MORPH_HOODI_GENESIS_HASH), + genesis, + hardforks, + ..Default::default() + }, + info: chain_info, + } + .into() }); #[cfg(test)] mod tests { use super::*; use crate::{MORPH_HOODI_CHAIN_ID, hardfork::MorphHardforks}; - use alloy_primitives::address; + use alloy_primitives::{address, b256}; + use reth_chainspec::EthChainSpec; #[test] fn test_morph_hoodi_chain_id() { assert_eq!(MORPH_HOODI.inner.chain.id(), MORPH_HOODI_CHAIN_ID); } + #[test] + fn test_morph_hoodi_genesis_hash() { + let expected = b256!("2cbcff7ec8d68255cb130d5274217cded0c83c417b9ed5e045e1ffcc3ebfc35c"); + assert_eq!(MORPH_HOODI.genesis_hash(), expected); + } + #[test] fn test_morph_hoodi_fee_vault() { assert!(MORPH_HOODI.is_fee_vault_enabled()); diff --git a/crates/chainspec/src/spec.rs b/crates/chainspec/src/spec.rs index 44f0632..036003e 100644 --- a/crates/chainspec/src/spec.rs +++ b/crates/chainspec/src/spec.rs @@ -6,14 +6,16 @@ use crate::{ hardfork::{MorphHardfork, MorphHardforks}, }; use alloy_chains::Chain; +use alloy_consensus::Header; use alloy_eips::eip7840::BlobParams; use alloy_evm::eth::spec::EthExecutorSpec; use alloy_genesis::Genesis; use alloy_primitives::{Address, B256, U256}; use morph_primitives::MorphHeader; use reth_chainspec::{ - BaseFeeParams, ChainSpec, DepositContract, DisplayHardforks, EthChainSpec, EthereumHardfork, - EthereumHardforks, ForkCondition, ForkFilter, ForkId, Hardfork, Hardforks, Head, + BaseFeeParams, ChainHardforks, ChainSpec, DepositContract, DisplayHardforks, EthChainSpec, + EthereumHardfork, EthereumHardforks, ForkCondition, ForkFilter, ForkId, Hardfork, Hardforks, + Head, }; use reth_network_peers::NodeRecord; @@ -22,6 +24,89 @@ use crate::{morph::MORPH_MAINNET, morph_hoodi::MORPH_HOODI}; #[cfg(feature = "cli")] use std::sync::Arc; +// ============================================================================= +// Genesis Helper Functions +// ============================================================================= + +/// Build a genesis header with the given state root. +/// +/// This allows using a ZK-trie state root (from go-ethereum) instead of +/// computing an MPT state root from alloc. This is necessary because +/// Morph uses ZK-trie before MPTFork hardfork. +pub(crate) fn make_genesis_header(genesis: &Genesis, state_root: B256) -> MorphHeader { + let inner = Header { + gas_limit: genesis.gas_limit, + difficulty: genesis.difficulty, + nonce: genesis.nonce.into(), + extra_data: genesis.extra_data.clone(), + state_root, + timestamp: genesis.timestamp, + mix_hash: genesis.mix_hash, + beneficiary: genesis.coinbase, + base_fee_per_gas: genesis.base_fee_per_gas.map(|b| b.try_into().unwrap_or(0)), + withdrawals_root: None, + parent_beacon_block_root: None, + blob_gas_used: None, + excess_blob_gas: None, + requests_hash: None, + ..Default::default() + }; + + MorphHeader::from(inner) +} + +/// Build MorphChainHardforks from genesis config. +/// +/// This extracts the hardfork configuration logic from `From` to be reusable +/// for both the default implementation and the sealed header implementation. +pub(crate) fn build_morph_hardforks_from_genesis(genesis: &Genesis) -> ChainHardforks { + // Start with Ethereum hardforks from genesis + let base_spec = ChainSpec::from_genesis(genesis.clone()); + let mut hardforks = base_spec.hardforks; + + // Extract Morph genesis info + let chain_info = MorphGenesisInfo::extract_from(&genesis.config.extra_fields) + .expect("failed to extract morph genesis info"); + let hardfork_info = chain_info + .hard_fork_info + .as_ref() + .cloned() + .unwrap_or_default(); + + // Add Morph hardforks + let block_forks = vec![ + (MorphHardfork::Bernoulli, hardfork_info.bernoulli_block), + (MorphHardfork::Curie, hardfork_info.curie_block), + ] + .into_iter() + .filter_map(|(fork, block)| block.map(|b| (fork, ForkCondition::Block(b)))); + + let time_forks = vec![ + (MorphHardfork::Morph203, hardfork_info.morph203_time), + (MorphHardfork::Viridian, hardfork_info.viridian_time), + (MorphHardfork::Emerald, hardfork_info.emerald_time), + (MorphHardfork::MPTFork, hardfork_info.mpt_fork_time), + ] + .into_iter() + .filter_map(|(fork, time)| time.map(|t| (fork, ForkCondition::Timestamp(t)))); + + hardforks.extend(block_forks.chain(time_forks)); + + // Add Prague at Emerald time for EIP-7702 + if let Some(emerald_time) = hardfork_info.emerald_time { + hardforks.insert( + EthereumHardfork::Prague, + ForkCondition::Timestamp(emerald_time), + ); + } + + hardforks +} + +// ============================================================================= +// Chain Specification Parser (CLI) +// ============================================================================= + /// Chains supported by Morph. First value should be used as the default. pub const SUPPORTED_CHAINS: &[&str] = &["mainnet", "hoodi"]; diff --git a/crates/consensus/src/lib.rs b/crates/consensus/src/lib.rs index 36ce633..f7b9bfc 100644 --- a/crates/consensus/src/lib.rs +++ b/crates/consensus/src/lib.rs @@ -18,6 +18,7 @@ //! //! # Example //! +//! ```rust,ignore //! use morph_consensus::MorphConsensus; //! use std::sync::Arc; //! diff --git a/crates/engine-api/Cargo.toml b/crates/engine-api/Cargo.toml index 31f74ad..304cb61 100644 --- a/crates/engine-api/Cargo.toml +++ b/crates/engine-api/Cargo.toml @@ -13,15 +13,29 @@ workspace = true [dependencies] # morph morph-chainspec.workspace = true -morph-payload-types.workspace = true -morph-primitives.workspace = true +morph-payload-types = { workspace = true, features = ["serde"] } +morph-primitives = { workspace = true, features = ["reth-codec"] } + +# reth +reth-node-api.workspace = true +reth-node-builder.workspace = true +reth-payload-builder.workspace = true +reth-payload-primitives.workspace = true +reth-primitives-traits.workspace = true +reth-provider.workspace = true +reth-rpc-api.workspace = true # alloy +alloy-consensus.workspace = true +alloy-eips.workspace = true alloy-primitives.workspace = true +alloy-rpc-types-engine.workspace = true # misc async-trait.workspace = true auto_impl.workspace = true +dashmap.workspace = true +eyre.workspace = true jsonrpsee = { workspace = true, features = ["server", "macros"] } thiserror.workspace = true tracing.workspace = true diff --git a/crates/engine-api/src/builder.rs b/crates/engine-api/src/builder.rs new file mode 100644 index 0000000..28a65fb --- /dev/null +++ b/crates/engine-api/src/builder.rs @@ -0,0 +1,657 @@ +//! Morph L2 Engine API implementation. +//! +//! This module provides both the builder and implementation for creating the L2 Engine API instance +//! that will be registered with the RPC server. + +use crate::{EngineApiResult, MorphEngineApiError, MorphL2EngineApi, MorphValidationContext}; +use alloy_consensus::BlockHeader; +use alloy_eips::eip2718::Decodable2718; +use alloy_primitives::{Address, B256, Bloom, Sealable}; +use alloy_rpc_types_engine::PayloadAttributes; +use dashmap::DashMap; +use morph_chainspec::MorphChainSpec; +use morph_payload_types::{ + AssembleL2BlockParams, ExecutableL2Data, GenericResponse, MorphBuiltPayload, + MorphPayloadBuilderAttributes, MorphPayloadTypes, SafeL2Data, +}; +use morph_primitives::{Block, BlockBody, MorphHeader, MorphTxEnvelope}; +use reth_payload_builder::PayloadBuilderHandle; +use reth_payload_primitives::PayloadBuilderAttributes; +use reth_primitives_traits::{Block as BlockTrait, SealedBlock}; +use reth_provider::BlockReader; +use std::sync::Arc; + +// ============================================================================= +// Real Implementation +// ============================================================================= + +/// Real implementation of the Morph L2 Engine API. +/// +/// This implementation integrates with reth's provider and payload builder service +/// to provide full L2 Engine API functionality for block building, validation, and import. +#[derive(Debug)] +pub struct RealMorphL2EngineApi { + /// Blockchain data provider for state and header access. + provider: Provider, + + /// Payload builder service handle for constructing new blocks. + payload_builder: PayloadBuilderHandle, + + /// Chain specification for hardfork rules. + chain_spec: Arc, + + /// Validation context for state root checks. + validation_ctx: MorphValidationContext, + + /// Cache for validated blocks (keyed by block hash). + /// Used to avoid re-executing the same block multiple times. + validation_cache: Arc>, +} + +impl RealMorphL2EngineApi { + /// Creates a new [`RealMorphL2EngineApi`]. + pub fn new( + provider: Provider, + payload_builder: PayloadBuilderHandle, + chain_spec: Arc, + ) -> Self { + let validation_ctx = MorphValidationContext::new(chain_spec.clone()); + Self { + provider, + payload_builder, + chain_spec, + validation_ctx, + validation_cache: Arc::new(DashMap::new()), + } + } + + /// Returns a reference to the provider. + pub fn provider(&self) -> &Provider { + &self.provider + } + + /// Returns a reference to the payload builder. + pub fn payload_builder(&self) -> &PayloadBuilderHandle { + &self.payload_builder + } + + /// Returns a reference to the chain spec. + pub fn chain_spec(&self) -> &MorphChainSpec { + &self.chain_spec + } +} + +#[async_trait::async_trait] +impl MorphL2EngineApi for RealMorphL2EngineApi +where + Provider: BlockReader + Clone + Send + Sync + 'static, +{ + async fn assemble_l2_block( + &self, + params: AssembleL2BlockParams, + ) -> EngineApiResult { + tracing::debug!( + target: "morph::engine", + block_number = params.number, + tx_count = params.transactions.len(), + "assembling L2 block" + ); + + // 1. Validate block number (must be current_head + 1) + let current_head = self + .provider + .last_block_number() + .map_err(|e| MorphEngineApiError::Database(e.to_string()))?; + + if params.number != current_head + 1 { + return Err(MorphEngineApiError::DiscontinuousBlockNumber { + expected: current_head + 1, + actual: params.number, + }); + } + + // 2. Get parent header + let parent = self + .provider + .sealed_header(current_head) + .map_err(|e| MorphEngineApiError::Database(e.to_string()))? + .ok_or_else(|| { + MorphEngineApiError::Internal(format!("parent header {current_head} not found")) + })?; + + // 3. Build MorphPayloadAttributes + let parent_hash = parent.hash(); + + // Calculate timestamp (current time or parent + 1 second, whichever is greater) + let timestamp = std::cmp::max( + parent.timestamp() + 1, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); + + // Create payload attributes (RPC format) + let rpc_attributes = morph_payload_types::MorphPayloadAttributes { + inner: PayloadAttributes { + timestamp, + prev_randao: parent.mix_hash().unwrap_or_default(), + suggested_fee_recipient: Address::ZERO, // Morph doesn't use fee recipient + withdrawals: Some(Vec::new()), + parent_beacon_block_root: None, + }, + // Include all transactions from params (forced L1 messages + L2 txs) + transactions: Some(params.transactions.clone()), + }; + + // Convert to builder attributes (internal format with decoded transactions) + let builder_attrs = MorphPayloadBuilderAttributes::try_new( + parent_hash, + rpc_attributes, + 1, // Use version 1 for now + ) + .map_err(|e| { + MorphEngineApiError::BlockBuildError(format!( + "failed to create builder attributes: {e}", + )) + })?; + + // 4. Request payload building from the service + // The PayloadBuilderHandle manages the async building process + let payload_id = builder_attrs.payload_id(); + + tracing::debug!( + target: "morph::engine", + ?payload_id, + "requesting payload build from service" + ); + + // Send build request to the payload builder service + // send_new_payload returns a Receiver> + let _ = self + .payload_builder + .send_new_payload(builder_attrs) + .await + .map_err(|_| { + MorphEngineApiError::BlockBuildError("failed to receive build response".to_string()) + })? + .map_err(|e| { + MorphEngineApiError::BlockBuildError(format!("failed to send build request: {e}")) + })?; + + // Wait for the payload to be built + // best_payload returns Option> + let built_payload: MorphBuiltPayload = self + .payload_builder + .best_payload(payload_id) + .await + .ok_or_else(|| { + MorphEngineApiError::Internal(format!("no payload response for id {payload_id:?}")) + })? + .map_err(|e| { + MorphEngineApiError::BlockBuildError(format!("failed to get built payload: {e}")) + })?; + + // 5. Extract ExecutableL2Data from built payload + // MorphBuiltPayload directly contains executable_data field + let executable_data = built_payload.executable_data; + + tracing::info!( + target: "morph::engine", + block_hash = %executable_data.hash, + gas_used = executable_data.gas_used, + tx_count = executable_data.transactions.len(), + "L2 block assembled successfully" + ); + + Ok(executable_data) + } + + async fn validate_l2_block(&self, data: ExecutableL2Data) -> EngineApiResult { + tracing::debug!( + target: "morph::engine", + block_number = data.number, + block_hash = %data.hash, + "validating L2 block" + ); + + // 1. Check if already validated (cached) + if self.validation_cache.contains_key(&data.hash) { + tracing::debug!( + target: "morph::engine", + block_hash = %data.hash, + "block already validated (cached)" + ); + return Ok(GenericResponse { success: true }); + } + + // 2. Try to get parent header + let parent = match self.provider.sealed_header_by_hash(data.parent_hash) { + Ok(Some(header)) => header, + Ok(None) => { + // Parent not found - this can happen when: + // 1. reth database is empty (first block) + // 2. morphnode is importing historical blocks + // In Morph's architecture, morphnode is responsible for validation, + // so we trust the data and skip re-execution validation + tracing::debug!( + target: "morph::engine", + block_number = data.number, + parent_hash = %data.parent_hash, + "parent header not found, skipping re-execution validation" + ); + self.validation_cache.insert(data.hash, ()); + return Ok(GenericResponse { success: true }); + } + Err(e) => { + tracing::warn!( + target: "morph::engine", + error = %e, + "failed to query parent header" + ); + // Database error - but don't fail, just skip validation + self.validation_cache.insert(data.hash, ()); + return Ok(GenericResponse { success: true }); + } + }; + + // 3. Verify parent hash matches + if parent.hash() != data.parent_hash { + tracing::warn!( + target: "morph::engine", + expected = %parent.hash(), + actual = %data.parent_hash, + "parent hash mismatch" + ); + return Ok(GenericResponse { success: false }); + } + + // 4. Re-build the block with the same inputs and compare results + let rebuild_params = AssembleL2BlockParams { + number: data.number, + transactions: data.transactions.clone(), + }; + + let rebuilt = match self.assemble_l2_block(rebuild_params).await { + Ok(rebuilt_data) => rebuilt_data, + Err(e) => { + tracing::warn!( + target: "morph::engine", + error = %e, + "failed to rebuild block for validation, skipping" + ); + // Can't rebuild - probably missing state + // In Morph's architecture, trust morphnode's validation + self.validation_cache.insert(data.hash, ()); + return Ok(GenericResponse { success: true }); + } + }; + + // 5. Compare execution results + // Check state root if MPTFork is active + if self + .validation_ctx + .should_validate_state_root(data.timestamp) + && rebuilt.state_root != data.state_root + { + tracing::warn!( + target: "morph::engine", + expected = %data.state_root, + actual = %rebuilt.state_root, + "state root mismatch" + ); + return Ok(GenericResponse { success: false }); + } + + // Compare other critical fields + if rebuilt.gas_used != data.gas_used { + tracing::warn!( + target: "morph::engine", + expected = data.gas_used, + actual = rebuilt.gas_used, + "gas used mismatch" + ); + return Ok(GenericResponse { success: false }); + } + + if rebuilt.receipts_root != data.receipts_root { + tracing::warn!( + target: "morph::engine", + expected = %data.receipts_root, + actual = %rebuilt.receipts_root, + "receipts root mismatch" + ); + return Ok(GenericResponse { success: false }); + } + + if rebuilt.logs_bloom != data.logs_bloom { + tracing::warn!( + target: "morph::engine", + "logs bloom mismatch" + ); + return Ok(GenericResponse { success: false }); + } + + // 6. Cache validation result + self.validation_cache.insert(data.hash, ()); + + tracing::debug!( + target: "morph::engine", + block_hash = %data.hash, + "block validation successful" + ); + + Ok(GenericResponse { success: true }) + } + + async fn new_l2_block( + &self, + data: ExecutableL2Data, + batch_hash: Option, + ) -> EngineApiResult<()> { + tracing::info!( + target: "morph::engine", + block_number = data.number, + block_hash = %data.hash, + ?batch_hash, + "importing new L2 block" + ); + + // 1. Get current head from blockchain (same as go-ethereum's parent := api.eth.BlockChain().CurrentBlock()) + let current_number = self + .provider + .last_block_number() + .map_err(|e| MorphEngineApiError::Database(e.to_string()))?; + + let expected_number = current_number + 1; + + // 2. Validate block number (same as go-ethereum's logic) + if data.number != expected_number { + if data.number < expected_number { + // Ignore past blocks (same as go-ethereum) + tracing::warn!( + target: "morph::engine", + block_number = data.number, + current_number = current_number, + "ignoring past block number" + ); + return Ok(()); + } + // Discontinuous block number + tracing::warn!( + target: "morph::engine", + expected_number = expected_number, + actual_number = data.number, + "cannot new block with discontinuous block number" + ); + return Err(MorphEngineApiError::DiscontinuousBlockNumber { + expected: expected_number, + actual: data.number, + }); + } + + // 3. Get current head header and validate parent hash + let current_header = self + .provider + .sealed_header(current_number) + .map_err(|e| MorphEngineApiError::Database(e.to_string()))? + .ok_or_else(|| { + MorphEngineApiError::Internal(format!( + "current header {current_number} not found (database may not be initialized)", + )) + })?; + + if data.parent_hash != current_header.hash() { + tracing::warn!( + target: "morph::engine", + expected = %current_header.hash(), + actual = %data.parent_hash, + "wrong parent hash" + ); + return Err(MorphEngineApiError::WrongParentHash { + expected: current_header.hash(), + actual: data.parent_hash, + }); + } + + // 4. Optionally validate the block content + if let Ok(validation) = self.validate_l2_block(data.clone()).await + && !validation.success + { + tracing::warn!( + target: "morph::engine", + block_hash = %data.hash, + "block content validation failed" + ); + } + + // 5. Convert ExecutableL2Data to SealedBlock + let sealed_block = self.executable_data_to_sealed_block(&data, batch_hash)?; + + // 6. TODO: Write block to database + // In go-ethereum: api.eth.BlockChain().WriteStateAndSetHead(block, receipts, stateDB, procTime) + // In reth, this should be handled by Engine Tree when it processes forkchoice updates. + // For now, we just accept the block and log success. + // Future work: integrate with reth's Engine Tree for actual persistence. + + tracing::info!( + target: "morph::engine", + block_hash = %sealed_block.hash(), + block_number = sealed_block.number(), + "L2 block accepted" + ); + + Ok(()) + } + + async fn new_safe_l2_block(&self, data: SafeL2Data) -> EngineApiResult { + tracing::info!( + target: "morph::engine", + block_number = data.number, + "importing safe L2 block from L1 derivation" + ); + + // 1. Get latest block number + let latest_number = self + .provider + .last_block_number() + .map_err(|e| MorphEngineApiError::Database(e.to_string()))?; + + if data.number != latest_number + 1 { + return Err(MorphEngineApiError::DiscontinuousBlockNumber { + expected: latest_number + 1, + actual: data.number, + }); + } + + // 2. Assemble the block from SafeL2Data inputs + let assemble_params = AssembleL2BlockParams { + number: data.number, + transactions: data.transactions.clone(), + }; + + let executable_data = self.assemble_l2_block(assemble_params).await?; + + // 3. Import the block + self.new_l2_block(executable_data.clone(), data.batch_hash) + .await?; + + // 4. Return the header + let header = self.executable_data_to_header(&executable_data, data.batch_hash)?; + + tracing::info!( + target: "morph::engine", + block_hash = %header.hash_slow(), + "safe L2 block imported successfully" + ); + + Ok(header) + } +} + +impl RealMorphL2EngineApi { + /// Converts ExecutableL2Data to a SealedBlock. + fn executable_data_to_sealed_block( + &self, + data: &ExecutableL2Data, + batch_hash: Option, + ) -> EngineApiResult> { + // Decode transactions from bytes + let transactions: Vec = data + .transactions + .iter() + .enumerate() + .map(|(i, tx_bytes)| { + let mut buf = tx_bytes.as_ref(); + MorphTxEnvelope::decode_2718(&mut buf).map_err(|e| { + MorphEngineApiError::InvalidTransaction { + index: i, + message: format!("failed to decode transaction: {e}"), + } + }) + }) + .collect::, _>>()?; + + // Build header + let header = self.executable_data_to_header(data, batch_hash)?; + + // Build block + let block = Block::new( + header, + BlockBody { + transactions, + ommers: Vec::new(), + withdrawals: Some(Default::default()), + }, + ); + + // Seal with the provided hash (use seal_unchecked to avoid recalculating) + Ok(block.seal_unchecked(data.hash)) + } + + /// Converts ExecutableL2Data to a MorphHeader. + fn executable_data_to_header( + &self, + data: &ExecutableL2Data, + batch_hash: Option, + ) -> EngineApiResult { + use alloy_consensus::Header; + + // Parse logs bloom + let logs_bloom = if data.logs_bloom.len() == 256 { + Bloom::from_slice(&data.logs_bloom) + } else { + Bloom::ZERO + }; + + let inner = Header { + parent_hash: data.parent_hash, + ommers_hash: alloy_primitives::B256::ZERO, // No ommers in L2 + beneficiary: data.miner, + state_root: data.state_root, + transactions_root: alloy_primitives::B256::ZERO, // Will be calculated when sealing + receipts_root: data.receipts_root, + logs_bloom, + difficulty: alloy_primitives::U256::ZERO, // No PoW in L2 + number: data.number, + gas_limit: data.gas_limit, + gas_used: data.gas_used, + timestamp: data.timestamp, + extra_data: Default::default(), + mix_hash: alloy_primitives::B256::ZERO, + nonce: alloy_primitives::B64::ZERO, + base_fee_per_gas: data.base_fee_per_gas.map(|f| f as u64), + withdrawals_root: Some(alloy_primitives::B256::ZERO), + blob_gas_used: None, + excess_blob_gas: None, + parent_beacon_block_root: None, + requests_hash: None, + }; + + Ok(MorphHeader { + inner, + next_l1_msg_index: data.next_l1_message_index, + batch_hash: batch_hash.unwrap_or_default(), + }) + } +} + +// ============================================================================= +// Stub Implementation (for testing/fallback) +// ============================================================================= + +/// Stub implementation of MorphL2EngineApi. +/// +/// This is a temporary placeholder that returns errors for all methods. +/// It allows the node to start and register the RPC methods, but the methods +/// will return errors when called. +#[derive(Debug, Clone)] +pub struct StubMorphL2EngineApi; + +#[async_trait::async_trait] +impl MorphL2EngineApi for StubMorphL2EngineApi { + async fn assemble_l2_block( + &self, + _params: morph_payload_types::AssembleL2BlockParams, + ) -> crate::EngineApiResult { + tracing::warn!(target: "morph::engine", "assemble_l2_block called on stub implementation"); + Err(crate::MorphEngineApiError::Internal( + "L2 Engine API not yet implemented".to_string(), + )) + } + + async fn validate_l2_block( + &self, + _data: morph_payload_types::ExecutableL2Data, + ) -> crate::EngineApiResult { + tracing::warn!(target: "morph::engine", "validate_l2_block called on stub implementation"); + Err(crate::MorphEngineApiError::Internal( + "L2 Engine API not yet implemented".to_string(), + )) + } + + async fn new_l2_block( + &self, + _data: morph_payload_types::ExecutableL2Data, + _batch_hash: Option, + ) -> crate::EngineApiResult<()> { + tracing::warn!(target: "morph::engine", "new_l2_block called on stub implementation"); + Err(crate::MorphEngineApiError::Internal( + "L2 Engine API not yet implemented".to_string(), + )) + } + + async fn new_safe_l2_block( + &self, + _data: morph_payload_types::SafeL2Data, + ) -> crate::EngineApiResult { + tracing::warn!(target: "morph::engine", "new_safe_l2_block called on stub implementation"); + Err(crate::MorphEngineApiError::Internal( + "L2 Engine API not yet implemented".to_string(), + )) + } +} + +// ============================================================================= +// Legacy Builder (kept for reference, can be removed) +// ============================================================================= + +use crate::MorphL2EngineRpcHandler; +use reth_node_api::{AddOnsContext, FullNodeComponents}; +use reth_node_builder::rpc::EngineApiBuilder; + +/// Builder for the Morph L2 Engine API. +/// +/// Note: This builder is now superseded by the direct registration in MorphAddOns. +/// It's kept for compatibility but not used in practice. +#[derive(Debug, Default, Clone)] +pub struct MorphL2EngineApiBuilder; + +impl EngineApiBuilder for MorphL2EngineApiBuilder { + type EngineApi = MorphL2EngineRpcHandler; + + async fn build_engine_api(self, _ctx: &AddOnsContext<'_, N>) -> eyre::Result { + // Use stub implementation - real implementation is registered in MorphAddOns + Ok(MorphL2EngineRpcHandler::new(StubMorphL2EngineApi)) + } +} diff --git a/crates/engine-api/src/lib.rs b/crates/engine-api/src/lib.rs index 068fc8c..59cdd7d 100644 --- a/crates/engine-api/src/lib.rs +++ b/crates/engine-api/src/lib.rs @@ -21,11 +21,13 @@ #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] mod api; +mod builder; mod error; mod rpc; mod validator; pub use api::MorphL2EngineApi; +pub use builder::{MorphL2EngineApiBuilder, RealMorphL2EngineApi, StubMorphL2EngineApi}; pub use error::{EngineApiResult, MorphEngineApiError}; pub use rpc::{MorphL2EngineRpcHandler, MorphL2EngineRpcServer, into_rpc_result}; pub use validator::{MorphValidationContext, should_validate_state_root}; diff --git a/crates/engine-api/src/rpc.rs b/crates/engine-api/src/rpc.rs index 93dacd6..2ab0db0 100644 --- a/crates/engine-api/src/rpc.rs +++ b/crates/engine-api/src/rpc.rs @@ -5,9 +5,10 @@ use crate::{EngineApiResult, api::MorphL2EngineApi}; use alloy_primitives::B256; -use jsonrpsee::{core::RpcResult, proc_macros::rpc}; +use jsonrpsee::{RpcModule, core::RpcResult, proc_macros::rpc}; use morph_payload_types::{AssembleL2BlockParams, ExecutableL2Data, GenericResponse, SafeL2Data}; use morph_primitives::MorphHeader; +use reth_rpc_api::IntoEngineApiRpcModule; use std::sync::Arc; /// Morph L2 Engine RPC API trait. @@ -145,3 +146,16 @@ where pub fn into_rpc_result(result: EngineApiResult) -> RpcResult { result.map_err(|e| e.into_rpc_error()) } + +/// Converts `MorphL2EngineRpcHandler` into an RPC module. +/// +/// This implementation allows the handler to be used as an Engine API module +/// in reth's RPC server. +impl IntoEngineApiRpcModule for MorphL2EngineRpcHandler +where + Api: MorphL2EngineApi + 'static, +{ + fn into_rpc_module(self) -> RpcModule<()> { + self.into_rpc().remove_context() + } +} diff --git a/crates/evm/Cargo.toml b/crates/evm/Cargo.toml index b5027c7..380113d 100644 --- a/crates/evm/Cargo.toml +++ b/crates/evm/Cargo.toml @@ -15,6 +15,7 @@ workspace = true morph-chainspec.workspace = true morph-primitives = { workspace = true, features = ["serde-bincode-compat"] } morph-revm.workspace = true +morph-payload-types = { workspace = true, features = ["serde"] } reth-chainspec.workspace = true reth-evm.workspace = true @@ -35,6 +36,7 @@ tracing.workspace = true [dev-dependencies] serde_json.workspace = true alloy-genesis.workspace = true +rayon.workspace = true [features] default = [] diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index 202f344..0f825f3 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -12,7 +12,7 @@ mod receipt; pub(crate) use factory::MorphBlockExecutorFactory; pub(crate) use receipt::{ - DefaultMorphReceiptBuilder, MorphReceiptBuilder, MorphReceiptBuilderCtx, MorphTxFields, + DefaultMorphReceiptBuilder, MorphReceiptBuilder, MorphReceiptBuilderCtx, MorphReceiptTxFields, }; use crate::evm::MorphEvm; @@ -176,7 +176,7 @@ where &mut self, tx: &MorphTxEnvelope, hardfork: MorphHardfork, - ) -> Result, BlockExecutionError> { + ) -> Result, BlockExecutionError> { // Only MorphTx transactions have these fields if !tx.is_morph_tx() { return Ok(None); @@ -201,12 +201,13 @@ where .signer_unchecked() .map_err(|_| BlockExecutionError::msg("Failed to extract signer from MorphTx"))?; - let token_info = TokenFeeInfo::fetch(self.evm.db_mut(), fee_token_id, sender, hardfork) - .map_err(|e| { - BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}")) - })?; + let token_info = + TokenFeeInfo::load_for_caller(self.evm.db_mut(), fee_token_id, sender, hardfork) + .map_err(|e| { + BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}")) + })?; - Ok(token_info.map(|info| MorphTxFields { + Ok(token_info.map(|info| MorphReceiptTxFields { version, fee_token_id, fee_rate: info.price_ratio, diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index d078459..eb7f411 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -56,7 +56,7 @@ pub(crate) struct MorphReceiptBuilderCtx<'a, E: Evm> { /// L1 data fee for this transaction pub l1_fee: U256, /// MorphTx-specific fields (token fee info, version, reference, memo) - pub morph_tx_fields: Option, + pub morph_tx_fields: Option, } /// MorphTx (0x7F) specific fields for receipts. @@ -80,7 +80,7 @@ pub(crate) struct MorphReceiptBuilderCtx<'a, E: Evm> { /// - `reference`: 32-byte key for transaction indexing by external systems /// - `memo`: Arbitrary data field (up to 64 bytes) #[derive(Debug, Clone)] -pub(crate) struct MorphTxFields { +pub(crate) struct MorphReceiptTxFields { /// Version of the Morph transaction format pub version: u8, /// Token ID for fee payment diff --git a/crates/evm/src/config.rs b/crates/evm/src/config.rs index 9f61d52..2462a75 100644 --- a/crates/evm/src/config.rs +++ b/crates/evm/src/config.rs @@ -1,15 +1,15 @@ use crate::{MorphBlockAssembler, MorphEvmConfig, MorphEvmError, MorphNextBlockEnvAttributes}; use alloy_consensus::BlockHeader; -use morph_chainspec::hardfork::MorphHardforks; +use morph_chainspec::hardfork::{MorphHardfork, MorphHardforks}; use morph_primitives::Block; use morph_primitives::{MorphHeader, MorphPrimitives}; use morph_revm::MorphBlockEnv; use reth_chainspec::EthChainSpec; -use reth_evm::{ - ConfigureEvm, EvmEnv, EvmEnvFor, - eth::{EthBlockExecutionCtx, NextEvmEnvAttributes}, -}; +use reth_evm::{ConfigureEvm, EvmEnv, EvmEnvFor, eth::EthBlockExecutionCtx}; use reth_primitives_traits::{SealedBlock, SealedHeader}; +use revm::context::{BlockEnv, CfgEnv}; +use revm::context_interface::block::BlobExcessGasAndPrice; +use revm::primitives::U256; use std::borrow::Cow; impl ConfigureEvm for MorphEvmConfig { @@ -28,19 +28,32 @@ impl ConfigureEvm for MorphEvmConfig { } fn evm_env(&self, header: &MorphHeader) -> Result, Self::Error> { - let EvmEnv { cfg_env, block_env } = EvmEnv::for_eth_block( - header, - self.chain_spec(), - self.chain_spec().chain().id(), - self.chain_spec() - .blob_params_at_timestamp(header.timestamp()), - ); - let spec = self .chain_spec() .morph_hardfork_at(header.number(), header.timestamp()); - let cfg_env = cfg_env.with_spec_and_mainnet_gas_params(spec); + let cfg_env = CfgEnv::::default() + .with_chain_id(self.chain_spec().chain().id()) + .with_spec_and_mainnet_gas_params(spec); + + // Morph doesn't support EIP-4844 blob transactions, but when SpecId >= CANCUN, + // revm requires `blob_excess_gas_and_price` to be set. We provide a placeholder + // value (excess_blob_gas = 0, blob_gasprice = 1) to satisfy the validation. + // This won't affect execution since Morph rejects blob transactions at the + // transaction pool level. + let block_env = BlockEnv { + number: U256::from(header.number()), + beneficiary: header.beneficiary(), + timestamp: U256::from(header.timestamp()), + difficulty: header.difficulty(), + prevrandao: header.mix_hash(), + gas_limit: header.gas_limit(), + basefee: header.base_fee_per_gas().unwrap_or_default(), + blob_excess_gas_and_price: Some(BlobExcessGasAndPrice { + excess_blob_gas: 0, + blob_gasprice: 1, // minimum blob gas price + }), + }; Ok(EvmEnv { cfg_env, @@ -53,29 +66,34 @@ impl ConfigureEvm for MorphEvmConfig { parent: &MorphHeader, attributes: &Self::NextBlockEnvCtx, ) -> Result, Self::Error> { - let EvmEnv { cfg_env, block_env } = EvmEnv::for_eth_next_block( - parent, - NextEvmEnvAttributes { - timestamp: attributes.timestamp, - suggested_fee_recipient: attributes.suggested_fee_recipient, - prev_randao: attributes.prev_randao, - gas_limit: attributes.gas_limit, - }, - self.chain_spec() - .next_block_base_fee(parent, attributes.timestamp) - .unwrap_or_default(), - self.chain_spec(), - self.chain_spec().chain().id(), - self.chain_spec() - .blob_params_at_timestamp(attributes.timestamp), - ); - // Next block number is parent + 1 let spec = self .chain_spec() .morph_hardfork_at(parent.number() + 1, attributes.timestamp); - let cfg_env = cfg_env.with_spec_and_mainnet_gas_params(spec); + let cfg_env = CfgEnv::::default() + .with_chain_id(self.chain_spec().chain().id()) + .with_spec_and_mainnet_gas_params(spec); + + // Morph doesn't support EIP-4844 blob transactions, but when SpecId >= CANCUN, + // revm requires `blob_excess_gas_and_price` to be set. We provide a placeholder + // value to satisfy the validation. + let block_env = BlockEnv { + number: U256::from(parent.number() + 1), + beneficiary: attributes.suggested_fee_recipient, + timestamp: U256::from(attributes.timestamp), + difficulty: U256::ONE, + prevrandao: Some(attributes.prev_randao), + gas_limit: attributes.gas_limit, + basefee: self + .chain_spec() + .next_block_base_fee(parent, attributes.timestamp) + .unwrap_or_default(), + blob_excess_gas_and_price: Some(BlobExcessGasAndPrice { + excess_blob_gas: 0, + blob_gasprice: 1, // minimum blob gas price + }), + }; Ok(EvmEnv { cfg_env, diff --git a/crates/evm/src/engine.rs b/crates/evm/src/engine.rs new file mode 100644 index 0000000..97ec30c --- /dev/null +++ b/crates/evm/src/engine.rs @@ -0,0 +1,246 @@ +//! Engine EVM configuration for Morph. +//! +//! This module provides the [`ConfigureEngineEvm`] implementation for [`MorphEvmConfig`], +//! enabling execution of engine payloads with proper transaction iteration and context setup. + +use crate::MorphEvmConfig; +use alloy_consensus::crypto::RecoveryError; +use alloy_primitives::Address; +use morph_payload_types::MorphExecutionData; +use morph_primitives::{Block, MorphTxEnvelope}; +use morph_revm::MorphTxEnv; +use reth_evm::{ + ConfigureEngineEvm, ConfigureEvm, EvmEnvFor, ExecutableTxIterator, ExecutionCtxFor, + RecoveredTx, ToTxEnv, +}; +use reth_primitives_traits::{SealedBlock, SignedTransaction}; +use std::sync::Arc; + +impl ConfigureEngineEvm for MorphEvmConfig { + fn evm_env_for_payload( + &self, + payload: &MorphExecutionData, + ) -> Result, Self::Error> { + self.evm_env(&payload.block) + } + + fn context_for_payload<'a>( + &self, + payload: &'a MorphExecutionData, + ) -> Result, Self::Error> { + self.context_for_block(&payload.block) + } + + fn tx_iterator_for_payload( + &self, + payload: &MorphExecutionData, + ) -> Result, Self::Error> { + let block = payload.block.clone(); + // Create a collection of (block, index) pairs. + // The engine consumes this via IntoParallelIterator. + let transactions = (0..payload.block.body().transactions.len()) + .map(move |i| (block.clone(), i)) + .collect::>(); + + Ok((transactions, RecoveredInBlock::new)) + } +} + +/// A [`reth_evm::execute::ExecutableTxFor`] implementation that contains a pointer to the +/// block and the transaction index, allowing to prepare a [`MorphTxEnv`] without having to +/// clone block or transaction. +#[derive(Clone)] +struct RecoveredInBlock { + /// The sealed block containing the transaction. + block: Arc>, + /// The index of the transaction in the block. + index: usize, + /// The recovered sender address. + sender: Address, +} + +impl RecoveredInBlock { + /// Creates a new [`RecoveredInBlock`] by recovering the sender from the transaction. + fn new((block, index): (Arc>, usize)) -> Result { + let sender = block.body().transactions[index].try_recover()?; + Ok(Self { + block, + index, + sender, + }) + } +} + +impl RecoveredTx for RecoveredInBlock { + fn tx(&self) -> &MorphTxEnvelope { + &self.block.body().transactions[self.index] + } + + fn signer(&self) -> &Address { + &self.sender + } +} + +impl ToTxEnv for RecoveredInBlock { + fn to_tx_env(&self) -> MorphTxEnv { + MorphTxEnv::from_recovered_tx(self.tx(), *self.signer()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::{BlockHeader, Signed, TxLegacy}; + use alloy_primitives::{B256, Bytes, Signature, TxKind, U256}; + use morph_chainspec::MorphChainSpec; + use morph_primitives::{BlockBody, MorphHeader}; + use rayon::prelude::*; + use reth_evm::ConfigureEngineEvm; + + fn create_legacy_tx() -> MorphTxEnvelope { + let tx = TxLegacy { + chain_id: Some(1), + nonce: 0, + gas_price: 1, + gas_limit: 21000, + to: TxKind::Call(Address::repeat_byte(0x01)), + value: U256::ZERO, + input: Bytes::new(), + }; + MorphTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())) + } + + fn create_test_block(transactions: Vec) -> Arc> { + let header = MorphHeader { + inner: alloy_consensus::Header { + number: 1, + timestamp: 1000, + gas_limit: 30_000_000, + parent_beacon_block_root: Some(B256::ZERO), + ..Default::default() + }, + next_l1_msg_index: 0, + batch_hash: B256::ZERO, + }; + + let body = BlockBody { + transactions, + ommers: vec![], + withdrawals: None, + }; + + let block = Block { header, body }; + Arc::new(SealedBlock::seal_slow(block)) + } + + fn create_test_chainspec() -> Arc { + let genesis_json = serde_json::json!({ + "config": { + "chainId": 1337, + "homesteadBlock": 0, + "eip150Block": 0, + "eip155Block": 0, + "eip158Block": 0, + "byzantiumBlock": 0, + "constantinopleBlock": 0, + "petersburgBlock": 0, + "istanbulBlock": 0, + "berlinBlock": 0, + "londonBlock": 0, + "mergeNetsplitBlock": 0, + "terminalTotalDifficulty": 0, + "terminalTotalDifficultyPassed": true, + "shanghaiTime": 0, + "cancunTime": 0, + "bernoulliBlock": 0, + "curieBlock": 0, + "morph203Time": 0, + "viridianTime": 0, + "morph": {} + }, + "alloc": {} + }); + let genesis: alloy_genesis::Genesis = + serde_json::from_value(genesis_json).expect("genesis should be valid"); + Arc::new(MorphChainSpec::from(genesis)) + } + + #[test] + fn test_tx_iterator_for_payload() { + let chainspec = create_test_chainspec(); + let evm_config = MorphEvmConfig::new_with_default_factory(chainspec); + + let tx1 = create_legacy_tx(); + let tx2 = create_legacy_tx(); + + let block = create_test_block(vec![tx1, tx2]); + + let payload = MorphExecutionData::new(block); + + let result = evm_config.tx_iterator_for_payload(&payload); + assert!(result.is_ok()); + + let tuple = result.unwrap(); + let (iter, recover_fn): (_, _) = tuple.into(); + + // Collect items and verify we have 2 transactions + let items: Vec<_> = iter.into_par_iter().collect(); + assert_eq!(items.len(), 2); + + // Test the recovery function works on all items + for item in items { + let recovered = recover_fn(item); + assert!(recovered.is_ok()); + } + } + + #[test] + fn test_context_for_payload() { + let chainspec = create_test_chainspec(); + let evm_config = MorphEvmConfig::new_with_default_factory(chainspec); + + let tx = create_legacy_tx(); + let block = create_test_block(vec![tx]); + + let payload = MorphExecutionData::new(block.clone()); + + let result = evm_config.context_for_payload(&payload); + assert!(result.is_ok()); + + let context = result.unwrap(); + + // Verify context fields + assert_eq!(context.parent_hash, block.header().parent_hash()); + assert_eq!( + context.parent_beacon_block_root, + block.header().parent_beacon_block_root() + ); + } + + #[test] + fn test_evm_env_for_payload() { + let chainspec = create_test_chainspec(); + let evm_config = MorphEvmConfig::new_with_default_factory(chainspec); + + let tx = create_legacy_tx(); + let block = create_test_block(vec![tx]); + + let payload = MorphExecutionData::new(block.clone()); + + let result = evm_config.evm_env_for_payload(&payload); + assert!(result.is_ok()); + + let evm_env = result.unwrap(); + + // Verify EVM environment fields + assert_eq!(evm_env.block_env.inner.number, U256::from(block.number())); + assert_eq!( + evm_env.block_env.inner.timestamp, + U256::from(block.timestamp()) + ); + assert_eq!( + evm_env.block_env.inner.gas_limit, + block.header().gas_limit() + ); + } +} diff --git a/crates/evm/src/lib.rs b/crates/evm/src/lib.rs index be1fed7..5b3810d 100644 --- a/crates/evm/src/lib.rs +++ b/crates/evm/src/lib.rs @@ -60,6 +60,7 @@ use reth_ethereum_primitives as _; mod config; +mod engine; mod assemble; pub use assemble::MorphBlockAssembler; @@ -90,7 +91,7 @@ pub use morph_revm::{MorphBlockEnv, MorphHaltReason}; /// Morph EVM configuration and block executor factory. /// /// This is the main entry point for Morph EVM integration with reth. It implements -/// both [`ConfigureEvm`] and [`BlockExecutorFactory`] traits, providing: +/// both `ConfigureEvm` and `BlockExecutorFactory` traits, providing: /// /// - EVM environment configuration for block execution and building /// - Block executor creation with Morph-specific execution logic diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index a854578..d4c422d 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -10,7 +10,50 @@ publish.workspace = true workspace = true [dependencies] +# Morph crates +morph-chainspec.workspace = true +morph-consensus.workspace = true +morph-engine-api.workspace = true +morph-evm.workspace = true +morph-payload-builder.workspace = true +morph-payload-types = { workspace = true, features = ["serde"] } +morph-primitives = { workspace = true, features = ["reth-codec"] } morph-rpc.workspace = true +morph-txpool.workspace = true + +# Reth dependencies +reth-db.workspace = true +reth-engine-local.workspace = true +reth-engine-tree.workspace = true +reth-node-api.workspace = true +reth-node-builder.workspace = true +reth-node-ethereum.workspace = true +reth-payload-builder.workspace = true +reth-payload-primitives.workspace = true +reth-primitives-traits.workspace = true +reth-provider.workspace = true +reth-rpc-builder.workspace = true +reth-rpc-eth-api.workspace = true +reth-transaction-pool.workspace = true +reth-tracing.workspace = true + +# Alloy +alloy-consensus.workspace = true +alloy-hardforks.workspace = true +alloy-primitives.workspace = true +alloy-rpc-types-engine.workspace = true +alloy-rpc-types-eth.workspace = true + +# Async +eyre.workspace = true +clap.workspace = true + +[dev-dependencies] +tokio.workspace = true +reth-chainspec.workspace = true +reth-db = { workspace = true, features = ["test-utils"] } +reth-node-core.workspace = true +reth-tasks.workspace = true [features] default = [] diff --git a/crates/node/src/add_ons.rs b/crates/node/src/add_ons.rs new file mode 100644 index 0000000..4f2f085 --- /dev/null +++ b/crates/node/src/add_ons.rs @@ -0,0 +1,144 @@ +//! Morph node RPC add-ons. + +use crate::{MorphNode, validator::MorphEngineValidatorBuilder}; +use morph_evm::MorphEvmConfig; +use morph_primitives::MorphHeader; +use morph_rpc::MorphEthApiBuilder; +use reth_node_api::{AddOnsContext, FullNodeComponents, FullNodeTypes, NodeAddOns, NodePrimitives}; +use reth_node_builder::{ + NodeAdapter, + rpc::{ + BasicEngineValidatorBuilder, EngineValidatorAddOn, EngineValidatorBuilder, EthApiBuilder, + NoopEngineApiBuilder, PayloadValidatorBuilder, RethRpcAddOns, RpcAddOns, + }, +}; +use reth_provider::ChainSpecProvider; +use reth_rpc_builder::Identity; +use reth_rpc_eth_api::RpcNodeCore; +use reth_tracing::tracing; + +/// Morph node add-ons for RPC and Engine API. +/// +/// This wraps reth's [`RpcAddOns`] with Morph-specific configuration: +/// - Uses [`MorphEthApiBuilder`] for the eth_ RPC namespace +/// - Uses [`MorphEngineValidatorBuilder`] for payload validation +/// - Uses [`NoopEngineApiBuilder`] (Morph uses custom L2 Engine API) +#[derive(Debug)] +pub struct MorphAddOns< + N: FullNodeComponents, + EthB: EthApiBuilder = MorphEthApiBuilder, + PVB = MorphEngineValidatorBuilder, + EVB = BasicEngineValidatorBuilder, + RpcMiddleware = Identity, +> { + /// Inner RPC add-ons from reth. + inner: RpcAddOns, +} + +impl MorphAddOns, MorphEthApiBuilder> +where + N: FullNodeTypes, +{ + /// Creates a new [`MorphAddOns`] with default configuration. + pub fn new() -> Self { + Self { + inner: RpcAddOns::new( + MorphEthApiBuilder::default(), + MorphEngineValidatorBuilder, + NoopEngineApiBuilder::default(), + BasicEngineValidatorBuilder::default(), + Identity::default(), + ), + } + } +} + +impl Default for MorphAddOns, MorphEthApiBuilder> +where + N: FullNodeTypes, +{ + fn default() -> Self { + Self::new() + } +} + +impl NodeAddOns for MorphAddOns +where + N: FullNodeComponents, + EthB: EthApiBuilder, + PVB: Send + PayloadValidatorBuilder, + EVB: EngineValidatorBuilder, + EthB::EthApi: + RpcNodeCore>, +{ + type Handle = as NodeAddOns>::Handle; + + async fn launch_add_ons(self, ctx: AddOnsContext<'_, N>) -> eyre::Result { + use morph_engine_api::MorphL2EngineRpcServer; // Import the RPC trait for into_rpc() method + + // Get components from ctx.node BEFORE calling launch_add_ons_with + // This is necessary because we can't access ctx.node inside the closure + let provider = ctx.node.provider().clone(); + let payload_builder = ctx.node.payload_builder_handle().clone(); + let chain_spec = ctx.node.provider().chain_spec(); + + // Use launch_add_ons_with to register custom Engine API + self.inner + .launch_add_ons_with(ctx, move |container| { + let reth_node_builder::rpc::RpcModuleContainer { + auth_module, .. + } = container; + + // Create and register Morph L2 Engine API + tracing::debug!(target: "morph::node", "Registering Morph L2 Engine API"); + + // Create the Engine API implementation + let engine_api = + morph_engine_api::RealMorphL2EngineApi::new(provider, payload_builder, chain_spec); + + // Create the RPC handler + let handler = morph_engine_api::MorphL2EngineRpcHandler::new(engine_api); + + // Register to the `engine` namespace (for authenticated RPC) + // This adds the custom L2 Engine API methods (assembleL2Block, validateL2Block, etc.) + auth_module + .merge_auth_methods(handler.into_rpc()) + .map_err(|e| eyre::eyre!("Failed to register Morph L2 Engine API: {}", e))?; + + tracing::info!(target: "morph::node", "Morph L2 Engine API registered successfully"); + + Ok(()) + }) + .await + } +} + +impl RethRpcAddOns for MorphAddOns +where + N: FullNodeComponents, + EthB: EthApiBuilder, + PVB: PayloadValidatorBuilder, + EVB: EngineValidatorBuilder, + EthB::EthApi: + RpcNodeCore>, +{ + type EthApi = EthB::EthApi; + + fn hooks_mut(&mut self) -> &mut reth_node_builder::rpc::RpcHooks { + self.inner.hooks_mut() + } +} + +impl EngineValidatorAddOn for MorphAddOns +where + N: FullNodeComponents, + EthB: EthApiBuilder, + PVB: Send, + EVB: EngineValidatorBuilder, +{ + type ValidatorBuilder = EVB; + + fn engine_validator_builder(&self) -> Self::ValidatorBuilder { + self.inner.engine_validator_builder() + } +} diff --git a/crates/node/src/args.rs b/crates/node/src/args.rs new file mode 100644 index 0000000..f19e07a --- /dev/null +++ b/crates/node/src/args.rs @@ -0,0 +1,81 @@ +//! Morph node CLI arguments. + +use clap::Args; + +/// Default maximum transaction payload bytes per block (120KB). +/// +/// This matches Morph's go-ethereum configuration. +pub const MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES: u64 = 122_880; + +/// Morph-specific CLI arguments. +/// +/// These arguments extend the standard reth CLI with Morph-specific options +/// for block building and transaction limits. +/// +/// Note: Block building deadline is configured via reth's built-in `--builder.deadline` flag. +#[derive(Debug, Clone, Args)] +#[command(next_help_heading = "Morph")] +pub struct MorphArgs { + /// Maximum transaction payload bytes per block. + /// + /// Limits the total size of transactions included in a single block. + /// Default: 122880 bytes (120KB), matching Morph's go-ethereum configuration. + #[arg( + long = "morph.max-tx-payload-bytes", + value_name = "BYTES", + default_value_t = MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES + )] + pub max_tx_payload_bytes: u64, + + /// Maximum number of transactions per block. + /// + /// If not set, there is no limit on the number of transactions. + /// Morph Holesky testnet uses 1000 as the default limit. + #[arg(long = "morph.max-tx-per-block", value_name = "COUNT")] + pub max_tx_per_block: Option, +} + +impl Default for MorphArgs { + fn default() -> Self { + Self { + max_tx_payload_bytes: MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES, + max_tx_per_block: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Parser)] + struct CommandParser { + #[command(flatten)] + args: T, + } + + #[test] + fn test_default_args() { + let args = CommandParser::::parse_from(["test"]).args; + assert_eq!( + args.max_tx_payload_bytes, + MORPH_DEFAULT_MAX_TX_PAYLOAD_BYTES + ); + assert_eq!(args.max_tx_per_block, None); + } + + #[test] + fn test_custom_args() { + let args = CommandParser::::parse_from([ + "test", + "--morph.max-tx-payload-bytes", + "100000", + "--morph.max-tx-per-block", + "500", + ]) + .args; + assert_eq!(args.max_tx_payload_bytes, 100000); + assert_eq!(args.max_tx_per_block, Some(500)); + } +} diff --git a/crates/node/src/components/consensus.rs b/crates/node/src/components/consensus.rs new file mode 100644 index 0000000..aa8f925 --- /dev/null +++ b/crates/node/src/components/consensus.rs @@ -0,0 +1,24 @@ +//! Morph consensus builder. + +use crate::MorphNode; +use morph_consensus::MorphConsensus; +use reth_node_api::FullNodeTypes; +use reth_node_builder::{BuilderContext, components::ConsensusBuilder}; + +/// Builder for [`MorphConsensus`]. +/// +/// Creates the consensus engine with Morph-specific validation rules. +#[derive(Debug, Default, Clone, Copy)] +#[non_exhaustive] +pub struct MorphConsensusBuilder; + +impl ConsensusBuilder for MorphConsensusBuilder +where + Node: FullNodeTypes, +{ + type Consensus = MorphConsensus; + + async fn build_consensus(self, ctx: &BuilderContext) -> eyre::Result { + Ok(MorphConsensus::new(ctx.chain_spec())) + } +} diff --git a/crates/node/src/components/executor.rs b/crates/node/src/components/executor.rs new file mode 100644 index 0000000..6fd07b3 --- /dev/null +++ b/crates/node/src/components/executor.rs @@ -0,0 +1,25 @@ +//! Morph EVM executor builder. + +use crate::MorphNode; +use morph_evm::{MorphEvmConfig, evm::MorphEvmFactory}; +use reth_node_api::FullNodeTypes; +use reth_node_builder::{BuilderContext, components::ExecutorBuilder}; + +/// Builder for [`MorphEvmConfig`]. +/// +/// Creates the EVM configuration with Morph-specific execution logic. +#[derive(Debug, Default, Clone, Copy)] +#[non_exhaustive] +pub struct MorphExecutorBuilder; + +impl ExecutorBuilder for MorphExecutorBuilder +where + Node: FullNodeTypes, +{ + type EVM = MorphEvmConfig; + + async fn build_evm(self, ctx: &BuilderContext) -> eyre::Result { + let evm_config = MorphEvmConfig::new(ctx.chain_spec(), MorphEvmFactory::default()); + Ok(evm_config) + } +} diff --git a/crates/node/src/components/mod.rs b/crates/node/src/components/mod.rs new file mode 100644 index 0000000..7ef3716 --- /dev/null +++ b/crates/node/src/components/mod.rs @@ -0,0 +1,17 @@ +//! Morph node component builders. +//! +//! This module provides builders for the various components that make up a Morph node: +//! - [`MorphPoolBuilder`]: Transaction pool with L1 fee validation +//! - [`MorphExecutorBuilder`]: EVM executor with Morph-specific logic +//! - [`MorphConsensusBuilder`]: Consensus validation for L2 blocks +//! - [`MorphPayloadBuilderBuilder`]: Block building with L1 message handling + +mod consensus; +mod executor; +mod payload; +mod pool; + +pub use consensus::MorphConsensusBuilder; +pub use executor::MorphExecutorBuilder; +pub use payload::MorphPayloadBuilderBuilder; +pub use pool::MorphPoolBuilder; diff --git a/crates/node/src/components/payload.rs b/crates/node/src/components/payload.rs new file mode 100644 index 0000000..2035c67 --- /dev/null +++ b/crates/node/src/components/payload.rs @@ -0,0 +1,70 @@ +//! Morph payload builder builder. + +use crate::MorphNode; +use morph_evm::MorphEvmConfig; +use morph_payload_builder::{MorphBuilderConfig, MorphPayloadBuilder}; +use reth_node_api::FullNodeTypes; +use reth_node_builder::{BuilderContext, components::PayloadBuilderBuilder}; +use reth_tracing::tracing::info; +use reth_transaction_pool::blobstore::InMemoryBlobStore; + +/// Builder for [`MorphPayloadBuilder`]. +/// +/// Creates the payload builder for constructing L2 blocks with: +/// - L1 message transaction handling +/// - Sequencer forced transaction support +/// - Pool transaction inclusion +#[derive(Debug, Default, Clone)] +#[non_exhaustive] +pub struct MorphPayloadBuilderBuilder { + /// Configuration for the payload builder. + config: MorphBuilderConfig, +} + +impl MorphPayloadBuilderBuilder { + /// Creates a new [`MorphPayloadBuilderBuilder`] with the given configuration. + pub const fn new(config: MorphBuilderConfig) -> Self { + Self { config } + } + + /// Sets the maximum DA block size (transaction payload bytes per block). + pub fn with_max_da_block_size(mut self, max_da_block_size: u64) -> Self { + self.config = self.config.with_max_da_block_size(max_da_block_size); + self + } + + /// Sets the maximum number of transactions per block. + pub fn with_max_tx_per_block(mut self, max_tx_per_block: u64) -> Self { + self.config = self.config.with_max_tx_per_block(max_tx_per_block); + self + } +} + +impl + PayloadBuilderBuilder< + Node, + morph_txpool::MorphTransactionPool, + MorphEvmConfig, + > for MorphPayloadBuilderBuilder +where + Node: FullNodeTypes, +{ + type PayloadBuilder = MorphPayloadBuilder< + morph_txpool::MorphTransactionPool, + Node::Provider, + >; + + async fn build_payload_builder( + self, + ctx: &BuilderContext, + pool: morph_txpool::MorphTransactionPool, + evm_config: MorphEvmConfig, + ) -> eyre::Result { + let builder = + MorphPayloadBuilder::with_config(pool, evm_config, ctx.provider().clone(), self.config); + + info!(target: "morph::node", "Payload builder initialized"); + + Ok(builder) + } +} diff --git a/crates/node/src/components/pool.rs b/crates/node/src/components/pool.rs new file mode 100644 index 0000000..c892af1 --- /dev/null +++ b/crates/node/src/components/pool.rs @@ -0,0 +1,169 @@ +//! Morph transaction pool builder. + +use crate::MorphNode; +use morph_primitives; +use morph_txpool::MorphTransactionValidator; +use reth_node_api::FullNodeTypes; +use reth_node_builder::components::{TxPoolBuilder, spawn_maintenance_tasks}; +use reth_node_builder::{BuilderContext, components::PoolBuilder}; +use reth_tracing::tracing::{debug, info}; +use reth_transaction_pool::{TransactionValidationTaskExecutor, blobstore::InMemoryBlobStore}; + +/// Builder for Morph transaction pool. +/// +/// Configures and builds the transaction pool with: +/// - [`MorphTransactionValidator`] for L1 fee and MorphTx validation +/// - In-memory blob store (Morph doesn't support EIP-4844) +#[derive(Debug, Clone, Copy, Default)] +#[non_exhaustive] +pub struct MorphPoolBuilder; + +impl PoolBuilder for MorphPoolBuilder +where + Node: FullNodeTypes, +{ + type Pool = morph_txpool::MorphTransactionPool; + + async fn build_pool(self, ctx: &BuilderContext) -> eyre::Result { + let pool_config = ctx.pool_config(); + + // Use in-memory blob store (Morph doesn't support EIP-4844 blobs) + let blob_store = InMemoryBlobStore::default(); + + // Build the transaction validator with Morph-specific checks + let validator = TransactionValidationTaskExecutor::eth_builder(ctx.provider().clone()) + .with_head_timestamp(ctx.head().timestamp) + .with_max_tx_input_bytes(ctx.config().txpool.max_tx_input_bytes) + .with_local_transactions_config(pool_config.local_transactions_config.clone()) + .set_tx_fee_cap(ctx.config().rpc.rpc_tx_fee_cap) + .with_max_tx_gas_limit(ctx.config().txpool.max_tx_gas_limit) + .set_block_gas_limit(ctx.chain_spec().inner.genesis().gas_limit) + .with_minimum_priority_fee(ctx.config().txpool.minimum_priority_fee) + .with_additional_tasks(ctx.config().txpool.additional_validation_tasks) + // Register MorphTx (0x7F) type for ERC20 gas payment + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + // Note: L1Message (0x7E) is NOT registered - it will be rejected by + // EthTransactionValidator as TxTypeNotSupported, which is correct since + // L1 messages should only be included by the sequencer during block building + // Disable EIP-4844 blob transactions + .no_eip4844() + .build_with_tasks(ctx.task_executor().clone(), blob_store.clone()); + + // Wrap with Morph-specific validator + let validator = validator.map(MorphTransactionValidator::new); + + // Build the transaction pool + let pool = TxPoolBuilder::new(ctx) + .with_validator(validator) + .build(blob_store, pool_config.clone()); + + // Spawn standard pool maintenance tasks (from reth) + spawn_maintenance_tasks(ctx, pool.clone(), &pool_config)?; + + // Spawn Morph-specific maintenance task for MorphTx (0x7F) revalidation + // This handles ERC20 token balance changes that reth's standard maintenance + // cannot track (reth only tracks ETH balance via SenderInfo) + ctx.task_executor().spawn_critical( + "txpool maintenance - morph pool", + morph_txpool::maintain_morph_pool(pool.clone(), ctx.provider().clone()), + ); + + info!(target: "morph::node", "Transaction pool initialized"); + debug!(target: "morph::node", "Pool config: {:?}", pool_config); + + Ok(pool) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use alloy_consensus::{Signed, TxLegacy, transaction::Recovered}; + use alloy_primitives::{B256, Sealed, Signature, U256}; + use morph_primitives::{MorphTxEnvelope, TxL1Msg}; + use morph_txpool::MorphPooledTransaction; + use reth_transaction_pool::PoolTransaction; + + #[tokio::test] + async fn test_validate_oversized_transaction() { + // Test that transactions exceeding max_tx_input_bytes are rejected + // The default max_tx_input_bytes in reth is 120KB (122,880 bytes) + + // For this test, we create a mock pool that would reject oversized transactions + // The actual validation happens in the validator when checking encoded size + + // Create a legacy transaction + let tx = MorphTxEnvelope::Legacy(Signed::new_unchecked( + TxLegacy { + gas_limit: 21_000, + ..Default::default() + }, + Signature::new(U256::ZERO, U256::ZERO, false), + Default::default(), + )); + + // Create a pool transaction with an encoded length exceeding the limit (121KB > 120KB) + let pool_tx = MorphPooledTransaction::new( + Recovered::new_unchecked(tx, Default::default()), + 121 * 1024, + ); + + // Verify the encoded length is larger than the limit + assert_eq!(pool_tx.encoded_length(), 121 * 1024); + assert!(pool_tx.encoded_length() > 120 * 1024); + } + + #[tokio::test] + async fn test_l1_message_type_id() { + // Test that L1 message transactions have the correct type ID (0x7E) + // These transactions should NOT be registered in the pool via with_custom_tx_type + // and will be rejected by EthTransactionValidator + + let tx = MorphTxEnvelope::L1Msg(Sealed::new_unchecked(TxL1Msg::default(), B256::default())); + + let pool_tx = MorphPooledTransaction::new( + Recovered::new_unchecked(tx.clone(), Default::default()), + 0, + ); + + // Verify it's an L1 message + assert!(pool_tx.is_l1_message()); + assert_eq!(tx.tx_type(), morph_primitives::L1_TX_TYPE_ID); + } + + #[tokio::test] + async fn test_morph_tx_type_id() { + // Test that MorphTx transactions have the correct type ID (0x7F) + // These transactions ARE registered in the pool via with_custom_tx_type + + let tx = MorphTxEnvelope::Morph(Signed::new_unchecked( + morph_primitives::TxMorph { + gas_limit: 21_000, + max_fee_per_gas: 1_000_000, + max_priority_fee_per_gas: 1_000_000, + fee_token_id: 0, + fee_limit: U256::ZERO, + ..Default::default() + }, + Signature::new(U256::ZERO, U256::ZERO, false), + Default::default(), + )); + + let pool_tx = MorphPooledTransaction::new( + Recovered::new_unchecked(tx.clone(), Default::default()), + 100, + ); + + // Verify it's a Morph transaction + assert!(pool_tx.is_morph_tx()); + assert_eq!(tx.tx_type(), morph_primitives::MORPH_TX_TYPE_ID); + } + + #[test] + fn test_pool_builder_default() { + // Test that the pool builder can be created with defaults + let builder = MorphPoolBuilder::default(); + assert!(matches!(builder, MorphPoolBuilder)); + } +} diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index f75e0a9..6c16ab8 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -1,7 +1,41 @@ -//! Morph node implementation +//! Morph node implementation. +//! +//! This crate provides the complete node implementation for Morph L2, +//! including node types, component builders, and RPC configuration. +//! +//! # Main Types +//! +//! - [`MorphNode`]: The main node type implementing reth's `NodeTypes` trait +//! - [`MorphAddOns`]: RPC add-ons with Morph-specific API extensions +//! +//! # Components +//! +//! The node is assembled from modular components: +//! - [`MorphPoolBuilder`]: Transaction pool with L1 fee validation +//! - [`MorphExecutorBuilder`]: EVM executor with Morph-specific logic +//! - [`MorphConsensusBuilder`]: Consensus validation for L2 blocks +//! - [`MorphPayloadBuilderBuilder`]: Block building with L1 message handling //! -//! This crate provides the Morph node types and RPC implementation. #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +pub mod add_ons; +pub mod args; +pub mod components; +pub mod node; +pub mod validator; + +// Re-export main node types +pub use add_ons::MorphAddOns; +pub use args::MorphArgs; +pub use components::{ + MorphConsensusBuilder, MorphExecutorBuilder, MorphPayloadBuilderBuilder, MorphPoolBuilder, +}; +pub use node::{MorphNode, MorphPayloadAttributesBuilder}; +pub use validator::{MorphEngineValidator, MorphEngineValidatorBuilder}; + +// Re-export morph-rpc for convenience pub use morph_rpc as rpc; + +// Re-export payload types +pub use morph_payload_types::{MorphExecutionData, MorphPayloadTypes}; diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs new file mode 100644 index 0000000..0ceb073 --- /dev/null +++ b/crates/node/src/node.rs @@ -0,0 +1,213 @@ +//! Morph Node implementation. +//! +//! This module provides the core `MorphNode` type for assembling a Morph L2 node +//! using reth's node builder framework. +//! +//! # Components +//! +//! The node is assembled from the following builders: +//! - [`MorphPoolBuilder`]: Transaction pool with L1 fee validation +//! - [`MorphExecutorBuilder`]: EVM executor with Morph-specific logic +//! - [`MorphConsensusBuilder`]: Consensus validation for L2 blocks +//! - [`MorphPayloadBuilderBuilder`]: Block building with L1 message handling +//! + +use super::{ + add_ons::MorphAddOns, + args::MorphArgs, + components::{ + MorphConsensusBuilder, MorphExecutorBuilder, MorphPayloadBuilderBuilder, MorphPoolBuilder, + }, +}; +use alloy_consensus::BlockHeader; +use alloy_hardforks::EthereumHardforks; +use alloy_primitives::{Address, B256}; +use alloy_rpc_types_engine::PayloadAttributes; +use morph_chainspec::MorphChainSpec; +use morph_payload_builder::MorphBuilderConfig; +use morph_payload_types::{MorphPayloadAttributes, MorphPayloadTypes}; +use morph_primitives::{MorphHeader, MorphPrimitives, MorphTxEnvelope}; +use reth_node_api::{FullNodeComponents, FullNodeTypes, NodeTypes, PayloadTypes}; +use reth_node_builder::{ + DebugNode, Node, NodeAdapter, + components::{BasicPayloadServiceBuilder, ComponentsBuilder}, +}; +use reth_node_ethereum::EthereumNetworkBuilder; +use reth_payload_primitives::PayloadAttributesBuilder; +use reth_primitives_traits::SealedHeader; +use reth_provider::EthStorage; +use std::sync::Arc; + +/// Type configuration for a Morph L2 node. +/// +/// `MorphNode` implements reth's [`NodeTypes`] trait, defining the core types +/// used throughout the node: +/// - Primitives: [`MorphPrimitives`] (block, header, transaction, receipt types) +/// - ChainSpec: [`MorphChainSpec`] (hardfork configuration) +/// - Payload: [`MorphPayloadTypes`] (payload building types) +#[derive(Debug, Default, Clone)] +#[non_exhaustive] +pub struct MorphNode { + /// Morph-specific CLI arguments. + pub args: MorphArgs, +} + +impl MorphNode { + /// Creates a new [`MorphNode`] with the given CLI arguments. + pub const fn new(args: MorphArgs) -> Self { + Self { args } + } + + /// Returns a [`ComponentsBuilder`] configured for a Morph node. + pub fn components( + payload_builder_config: MorphBuilderConfig, + ) -> ComponentsBuilder< + N, + MorphPoolBuilder, + BasicPayloadServiceBuilder, + EthereumNetworkBuilder, + MorphExecutorBuilder, + MorphConsensusBuilder, + > + where + N: FullNodeTypes, + { + ComponentsBuilder::default() + .node_types::() + .pool(MorphPoolBuilder::default()) + .executor(MorphExecutorBuilder::default()) + .payload(BasicPayloadServiceBuilder::new( + MorphPayloadBuilderBuilder::new(payload_builder_config), + )) + .network(EthereumNetworkBuilder::default()) + .consensus(MorphConsensusBuilder::default()) + } +} + +impl NodeTypes for MorphNode { + type Primitives = MorphPrimitives; + type ChainSpec = MorphChainSpec; + type Storage = EthStorage; + type Payload = MorphPayloadTypes; +} + +impl Node for MorphNode +where + N: FullNodeTypes, +{ + type ComponentsBuilder = ComponentsBuilder< + N, + MorphPoolBuilder, + BasicPayloadServiceBuilder, + EthereumNetworkBuilder, + MorphExecutorBuilder, + MorphConsensusBuilder, + >; + + type AddOns = MorphAddOns>; + + fn components_builder(&self) -> Self::ComponentsBuilder { + // Build payload config from args + let payload_config = + MorphBuilderConfig::default().with_max_da_block_size(self.args.max_tx_payload_bytes); + + let payload_config = if let Some(max_tx) = self.args.max_tx_per_block { + payload_config.with_max_tx_per_block(max_tx) + } else { + payload_config + }; + + Self::components(payload_config) + } + + fn add_ons(&self) -> Self::AddOns { + MorphAddOns::new() + } +} + +// ============================================================================= +// DebugNode Implementation +// ============================================================================= + +impl DebugNode for MorphNode +where + N: FullNodeComponents, +{ + type RpcBlock = alloy_rpc_types_eth::Block; + + fn rpc_to_primitive_block(rpc_block: Self::RpcBlock) -> reth_node_api::BlockTy { + // Convert RPC block to consensus block, mapping header to MorphHeader + let block = rpc_block.into_consensus(); + alloy_consensus::Block { + header: block.header.into(), + body: alloy_consensus::BlockBody { + transactions: block.body.transactions, + ommers: block.body.ommers.into_iter().map(Into::into).collect(), + withdrawals: block.body.withdrawals, + }, + } + } + + fn local_payload_attributes_builder( + chain_spec: &Self::ChainSpec, + ) -> impl PayloadAttributesBuilder< + <::Payload as PayloadTypes>::PayloadAttributes, + MorphHeader, + > { + MorphPayloadAttributesBuilder::new(Arc::new(chain_spec.clone())) + } +} + +// ============================================================================= +// Payload Attributes Builder +// ============================================================================= + +/// Builder for Morph payload attributes used in debug/local mining mode. +/// +/// This creates payload attributes for local block building, primarily used +/// for testing and development purposes. +#[derive(Debug, Clone)] +pub struct MorphPayloadAttributesBuilder { + chain_spec: Arc, +} + +impl MorphPayloadAttributesBuilder { + /// Creates a new builder with the given chain specification. + pub const fn new(chain_spec: Arc) -> Self { + Self { chain_spec } + } +} + +impl PayloadAttributesBuilder + for MorphPayloadAttributesBuilder +{ + fn build(&self, parent: &SealedHeader) -> MorphPayloadAttributes { + let timestamp = std::cmp::max(parent.timestamp().saturating_add(1), unix_timestamp_now()); + + MorphPayloadAttributes { + inner: PayloadAttributes { + timestamp, + prev_randao: B256::random(), + suggested_fee_recipient: Address::random(), + withdrawals: self + .chain_spec + .is_shanghai_active_at_timestamp(timestamp) + .then(Default::default), + parent_beacon_block_root: self + .chain_spec + .is_cancun_active_at_timestamp(timestamp) + .then(B256::random), + }, + // No L1 transactions in local mining mode + transactions: None, + } + } +} + +/// Returns the current unix timestamp in seconds. +fn unix_timestamp_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() +} diff --git a/crates/node/src/validator.rs b/crates/node/src/validator.rs new file mode 100644 index 0000000..7c38ef8 --- /dev/null +++ b/crates/node/src/validator.rs @@ -0,0 +1,69 @@ +//! Morph engine validator. + +use crate::MorphNode; +use alloy_consensus::BlockHeader; +use morph_payload_types::{MorphExecutionData, MorphPayloadTypes}; +use morph_primitives::MorphHeader; +use reth_node_api::{ + AddOnsContext, FullNodeComponents, InvalidPayloadAttributesError, NewPayloadError, + PayloadAttributes, PayloadValidator, +}; +use reth_node_builder::rpc::PayloadValidatorBuilder; +use reth_primitives_traits::SealedBlock; +use std::sync::Arc; + +/// Builder for Morph engine validator (payload validation). +/// +/// Creates a validator for validating engine API payloads. +#[derive(Debug, Default, Clone, Copy)] +#[non_exhaustive] +pub struct MorphEngineValidatorBuilder; + +impl PayloadValidatorBuilder for MorphEngineValidatorBuilder +where + Node: FullNodeComponents, +{ + type Validator = MorphEngineValidator; + + async fn build(self, _ctx: &AddOnsContext<'_, Node>) -> eyre::Result { + Ok(MorphEngineValidator::new()) + } +} + +/// Morph engine validator for payload validation. +/// +/// This validator is used by the engine API to validate incoming payloads. +/// For Morph, most validation is deferred to the consensus layer. +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct MorphEngineValidator; + +impl MorphEngineValidator { + /// Creates a new [`MorphEngineValidator`]. + pub const fn new() -> Self { + Self + } +} + +impl PayloadValidator for MorphEngineValidator { + type Block = morph_primitives::Block; + + fn convert_payload_to_block( + &self, + payload: MorphExecutionData, + ) -> Result, NewPayloadError> { + Ok(Arc::unwrap_or_clone(payload.block)) + } + + fn validate_payload_attributes_against_header( + &self, + attr: &::PayloadAttributes, + header: &MorphHeader, + ) -> Result<(), InvalidPayloadAttributesError> { + // Ensure that payload attributes timestamp is not in the past + if attr.timestamp() < header.timestamp() { + return Err(InvalidPayloadAttributesError::InvalidTimestamp); + } + Ok(()) + } +} diff --git a/crates/payload/builder/src/builder.rs b/crates/payload/builder/src/builder.rs index 8312e68..d33c207 100644 --- a/crates/payload/builder/src/builder.rs +++ b/crates/payload/builder/src/builder.rs @@ -355,15 +355,15 @@ impl MorphPayloadBuilderCtx { } }; - // For L1 messages, use full gas limit (no refund) and no fees collected. - // L1 gas is prepaid on L1, so unused gas is not refunded. - // Also track the next L1 message index. + // For L1 messages, track the next L1 message index. + // L1 gas is prepaid on L1, so no fees are collected here. let gas_used = if recovered_tx.is_l1_msg() { // Update next_l1_message_index to be queue_index + 1 if let Some(queue_index) = recovered_tx.queue_index() { info.next_l1_message_index = queue_index + 1; } - recovered_tx.gas_limit() + // Use actual gas consumed (including intrinsic gas) + gas_used } else { // Calculate fees for L2 transactions: effective_tip * gas_used let effective_tip = recovered_tx @@ -376,6 +376,9 @@ impl MorphPayloadBuilderCtx { info.cumulative_gas_used += gas_used; gas_spent_by_transactions.push(gas_used); + // Increment transaction count + info.transaction_count += 1; + // Store the original transaction bytes for ExecutableL2Data executed_txs.push(tx_bytes.clone()); } @@ -407,12 +410,17 @@ impl MorphPayloadBuilderCtx { return Ok(Some(())); } - // Check if the breaker triggers (time/gas/DA limits) - if breaker.should_break(info.cumulative_gas_used, info.cumulative_da_bytes_used) { + // Check if the breaker triggers (time/gas/DA/tx count limits) + if breaker.should_break( + info.cumulative_gas_used, + info.cumulative_da_bytes_used, + info.transaction_count, + ) { tracing::debug!( target: "payload_builder", cumulative_gas_used = info.cumulative_gas_used, cumulative_da_bytes_used = info.cumulative_da_bytes_used, + transaction_count = info.transaction_count, elapsed = ?breaker.elapsed(), "breaker triggered, stopping pool transaction execution" ); @@ -487,6 +495,7 @@ impl MorphPayloadBuilderCtx { // Update execution info info.cumulative_gas_used += gas_used; info.cumulative_da_bytes_used += tx.length() as u64; + info.transaction_count += 1; // Calculate fees: effective_tip * gas_used let effective_tip = tx.effective_tip_per_gas(base_fee).unwrap_or_default(); @@ -513,6 +522,8 @@ struct ExecutionInfo { total_fees: U256, /// Next L1 message queue index. next_l1_message_index: u64, + /// Number of transactions executed (including both sequencer and pool transactions). + transaction_count: u64, } impl ExecutionInfo { @@ -523,6 +534,7 @@ impl ExecutionInfo { cumulative_da_bytes_used: 0, total_fees: U256::ZERO, next_l1_message_index, + transaction_count: 0, } } diff --git a/crates/payload/builder/src/config.rs b/crates/payload/builder/src/config.rs index 9260173..bed39aa 100644 --- a/crates/payload/builder/src/config.rs +++ b/crates/payload/builder/src/config.rs @@ -35,7 +35,17 @@ pub struct MorphBuilderConfig { /// L2 transactions need to be published to L1 for data availability. /// This limit controls the maximum size of transaction data in a single block. /// If `None`, no DA limit is enforced. + /// + /// This corresponds to the `--morph.max-tx-payload-bytes` CLI flag. pub max_da_block_size: Option, + + /// Maximum number of transactions per block. + /// + /// If set, the builder will stop adding transactions once this limit is reached. + /// If `None`, no transaction count limit is enforced. + /// + /// This corresponds to the `--morph.max-tx-per-block` CLI flag. + pub max_tx_per_block: Option, } impl Default for MorphBuilderConfig { @@ -46,6 +56,8 @@ impl Default for MorphBuilderConfig { time_limit: Duration::from_secs(1), // No DA limit by default max_da_block_size: None, + // No transaction count limit by default + max_tx_per_block: None, } } } @@ -56,11 +68,13 @@ impl MorphBuilderConfig { gas_limit: Option, time_limit: Duration, max_da_block_size: Option, + max_tx_per_block: Option, ) -> Self { Self { gas_limit, time_limit, max_da_block_size, + max_tx_per_block, } } @@ -82,20 +96,32 @@ impl MorphBuilderConfig { self } + /// Sets the maximum number of transactions per block. + pub const fn with_max_tx_per_block(mut self, max_tx_per_block: u64) -> Self { + self.max_tx_per_block = Some(max_tx_per_block); + self + } + /// Creates a [`PayloadBuildingBreaker`] for this configuration. pub(crate) fn breaker(&self, block_gas_limit: u64) -> PayloadBuildingBreaker { // Use configured gas limit or fall back to block gas limit let effective_gas_limit = self.gas_limit.unwrap_or(block_gas_limit); - PayloadBuildingBreaker::new(self.time_limit, effective_gas_limit, self.max_da_block_size) + PayloadBuildingBreaker::new( + self.time_limit, + effective_gas_limit, + self.max_da_block_size, + self.max_tx_per_block, + ) } } /// Used in the payload builder to exit the transactions execution loop early. /// -/// The breaker checks three conditions: +/// The breaker checks four conditions: /// 1. Time limit - stop if building takes too long /// 2. Gas limit - stop if remaining gas is insufficient for any transaction /// 3. DA limit - stop if data availability size limit is reached +/// 4. Transaction count limit - stop if maximum transactions per block is reached #[derive(Debug, Clone)] pub struct PayloadBuildingBreaker { /// When the payload building started. @@ -106,16 +132,24 @@ pub struct PayloadBuildingBreaker { gas_limit: u64, /// Maximum DA block size. max_da_block_size: Option, + /// Maximum number of transactions per block. + max_tx_per_block: Option, } impl PayloadBuildingBreaker { /// Creates a new [`PayloadBuildingBreaker`]. - fn new(time_limit: Duration, gas_limit: u64, max_da_block_size: Option) -> Self { + fn new( + time_limit: Duration, + gas_limit: u64, + max_da_block_size: Option, + max_tx_per_block: Option, + ) -> Self { Self { start: Instant::now(), time_limit, gas_limit, max_da_block_size, + max_tx_per_block, } } @@ -125,7 +159,13 @@ impl PayloadBuildingBreaker { /// - Time limit has been exceeded /// - Gas limit has been reached (leaving room for at least one minimal transaction) /// - DA size limit has been reached (leaving room for at least one minimal transaction) - pub fn should_break(&self, cumulative_gas_used: u64, cumulative_da_size_used: u64) -> bool { + /// - Transaction count limit has been reached + pub fn should_break( + &self, + cumulative_gas_used: u64, + cumulative_da_size_used: u64, + transaction_count: u64, + ) -> bool { // Check time limit if self.start.elapsed() >= self.time_limit { tracing::trace!( @@ -161,6 +201,19 @@ impl PayloadBuildingBreaker { return true; } + // Check transaction count limit if configured + if let Some(max_count) = self.max_tx_per_block + && transaction_count >= max_count + { + tracing::trace!( + target: "payload_builder", + transaction_count, + max_tx_per_block = max_count, + "transaction count limit reached" + ); + return true; + } + false } @@ -180,6 +233,7 @@ mod tests { assert_eq!(config.gas_limit, None); assert_eq!(config.time_limit, Duration::from_secs(1)); assert_eq!(config.max_da_block_size, None); + assert_eq!(config.max_tx_per_block, None); } #[test] @@ -187,26 +241,32 @@ mod tests { let config = MorphBuilderConfig::default() .with_gas_limit(20_000_000) .with_time_limit(Duration::from_millis(500)) - .with_max_da_block_size(128 * 1024); + .with_max_da_block_size(128 * 1024) + .with_max_tx_per_block(1000); assert_eq!(config.gas_limit, Some(20_000_000)); assert_eq!(config.time_limit, Duration::from_millis(500)); assert_eq!(config.max_da_block_size, Some(128 * 1024)); + assert_eq!(config.max_tx_per_block, Some(1000)); } #[test] fn test_breaker_should_break_on_time_limit() { - let breaker = - PayloadBuildingBreaker::new(Duration::from_millis(100), 30_000_000, Some(128 * 1024)); + let breaker = PayloadBuildingBreaker::new( + Duration::from_millis(100), + 30_000_000, + Some(128 * 1024), + None, + ); // Should not break immediately - assert!(!breaker.should_break(0, 0)); + assert!(!breaker.should_break(0, 0, 0)); // Wait for time limit std::thread::sleep(Duration::from_millis(150)); // Should break now - assert!(breaker.should_break(0, 0)); + assert!(breaker.should_break(0, 0, 0)); } #[test] @@ -215,13 +275,13 @@ mod tests { // Threshold = 42000 - 21000 = 21000 // should_break returns true when cumulative_gas_used > threshold let gas_limit = 2 * MIN_TRANSACTION_GAS; - let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), gas_limit, None); + let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), gas_limit, None, None); // At threshold (21000), should NOT break (21000 > 21000 is false) - assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0)); + assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0, 0)); // Just over threshold, should break (21001 > 21000 is true) - assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0)); + assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0, 0)); } #[test] @@ -229,22 +289,49 @@ mod tests { // Set max_da = 2 * MIN_TRANSACTION_DATA_SIZE = 230 // Threshold = 230 - 115 = 115 let max_da_size = 2 * MIN_TRANSACTION_DATA_SIZE; - let breaker = - PayloadBuildingBreaker::new(Duration::from_secs(10), 30_000_000, Some(max_da_size)); + let breaker = PayloadBuildingBreaker::new( + Duration::from_secs(10), + 30_000_000, + Some(max_da_size), + None, + ); // At threshold (115), should NOT break (115 > 115 is false) - assert!(!breaker.should_break(0, MIN_TRANSACTION_DATA_SIZE)); + assert!(!breaker.should_break(0, MIN_TRANSACTION_DATA_SIZE, 0)); // Just over threshold, should break (116 > 115 is true) - assert!(breaker.should_break(0, MIN_TRANSACTION_DATA_SIZE + 1)); + assert!(breaker.should_break(0, MIN_TRANSACTION_DATA_SIZE + 1, 0)); + } + + #[test] + fn test_breaker_should_break_on_tx_count_limit() { + let breaker = + PayloadBuildingBreaker::new(Duration::from_secs(10), 30_000_000, None, Some(100)); + + // Below limit, should NOT break + assert!(!breaker.should_break(0, 0, 99)); + + // At limit, should break (>= comparison) + assert!(breaker.should_break(0, 0, 100)); + + // Above limit, should break + assert!(breaker.should_break(0, 0, 101)); } #[test] fn test_breaker_no_da_limit() { - let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), 30_000_000, None); + let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), 30_000_000, None, None); // Should not break even with huge DA size when no limit is set - assert!(!breaker.should_break(0, u64::MAX)); + assert!(!breaker.should_break(0, u64::MAX, 0)); + } + + #[test] + fn test_breaker_no_tx_count_limit() { + let breaker = PayloadBuildingBreaker::new(Duration::from_secs(10), 30_000_000, None, None); + + // Should not break even with huge tx count when no limit is set + assert!(!breaker.should_break(0, 0, u64::MAX)); } #[test] @@ -257,9 +344,9 @@ mod tests { // Threshold = 42000 - 21000 = 21000 // At threshold, should NOT break - assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0)); + assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0, 0)); // Just over, should break - assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0)); + assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0, 0)); } #[test] @@ -273,8 +360,8 @@ mod tests { // Should use configured_limit, not block_gas_limit // Threshold = 42000 - 21000 = 21000 - assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0)); - assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0)); + assert!(!breaker.should_break(MIN_TRANSACTION_GAS, 0, 0)); + assert!(breaker.should_break(MIN_TRANSACTION_GAS + 1, 0, 0)); // Verify it's not using block_gas_limit // If using block_gas_limit, threshold would be ~29,979,000 diff --git a/crates/payload/builder/src/lib.rs b/crates/payload/builder/src/lib.rs index 1ce5228..b010c20 100644 --- a/crates/payload/builder/src/lib.rs +++ b/crates/payload/builder/src/lib.rs @@ -2,7 +2,7 @@ //! //! This crate provides the payload building logic for Morph L2. //! -//! The [`MorphPayloadBuilder`] implements reth's [`PayloadBuilder`] trait +//! The [`MorphPayloadBuilder`] implements reth's `PayloadBuilder` trait //! to construct L2 blocks with: //! - L1 message transactions (prioritized, must be at the beginning) //! - Sequencer forced transactions diff --git a/crates/payload/types/Cargo.toml b/crates/payload/types/Cargo.toml index 33355ca..69849aa 100644 --- a/crates/payload/types/Cargo.toml +++ b/crates/payload/types/Cargo.toml @@ -36,3 +36,7 @@ sha2.workspace = true serde_json.workspace = true rand.workspace = true alloy-primitives = { workspace = true, features = ["rand"] } + +[features] +default = [] +serde = [] diff --git a/crates/payload/types/src/attributes.rs b/crates/payload/types/src/attributes.rs index 433c208..d8c1375 100644 --- a/crates/payload/types/src/attributes.rs +++ b/crates/payload/types/src/attributes.rs @@ -18,7 +18,7 @@ use sha2::{Digest, Sha256}; #[serde(rename_all = "camelCase")] pub struct MorphPayloadAttributes { /// Standard Ethereum payload attributes. - #[serde(flatten)] + #[cfg_attr(feature = "serde", serde(flatten))] pub inner: PayloadAttributes, /// Forced transactions to include at the beginning of the block. diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index edfba2e..8e5f979 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -42,6 +42,7 @@ serde = [ "alloy-eips/serde", "alloy-primitives/serde", "reth-primitives-traits/serde", + "reth-ethereum-primitives?/std", "reth-ethereum-primitives?/serde", ] @@ -58,6 +59,7 @@ reth-codec = [ "dep:modular-bitfield", "dep:reth-zstd-compressors", "dep:reth-ethereum-primitives", + "reth-ethereum-primitives/std", "reth-ethereum-primitives/reth-codec", "reth-codecs/alloy", "dep:bytes", diff --git a/crates/primitives/src/receipt/mod.rs b/crates/primitives/src/receipt/mod.rs index 6a78952..a04e4e6 100644 --- a/crates/primitives/src/receipt/mod.rs +++ b/crates/primitives/src/receipt/mod.rs @@ -583,6 +583,35 @@ mod compact { } } +/// Database storage implementations for [`MorphReceipt`]. +/// +/// These implementations allow `MorphReceipt` to be stored in reth's database +/// using the Compact codec for compression. +#[cfg(feature = "reth-codec")] +mod db_impl { + use super::MorphReceipt; + use reth_codecs::Compact; + use reth_db_api::{ + DatabaseError, + table::{Compress, Decompress}, + }; + + impl Compress for MorphReceipt { + type Compressed = Vec; + + fn compress_to_buf>(&self, buf: &mut B) { + let _ = Compact::to_compact(self, buf); + } + } + + impl Decompress for MorphReceipt { + fn decompress(value: &[u8]) -> Result { + let (obj, _) = Compact::from_compact(value, value.len()); + Ok(obj) + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index bde3c31..c514f75 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -5,7 +5,7 @@ use alloy_eips::eip2718::Encodable2718; use alloy_primitives::{B256, Bytes}; use alloy_rlp::BytesMut; -use crate::{TxL1Msg, TxMorph}; +use crate::{TxL1Msg, TxMorph, transaction::morph_transaction::MorphTxFields}; #[derive(Debug, Clone, TransactionEnvelope)] #[envelope(tx_type_name = MorphTxType)] @@ -107,6 +107,20 @@ impl MorphTxEnvelope { } } + /// Returns all MorphTx-specific fields, or `None` if this is not a MorphTx. + pub fn morph_fields(&self) -> Option { + match self { + Self::Morph(tx) => Some(MorphTxFields { + version: tx.tx().version, + fee_token_id: tx.tx().fee_token_id, + fee_limit: tx.tx().fee_limit, + reference: tx.tx().reference, + memo: tx.tx().memo.clone(), + }), + _ => None, + } + } + pub fn queue_index(&self) -> Option { match self { Self::Legacy(_) diff --git a/crates/primitives/src/transaction/mod.rs b/crates/primitives/src/transaction/mod.rs index 9386a93..f1488eb 100644 --- a/crates/primitives/src/transaction/mod.rs +++ b/crates/primitives/src/transaction/mod.rs @@ -7,5 +7,6 @@ pub mod morph_transaction; pub use envelope::{MorphTxEnvelope, MorphTxType}; pub use l1_transaction::{L1_TX_TYPE_ID, TxL1Msg}; pub use morph_transaction::{ - MAX_MEMO_LENGTH, MORPH_TX_TYPE_ID, MORPH_TX_VERSION_0, MORPH_TX_VERSION_1, TxMorph, TxMorphExt, + MAX_MEMO_LENGTH, MORPH_TX_TYPE_ID, MORPH_TX_VERSION_0, MORPH_TX_VERSION_1, MorphTxFields, + TxMorph, TxMorphExt, }; diff --git a/crates/primitives/src/transaction/morph_transaction.rs b/crates/primitives/src/transaction/morph_transaction.rs index 2a3358a..4c93ec8 100644 --- a/crates/primitives/src/transaction/morph_transaction.rs +++ b/crates/primitives/src/transaction/morph_transaction.rs @@ -31,6 +31,26 @@ pub const MORPH_TX_VERSION_1: u8 = 1; /// Maximum length of the memo field in bytes. pub const MAX_MEMO_LENGTH: usize = 64; +/// Canonical MorphTx-specific fields shared across modules. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))] +pub struct MorphTxFields { + pub version: u8, + pub fee_token_id: u16, + pub fee_limit: U256, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub reference: Option, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub memo: Option, +} + /// Morph Transaction for Morph L2. /// /// This transaction type extends EIP-1559 style transactions with Morph-specific fields: diff --git a/crates/revm/Cargo.toml b/crates/revm/Cargo.toml index 6bfceb7..2cafdbb 100644 --- a/crates/revm/Cargo.toml +++ b/crates/revm/Cargo.toml @@ -15,6 +15,7 @@ morph-primitives.workspace = true morph-chainspec.workspace = true reth-evm.workspace = true +reth-ethereum-primitives = { workspace = true, features = ["std", "serde"] } reth-storage-api = { workspace = true, optional = true } reth-rpc-eth-types = { workspace = true, optional = true } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index b4bebd6..578d4bf 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -18,7 +18,7 @@ use crate::{ error::MorphHaltReason, evm::MorphContext, l1block::L1BlockInfo, - token_fee::{TokenFeeInfo, mapping_slot_for}, + token_fee::{TokenFeeInfo, compute_mapping_slot_for_address}, tx::MorphTxExt, }; @@ -109,11 +109,13 @@ where return Ok(()); } - // Check if transaction is MorphTransaction (tx_type 0x7F) which uses token fee + // MorphTx (0x7F) can use token fee (fee_token_id > 0) or ETH fee (fee_token_id == 0). if evm.ctx_ref().tx().is_morph_tx() { - // Get fee_token_id directly from MorphTxEnv let token_id = evm.ctx_ref().tx().fee_token_id.unwrap_or_default(); - return self.validate_and_deduct_token_fee(evm, token_id); + if token_id > 0 { + return self.validate_and_deduct_token_fee(evm, token_id); + } + return self.validate_and_deduct_eth_fee(evm); } // Standard ETH-based fee handling @@ -132,11 +134,15 @@ where return Ok(()); } - // Check if transaction is MorphTransaction (tx_type 0x7F) which uses token fee + // MorphTx (0x7F) can use token fee (fee_token_id > 0) or ETH fee (fee_token_id == 0). if tx.is_morph_tx() { - // Get fee_token_id directly from MorphTxEnv let token_id = tx.fee_token_id.unwrap_or_default(); - return self.reimburse_caller_token_fee(evm, exec_result.gas(), token_id); + if token_id > 0 { + return self.reimburse_caller_token_fee(evm, exec_result.gas(), token_id); + } + // fee_token_id == 0 follows standard ETH reimbursement flow + post_execution::reimburse_caller(evm.ctx(), exec_result.gas(), U256::ZERO)?; + return Ok(()); } // Standard ETH-based fee handling @@ -152,9 +158,9 @@ where ) -> Result<(), Self::Error> { let (block, tx, cfg, journal, _, _) = evm.ctx().all_mut(); - // L1 message transactions skip all reward. - // MorphTransaction rewards are already applied when gasFee is deducted. - if tx.is_l1_msg() || tx.is_morph_tx() { + // L1 messages skip all reward. + // Token-fee MorphTx rewards are already applied when token fee is deducted. + if tx.is_l1_msg() || (tx.is_morph_tx() && tx.fee_token_id.unwrap_or_default() > 0) { return Ok(()); } @@ -213,6 +219,32 @@ where let tx = evm.ctx_ref().tx(); let cfg = evm.ctx_ref().cfg(); let spec = (*cfg.spec()).into(); + + // For L1 message transactions, handle intrinsic gas specially + if tx.is_l1_msg() { + // Calculate intrinsic gas (same as normal transactions) + let initial_and_floor = + validation::validate_initial_tx_gas(tx, spec, cfg.is_eip7623_disabled()) + .unwrap_or_else(|_| { + // If intrinsic gas > gas_limit, use gas_limit as intrinsic gas + // This matches go-ethereum's behavior for L1 messages + InitialAndFloorGas { + initial_gas: tx.gas_limit(), + floor_gas: 0, + } + }); + + tracing::debug!( + target: "morph::revm::handler", + gas_limit = tx.gas_limit(), + initial_gas = initial_and_floor.initial_gas, + "L1 Message intrinsic gas calculated" + ); + + return Ok(initial_and_floor); + } + + // Normal transaction validation Ok( validation::validate_initial_tx_gas(tx, spec, cfg.is_eip7623_disabled()) .map_err(MorphInvalidTransaction::EthInvalidTransaction)?, @@ -333,8 +365,9 @@ where // Fetch token fee info from Token Registry let spec = *evm.ctx_ref().cfg().spec(); - let token_fee_info = TokenFeeInfo::fetch(evm.ctx_mut().db_mut(), token_id, caller, spec)? - .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; + let token_fee_info = + TokenFeeInfo::load_for_caller(evm.ctx_mut().db_mut(), token_id, caller, spec)? + .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; // Check if token is active if !token_fee_info.is_active { @@ -394,7 +427,7 @@ where // Fetch token fee info from Token Registry let token_fee_info = - TokenFeeInfo::fetch(journal.db_mut(), token_id, caller_addr, hardfork)? + TokenFeeInfo::load_for_caller(journal.db_mut(), token_id, caller_addr, hardfork)? .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; // Check if token is active @@ -529,7 +562,7 @@ where DB: alloy_evm::Database, { // Sub amount - let from_storage_slot = mapping_slot_for(token_balance_slot, from); + let from_storage_slot = compute_mapping_slot_for_address(token_balance_slot, from); let balance = journal.sload(token, from_storage_slot)?; journal.sstore( token, @@ -538,7 +571,7 @@ where )?; // Add amount - let to_storage_slot = mapping_slot_for(token_balance_slot, to); + let to_storage_slot = compute_mapping_slot_for_address(token_balance_slot, to); let balance = journal.sload(token, to_storage_slot)?; journal.sstore(token, to_storage_slot, balance.saturating_add(token_amount))?; Ok((from_storage_slot, to_storage_slot)) diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index 112e7e4..75727d6 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -37,9 +37,10 @@ mod block; // Suppress unused_crate_dependencies warnings #[cfg(not(test))] use alloy_consensus as _; -#[cfg(not(test))] use alloy_sol_types as _; #[cfg(not(test))] +use reth_ethereum_primitives as _; +#[cfg(not(test))] use tracing as _; mod common; @@ -78,7 +79,7 @@ pub use l1block::{ }; pub use precompiles::MorphPrecompiles; pub use token_fee::{ - L2_TOKEN_REGISTRY_ADDRESS, TokenFeeInfo, encode_balance_of, erc20_balance_of, mapping_slot, - mapping_slot_for, + L2_TOKEN_REGISTRY_ADDRESS, TokenFeeInfo, compute_mapping_slot, + compute_mapping_slot_for_address, encode_balance_of_calldata, query_erc20_balance, }; pub use tx::{MorphTxEnv, MorphTxExt}; diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index 70e8840..a6f5f0f 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -9,10 +9,7 @@ use alloy_evm::Database; use alloy_primitives::{Address, Bytes, U256, address, keccak256}; use morph_chainspec::hardfork::MorphHardfork; use revm::SystemCallEvm; -use revm::{ - Database as RevmDatabase, Inspector, context_interface::result::EVMError, - inspector::NoOpInspector, -}; +use revm::{Inspector, context_interface::result::EVMError, inspector::NoOpInspector}; use crate::evm::MorphContext; use crate::{MorphEvm, MorphInvalidTransaction}; @@ -61,23 +58,23 @@ struct TokenRegistryEntry { } impl TokenFeeInfo { - /// Fetch token fee information with EVM call fallback. + /// Load token fee information with EVM call fallback. /// /// Reads token parameters from L2 Token Registry storage. If the token's /// balance slot is unknown, falls back to an EVM `balanceOf` call. /// Returns `None` if the token is not registered. - pub fn fetch( + pub fn load_for_caller( db: &mut DB, token_id: u16, caller: Address, hardfork: MorphHardfork, ) -> Result, DB::Error> { - let entry = match load_registry_entry(db, token_id)? { + let entry = match read_registry_entry(db, token_id)? { Some(e) => e, None => return Ok(None), }; - let balance = read_erc20_balance( + let balance = read_token_balance_with_fallback( db, entry.token_address, caller, @@ -97,34 +94,6 @@ impl TokenFeeInfo { })) } - /// Fetch token fee information using storage-only reads. - /// - /// Does not execute EVM calls. If the balance slot is unknown, - /// the returned `balance` will be zero. - pub fn fetch_storage_only( - db: &mut DB, - token_id: u16, - caller: Address, - ) -> Result, DB::Error> { - let entry = match load_registry_entry(db, token_id)? { - Some(e) => e, - None => return Ok(None), - }; - - let balance = read_balance_slot(db, entry.token_address, caller, entry.balance_slot)?; - - Ok(Some(Self { - token_address: entry.token_address, - is_active: entry.is_active, - decimals: entry.decimals, - price_ratio: entry.price_ratio, - scale: entry.scale, - caller, - balance, - balance_slot: entry.balance_slot, - })) - } - /// Calculate the token amount required for a given ETH amount. /// /// Uses the price ratio and scale to convert ETH value to token amount. @@ -144,22 +113,16 @@ impl TokenFeeInfo { token_amount } } - - /// Check if the caller has sufficient token balance for the given ETH amount. - pub fn has_sufficient_balance(&self, eth_amount: U256) -> bool { - let required = self.eth_to_token_amount(eth_amount); - self.balance >= required - } } -fn load_registry_entry( +fn read_registry_entry( db: &mut DB, token_id: u16, ) -> Result, DB::Error> { // Get the base slot for this token_id in tokenRegistry mapping let mut token_id_bytes = [0u8; 32]; token_id_bytes[30..32].copy_from_slice(&token_id.to_be_bytes()); - let base = mapping_slot(TOKEN_REGISTRY_SLOT, token_id_bytes.to_vec()); + let base = compute_mapping_slot(TOKEN_REGISTRY_SLOT, token_id_bytes.to_vec()); // TokenInfo struct layout in storage (Solidity packing): // base + 0: tokenAddress (20 bytes) + padding @@ -189,7 +152,7 @@ fn load_registry_entry( let scale = db.storage(L2_TOKEN_REGISTRY_ADDRESS, base + U256::from(3))?; // Get price ratio from priceRatio mapping - let price_ratio = load_mapping_value( + let price_ratio = read_mapping_value( db, L2_TOKEN_REGISTRY_ADDRESS, PRICE_RATIO_SLOT, @@ -210,7 +173,7 @@ fn load_registry_entry( /// /// For `mapping(keyType => valueType)` at slot `base_slot`, /// the value for `key` is at `keccak256(key ++ base_slot)`. -pub fn mapping_slot(base_slot: U256, mut key: Vec) -> U256 { +pub fn compute_mapping_slot(base_slot: U256, mut key: Vec) -> U256 { let mut preimage = base_slot.to_be_bytes_vec(); key.append(&mut preimage); U256::from_be_bytes(keccak256(key).0) @@ -218,27 +181,27 @@ pub fn mapping_slot(base_slot: U256, mut key: Vec) -> U256 { /// Calculate mapping slot for an address key (left-padded to 32 bytes). #[inline] -pub fn mapping_slot_for(base_slot: U256, account: Address) -> U256 { +pub fn compute_mapping_slot_for_address(base_slot: U256, account: Address) -> U256 { let mut key = [0u8; 32]; key[12..32].copy_from_slice(account.as_slice()); - mapping_slot(base_slot, key.to_vec()) + compute_mapping_slot(base_slot, key.to_vec()) } /// Load a value from a mapping in contract storage. -fn load_mapping_value( +fn read_mapping_value( db: &mut DB, contract: Address, base_slot: U256, key: Vec, ) -> Result { - db.storage(contract, mapping_slot(base_slot, key)) + db.storage(contract, compute_mapping_slot(base_slot, key)) } /// Read ERC20 balance with EVM call fallback. /// /// If `balance_slot` is known, reads directly from storage. /// Otherwise, constructs a temporary EVM to call `balanceOf(address)`. -fn read_erc20_balance( +fn read_token_balance_with_fallback( db: &mut DB, token: Address, account: Address, @@ -246,14 +209,14 @@ fn read_erc20_balance( hardfork: MorphHardfork, ) -> Result { if balance_slot.is_some() { - return read_balance_slot(db, token, account, balance_slot); + return read_balance_from_storage(db, token, account, balance_slot); } // EVM fallback: construct temporary MorphEvm for balanceOf call let db: &mut dyn Database = db; let mut evm = MorphEvm::new(MorphContext::new(db, hardfork), NoOpInspector {}); - match call_balance_of(&mut evm, token, account) { + match query_balance_via_system_call(&mut evm, token, account) { Ok(balance) => Ok(balance), Err(EVMError::Database(e)) => Err(e), Err(_) => Ok(U256::ZERO), // Non-DB errors → zero (safe fallback) @@ -263,7 +226,7 @@ fn read_erc20_balance( /// Read ERC20 balance directly from storage slot. /// /// Returns zero if `balance_slot` is `None`. -fn read_balance_slot( +fn read_balance_from_storage( db: &mut DB, token: Address, account: Address, @@ -273,14 +236,14 @@ fn read_balance_slot( Some(slot) => { let mut key = [0u8; 32]; key[12..32].copy_from_slice(account.as_slice()); - load_mapping_value(db, token, slot, key.to_vec()) + read_mapping_value(db, token, slot, key.to_vec()) } None => Ok(U256::ZERO), } } /// Execute EVM `balanceOf(address)` call. -fn call_balance_of( +fn query_balance_via_system_call( evm: &mut MorphEvm, token: Address, account: Address, @@ -289,7 +252,7 @@ where DB: Database, I: Inspector>, { - let calldata = encode_balance_of(account); + let calldata = encode_balance_of_calldata(account); match evm.system_call_one(token, calldata) { Ok(result) if result.is_success() => { if let Some(output) = result.output() @@ -307,7 +270,7 @@ where /// Query ERC20 balance via EVM call. /// /// Use this when you have a `MorphEvm` instance and need to call `balanceOf`. -pub fn erc20_balance_of( +pub fn query_erc20_balance( evm: &mut MorphEvm, token: Address, account: Address, @@ -316,13 +279,13 @@ where DB: Database, I: Inspector>, { - call_balance_of(evm, token, account) + query_balance_via_system_call(evm, token, account) } /// Encode ERC20 `balanceOf(address)` calldata. /// /// Function selector: `0x70a08231` -pub fn encode_balance_of(account: Address) -> Bytes { +pub fn encode_balance_of_calldata(account: Address) -> Bytes { const SELECTOR: [u8; 4] = [0x70, 0xa0, 0x82, 0x31]; let mut data = Vec::with_capacity(36); data.extend_from_slice(&SELECTOR); @@ -352,8 +315,8 @@ mod tests { // Test that mapping slot calculation produces deterministic results let slot = U256::from(151); let key = vec![0u8; 32]; - let result1 = mapping_slot(slot, key.clone()); - let result2 = mapping_slot(slot, key); + let result1 = compute_mapping_slot(slot, key.clone()); + let result2 = compute_mapping_slot(slot, key); assert_eq!(result1, result2); } @@ -361,7 +324,7 @@ mod tests { fn test_mapping_slot_for() { let slot = U256::from(1); let account = address!("1234567890123456789012345678901234567890"); - let result = mapping_slot_for(slot, account); + let result = compute_mapping_slot_for_address(slot, account); // Result should be non-zero assert!(!result.is_zero()); } @@ -393,27 +356,10 @@ mod tests { assert_eq!(token_amount, U256::ZERO); } - #[test] - fn test_has_sufficient_balance() { - let info = TokenFeeInfo { - price_ratio: U256::from(1_000_000_000_000_000_000u128), // 1:1 - scale: U256::from(1_000_000_000_000_000_000u128), - balance: U256::from(1_000_000_000_000_000_000u128), // 1 token - ..Default::default() - }; - - // Has exactly enough - assert!(info.has_sufficient_balance(U256::from(1_000_000_000_000_000_000u128))); - // Has more than enough - assert!(info.has_sufficient_balance(U256::from(500_000_000_000_000_000u128))); - // Not enough - assert!(!info.has_sufficient_balance(U256::from(2_000_000_000_000_000_000u128))); - } - #[test] fn test_encode_balance_of() { let account = address!("1234567890123456789012345678901234567890"); - let calldata = encode_balance_of(account); + let calldata = encode_balance_of_calldata(account); // Should be 4 bytes selector + 32 bytes address = 36 bytes assert_eq!(calldata.len(), 36); diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index b31602e..9104f23 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -2,11 +2,13 @@ //! //! This module defines the Morph-specific transaction environment with token fee support. -use alloy_consensus::{EthereumTxEnvelope, Transaction as AlloyTransaction, TxEip4844}; +use alloy_consensus::{ + EthereumTxEnvelope, SignableTransaction, Transaction as AlloyTransaction, TxEip4844, +}; use alloy_eips::eip2718::Encodable2718; use alloy_eips::eip2930::AccessList; use alloy_eips::eip7702::RecoveredAuthority; -use alloy_primitives::{Address, B256, Bytes, TxKind, U256}; +use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256}; use alloy_rlp::Decodable; use morph_primitives::{L1_TX_TYPE_ID, MORPH_TX_TYPE_ID, MorphTxEnvelope, TxMorph}; use reth_evm::{FromRecoveredTx, FromTxWithEncoded, ToTxEnv, TransactionEnv}; @@ -99,6 +101,107 @@ impl MorphTxEnv { self } + /// Encodes this tx env into EIP-2718 bytes for L1 fee accounting. + /// + /// This is used by simulation paths (`eth_call`, `eth_estimateGas`) where + /// we have tx env fields but no pre-encoded transaction bytes. + pub fn encode_for_l1_fee(&self, fallback_chain_id: u64) -> Bytes { + // Signature validity is irrelevant for fee sizing, but encoded length matters. + let placeholder_signature = Signature::new(Default::default(), Default::default(), false); + + match self.build_morph_tx_for_l1_fee(fallback_chain_id) { + Some(morph_tx) => { + let signed = morph_tx.into_signed(placeholder_signature); + MorphTxEnvelope::Morph(signed).rlp() + } + None => self + .build_ethereum_envelope_for_l1_fee(fallback_chain_id, placeholder_signature) + .rlp(), + } + } + + fn build_morph_tx_for_l1_fee(&self, fallback_chain_id: u64) -> Option { + let fee_token_id = self.fee_token_id.unwrap_or_default(); + let has_fee_token = fee_token_id > 0; + let has_reference = self.reference.is_some(); + let has_memo = self.memo.as_ref().is_some_and(|m| !m.is_empty()); + + if !has_fee_token && !has_reference && !has_memo { + return None; + } + + Some(TxMorph { + chain_id: self.chain_id().unwrap_or(fallback_chain_id), + nonce: self.inner.nonce, + gas_limit: self.gas_limit() as u128, + max_fee_per_gas: self.max_fee_per_gas(), + max_priority_fee_per_gas: self.max_priority_fee_per_gas().unwrap_or_default(), + to: self.kind(), + value: self.value(), + access_list: self.access_list.clone(), + input: self.input().clone(), + fee_token_id, + fee_limit: self.fee_limit.unwrap_or_default(), + version: morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1, + reference: self.reference, + memo: self.memo.clone(), + }) + } + + fn build_ethereum_envelope_for_l1_fee( + &self, + fallback_chain_id: u64, + signature: Signature, + ) -> MorphTxEnvelope { + let chain_id = self.chain_id().unwrap_or(fallback_chain_id); + let has_dynamic_fees = self.max_priority_fee_per_gas().is_some(); + let has_access_list = !self.access_list.is_empty(); + + if !has_dynamic_fees && !has_access_list { + MorphTxEnvelope::Legacy( + alloy_consensus::TxLegacy { + chain_id: Some(chain_id), + nonce: self.inner.nonce, + gas_price: self.gas_price(), + gas_limit: self.gas_limit(), + to: self.kind(), + value: self.value(), + input: self.input().clone(), + } + .into_signed(signature), + ) + } else if has_access_list && !has_dynamic_fees { + MorphTxEnvelope::Eip2930( + alloy_consensus::TxEip2930 { + chain_id, + nonce: self.inner.nonce, + gas_price: self.gas_price(), + gas_limit: self.gas_limit(), + to: self.kind(), + value: self.value(), + access_list: self.access_list.clone(), + input: self.input().clone(), + } + .into_signed(signature), + ) + } else { + MorphTxEnvelope::Eip1559( + alloy_consensus::TxEip1559 { + chain_id, + nonce: self.inner.nonce, + gas_limit: self.gas_limit(), + max_fee_per_gas: self.max_fee_per_gas(), + max_priority_fee_per_gas: self.max_priority_fee_per_gas().unwrap_or_default(), + to: self.kind(), + value: self.value(), + access_list: self.access_list.clone(), + input: self.input().clone(), + } + .into_signed(signature), + ) + } + } + /// Create a new Morph transaction environment from a recovered transaction. /// /// This method: @@ -178,7 +281,7 @@ impl MorphTxEnv { } /// Extracted MorphTx fields from RLP-encoded bytes. -struct MorphTxFields { +struct DecodedMorphTxFields { version: u8, fee_token_id: u16, fee_limit: U256, @@ -190,7 +293,7 @@ struct MorphTxFields { /// /// The bytes should be EIP-2718 encoded (type byte + RLP payload). /// Returns None if decoding fails. -fn extract_morph_tx_fields_from_rlp(rlp_bytes: &Bytes) -> Option { +fn extract_morph_tx_fields_from_rlp(rlp_bytes: &Bytes) -> Option { if rlp_bytes.is_empty() { return None; } @@ -198,7 +301,7 @@ fn extract_morph_tx_fields_from_rlp(rlp_bytes: &Bytes) -> Option // Skip the type byte (0x7F) and decode the TxMorph let payload = &rlp_bytes[1..]; TxMorph::decode(&mut &payload[..]) - .map(|tx| MorphTxFields { + .map(|tx| DecodedMorphTxFields { version: tx.version, fee_token_id: tx.fee_token_id, fee_limit: tx.fee_limit, @@ -501,4 +604,39 @@ mod tests { let tx_with_rlp = MorphTxEnv::default().with_rlp_bytes(Bytes::from(vec![1, 2, 3])); assert_eq!(tx_with_rlp.rlp_bytes, Some(Bytes::from(vec![1, 2, 3]))); } + + #[test] + fn test_encode_for_l1_fee_legacy() { + let tx = MorphTxEnv { + inner: TxEnv { + chain_id: Some(53077), + gas_limit: 21_000, + gas_price: 1, + nonce: 1, + kind: TxKind::Call(Address::ZERO), + ..Default::default() + }, + ..Default::default() + }; + assert!(!tx.encode_for_l1_fee(53077).is_empty()); + } + + #[test] + fn test_encode_for_l1_fee_morph_tx() { + let tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + chain_id: Some(53077), + gas_limit: 21_000, + gas_price: 1, + nonce: 1, + kind: TxKind::Call(Address::ZERO), + ..Default::default() + }, + fee_token_id: Some(1), + fee_limit: Some(U256::from(1000)), + ..Default::default() + }; + assert!(!tx.encode_for_l1_fee(53077).is_empty()); + } } diff --git a/crates/rpc/src/error.rs b/crates/rpc/src/error.rs index a0f2b57..9bd81f1 100644 --- a/crates/rpc/src/error.rs +++ b/crates/rpc/src/error.rs @@ -63,19 +63,6 @@ pub enum MorphEthApiError { /// Provider error #[error("provider error: {0}")] Provider(String), - - // ========== Gas estimation errors ========== - /// Insufficient funds for L1 data fee - #[error("insufficient funds for l1 fee")] - InsufficientFundsForL1Fee, - - /// Insufficient funds for value transfer - #[error("insufficient funds for transfer")] - InsufficientFundsForTransfer, - - /// Invalid fee token (not registered, inactive, or invalid configuration) - #[error("invalid token")] - InvalidFeeToken, } /// Converts [`MorphEthApiError`] to a JSON-RPC error object. @@ -123,19 +110,6 @@ impl From for jsonrpsee::types::ErrorObject<'static> { format!("Provider error: {msg}"), None::<()>, ), - MorphEthApiError::InsufficientFundsForL1Fee => jsonrpsee::types::ErrorObject::owned( - -32008, - "insufficient funds for l1 fee", - None::<()>, - ), - MorphEthApiError::InsufficientFundsForTransfer => jsonrpsee::types::ErrorObject::owned( - -32009, - "insufficient funds for transfer", - None::<()>, - ), - MorphEthApiError::InvalidFeeToken => { - jsonrpsee::types::ErrorObject::owned(-32010, "invalid token", None::<()>) - } } } } diff --git a/crates/rpc/src/eth/call.rs b/crates/rpc/src/eth/call.rs index b368ada..82b72dc 100644 --- a/crates/rpc/src/eth/call.rs +++ b/crates/rpc/src/eth/call.rs @@ -1,19 +1,11 @@ //! Morph `eth_call` and `eth_estimateGas` overrides. use crate::MorphEthApiError; -use crate::error::ToMorphErr; use crate::eth::{MorphEthApi, MorphNodeCore}; -use alloy_primitives::U256; -use morph_chainspec::{MorphChainSpec, MorphHardforks}; -use morph_revm::{L1BlockInfo, MorphTxExt, TokenFeeInfo}; -use reth_evm::{EvmEnvFor, TxEnvFor}; +use morph_chainspec::MorphChainSpec; use reth_provider::ChainSpecProvider; -use reth_rpc_eth_api::{ - EthApiTypes, RpcNodeCore, - helpers::{Call, EthCall, estimate::EstimateCall}, -}; +use reth_rpc_eth_api::helpers::{Call, EthCall, estimate::EstimateCall}; use reth_rpc_eth_types::EthApiError; -use revm::{Database, context::Transaction as RevmTransaction}; impl EthCall for MorphEthApi where @@ -54,201 +46,4 @@ where fn evm_memory_limit(&self) -> u64 { self.eth_api().evm_memory_limit() } - - fn caller_gas_allowance( - &self, - mut db: impl Database>, - evm_env: &EvmEnvFor<::Evm>, - tx_env: &TxEnvFor<::Evm>, - ) -> Result::Error> { - let caller = tx_env.caller(); - let balance = db - .basic(caller) - .to_morph_err()? - .map(|acc| acc.balance) - .unwrap_or_default(); - - let value = tx_env.value(); - if value > balance { - return Err(MorphEthApiError::InsufficientFundsForTransfer); - } - - let l1_fee = self.estimate_l1_fee(&mut db, evm_env, tx_env)?; - - if let Some(fee_token_id) = tx_env.fee_token_id.filter(|id| *id > 0) { - return self.caller_gas_allowance_with_token( - &mut db, - caller, - balance, - value, - l1_fee, - fee_token_id, - tx_env.fee_limit, - tx_env.gas_price(), - ); - } - - caller_gas_allowance_with_eth(balance, value, l1_fee, tx_env.gas_price()) - } -} - -impl MorphEthApi -where - N: MorphNodeCore, - N::Provider: ChainSpecProvider, - Rpc: - reth_rpc_convert::RpcConvert, -{ - /// Estimates the L1 data fee for the given transaction. - /// - /// Returns zero for L1 message transactions since they don't pay L1 fees. - fn estimate_l1_fee( - &self, - db: &mut DB, - evm_env: &EvmEnvFor<::Evm>, - tx_env: &TxEnvFor<::Evm>, - ) -> Result - where - DB: Database, - DB::Error: Into, - { - let block_number = u64::try_from(evm_env.block_env.number) - .map_err(|_| EthApiError::InvalidParams("invalid block number".to_string()))?; - let timestamp = u64::try_from(evm_env.block_env.timestamp) - .map_err(|_| EthApiError::InvalidParams("invalid block timestamp".to_string()))?; - let chain_spec = self.provider().chain_spec(); - let hardfork = chain_spec.morph_hardfork_at(block_number, timestamp); - - if tx_env.is_l1_msg() { - return Ok(U256::ZERO); - } - - let rlp_bytes = tx_env.rlp_bytes.as_ref().ok_or_else(|| { - EthApiError::InvalidParams("missing rlp bytes for l1 fee".to_string()) - })?; - - let l1_info = L1BlockInfo::try_fetch(db, hardfork).map_err(|err| { - EthApiError::InvalidParams(format!("failed to estimate L1 data fee: {err}")) - })?; - - Ok(l1_info.calculate_tx_l1_cost(rlp_bytes, hardfork)) - } - - /// Calculate caller's gas allowance when paying with ERC20 tokens. - /// - /// Uses storage-only reads. For tokens without a known `balance_slot`, - /// skips the token balance limit (EVM handler will verify during execution). - #[allow(clippy::too_many_arguments)] - fn caller_gas_allowance_with_token( - &self, - db: &mut DB, - caller: alloy_primitives::Address, - balance: U256, - value: U256, - l1_fee: U256, - fee_token_id: u16, - fee_limit: Option, - gas_price: u128, - ) -> Result - where - DB: Database, - DB::Error: Into, - { - let token_fee_info = TokenFeeInfo::fetch_storage_only(db, fee_token_id, caller) - .map_err(|_| MorphEthApiError::InvalidFeeToken)? - .ok_or(MorphEthApiError::InvalidFeeToken)?; - - // Validate token is registered and active - if !token_fee_info.is_active - || token_fee_info.price_ratio.is_zero() - || token_fee_info.scale.is_zero() - { - return Err(MorphEthApiError::InvalidFeeToken); - } - - // Calculate base ETH allowance (for tx.value transfer) - let eth_allowance = caller_gas_allowance_with_eth(balance, value, U256::ZERO, gas_price)?; - - // If balance_slot is unknown, we cannot accurately read the token balance - // via storage. Skip the token balance limit and let the EVM handler - // (`validate_and_deduct_token_fee`) verify the actual balance. - if token_fee_info.balance_slot.is_none() { - tracing::debug!( - target: "morph::rpc", - token_id = fee_token_id, - "Token balance_slot unknown, skipping token balance limit in caller_gas_allowance" - ); - return Ok(eth_allowance); - } - - // Calculate token-based gas allowance - let limit = match fee_limit { - Some(limit) if !limit.is_zero() => token_fee_info.balance.min(limit), - _ => token_fee_info.balance, - }; - - let l1_fee_in_token = token_fee_info.eth_to_token_amount(l1_fee); - if l1_fee_in_token >= limit { - return Err(MorphEthApiError::InsufficientFundsForL1Fee); - } - - let available_token = limit - l1_fee_in_token; - let available_eth = token_amount_to_eth(available_token, &token_fee_info) - .ok_or(MorphEthApiError::InvalidFeeToken)?; - - let token_allowance = gas_allowance_from_balance(available_eth, gas_price); - Ok(eth_allowance.min(token_allowance)) - } -} - -/// Calculates the caller's gas allowance when paying with ETH. -/// -/// Subtracts the transaction value and L1 fee from the balance, -/// then converts the remaining balance to gas units. -fn caller_gas_allowance_with_eth( - balance: U256, - value: U256, - l1_fee: U256, - gas_price: u128, -) -> Result { - let mut available = balance.saturating_sub(value); - if l1_fee >= available { - return Err(MorphEthApiError::InsufficientFundsForL1Fee); - } - available -= l1_fee; - Ok(gas_allowance_from_balance(available, gas_price)) -} - -/// Converts a balance to gas units based on the gas price. -/// -/// Returns `u64::MAX` if gas price is zero (unlimited gas). -fn gas_allowance_from_balance(balance: U256, gas_price: u128) -> u64 { - if gas_price == 0 { - return u64::MAX; - } - let gas_price = U256::from(gas_price); - let allowance = balance / gas_price; - if allowance > U256::from(u64::MAX) { - u64::MAX - } else { - allowance.to::() - } -} - -/// Converts a token amount to ETH equivalent using the fee token info. -/// -/// Uses the formula: `eth = token_amount * price_ratio / scale`. -/// Returns `None` if price_ratio or scale is zero. -fn token_amount_to_eth(token_amount: U256, info: &TokenFeeInfo) -> Option { - if info.price_ratio.is_zero() || info.scale.is_zero() { - return None; - } - let (eth_amount, remainder) = token_amount - .saturating_mul(info.price_ratio) - .div_rem(info.scale); - if remainder.is_zero() { - Some(eth_amount) - } else { - Some(eth_amount.saturating_add(U256::from(1))) - } } diff --git a/crates/rpc/src/eth/receipt.rs b/crates/rpc/src/eth/receipt.rs index fcb397d..26418d4 100644 --- a/crates/rpc/src/eth/receipt.rs +++ b/crates/rpc/src/eth/receipt.rs @@ -51,7 +51,7 @@ impl MorphReceiptBuilder { where N: NodePrimitives, { - let fee_fields = morph_fee_fields(&input.receipt); + let tx_receipt_fields = morph_tx_receipt_fields(&input.receipt); let core_receipt = build_receipt(input, None, |receipt, next_log_index, meta| { let map_logs = |receipt: Receipt| { @@ -92,14 +92,14 @@ impl MorphReceiptBuilder { let receipt = MorphRpcReceipt { inner: core_receipt, - l1_fee: fee_fields.l1_fee, - version: fee_fields.version, - fee_token_id: fee_fields.fee_token_id.map(U64::from), - fee_rate: fee_fields.fee_rate, - token_scale: fee_fields.token_scale, - fee_limit: fee_fields.fee_limit, - reference: fee_fields.reference, - memo: fee_fields.memo, + l1_fee: tx_receipt_fields.l1_fee, + version: tx_receipt_fields.version, + fee_token_id: tx_receipt_fields.fee_token_id.map(U64::from), + fee_rate: tx_receipt_fields.fee_rate, + token_scale: tx_receipt_fields.token_scale, + fee_limit: tx_receipt_fields.fee_limit, + reference: tx_receipt_fields.reference, + memo: tx_receipt_fields.memo, }; Self { receipt } @@ -120,7 +120,7 @@ where /// Morph-specific fee fields extracted from a receipt. #[derive(Debug, Default)] -struct MorphTxFields { +struct MorphTxReceiptFields { l1_fee: U256, version: Option, fee_token_id: Option, @@ -134,13 +134,13 @@ struct MorphTxFields { /// Extracts Morph-specific fee fields from a receipt. /// /// L1 message receipts return zero/None for all fee fields. -fn morph_fee_fields(receipt: &MorphReceipt) -> MorphTxFields { +fn morph_tx_receipt_fields(receipt: &MorphReceipt) -> MorphTxReceiptFields { match receipt { MorphReceipt::Legacy(r) | MorphReceipt::Eip2930(r) | MorphReceipt::Eip1559(r) | MorphReceipt::Eip7702(r) - | MorphReceipt::Morph(r) => MorphTxFields { + | MorphReceipt::Morph(r) => MorphTxReceiptFields { l1_fee: r.l1_fee, version: r.version, fee_token_id: r.fee_token_id, @@ -150,6 +150,6 @@ fn morph_fee_fields(receipt: &MorphReceipt) -> MorphTxFields { reference: r.reference, memo: r.memo.clone(), }, - MorphReceipt::L1Msg(_) => MorphTxFields::default(), + MorphReceipt::L1Msg(_) => MorphTxReceiptFields::default(), } } diff --git a/crates/rpc/src/eth/transaction.rs b/crates/rpc/src/eth/transaction.rs index 739a8cf..d774268 100644 --- a/crates/rpc/src/eth/transaction.rs +++ b/crates/rpc/src/eth/transaction.rs @@ -5,15 +5,13 @@ use crate::types::transaction::MorphRpcTransaction; use alloy_consensus::{ EthereumTxEnvelope, SignableTransaction, Transaction, TxEip4844, transaction::Recovered, }; -use alloy_eips::eip2718::Encodable2718; use alloy_network::TxSigner; -use alloy_primitives::{Address, Bytes, Signature, TxKind, U64, U256}; +use alloy_primitives::{Address, Signature, TxKind, U64, U256}; use alloy_rpc_types_eth::{AccessList, Transaction as RpcTransaction, TransactionInfo}; use reth_rpc_convert::{ SignTxRequestError, SignableTxRequest, TryIntoSimTx, TryIntoTxEnv, transaction::FromConsensusTx, }; use reth_rpc_eth_types::EthApiError; -use revm::context::Transaction as RevmTransaction; use std::convert::Infallible; use morph_primitives::{MorphTxEnvelope, TxMorph}; @@ -165,12 +163,8 @@ impl TryIntoTxEnv for MorphTransactionRequest { let reference = self.reference; let memo = self.memo; let inner = self.inner; - let access_list = inner.access_list.clone().unwrap_or_default(); - let inner_tx_env = inner - .clone() - .try_into_tx_env(evm_env) - .map_err(EthApiError::from)?; + let inner_tx_env = inner.try_into_tx_env(evm_env).map_err(EthApiError::from)?; let mut tx_env = MorphTxEnv::new(inner_tx_env); tx_env.fee_token_id = match fee_token_id { @@ -195,9 +189,9 @@ impl TryIntoTxEnv for MorphTransactionRequest { Some(morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1); } - let rlp_bytes = encode_tx_for_l1_fee(&tx_env, access_list, evm_env, inner)?; - - tx_env.rlp_bytes = Some(rlp_bytes); + // For eth_call/eth_estimateGas we still need encoded tx bytes so REVM can + // account for L1 data fee correctly during validation. + tx_env.rlp_bytes = Some(tx_env.encode_for_l1_fee(evm_env.cfg_env.chain_id)); Ok(tx_env) } } @@ -278,103 +272,3 @@ fn try_build_morph_tx_from_request( memo, })) } - -/// Attempts to build a [`TxMorph`] from an existing transaction environment. -/// -/// Returns `Ok(Some(tx))` if a MorphTx should be constructed (always Version 1), -/// `Ok(None)` if this should be a standard Ethereum transaction, -/// or `Err(...)` if there's a validation error. -/// -/// A MorphTx is constructed when any of these conditions are met: -/// - `feeTokenID > 0` (ERC20 gas payment) -/// - `reference` is present -/// - `memo` is present and non-empty -fn try_build_morph_tx_from_env( - tx_env: &MorphTxEnv, - fee_token_id: U64, - fee_limit: U256, - access_list: AccessList, - evm_env: &EvmEnv, - reference: Option, - memo: Option, -) -> Result, EthApiError> { - let fee_token_id_u16 = u16::try_from(fee_token_id.to::()) - .map_err(|_| EthApiError::InvalidParams("invalid token".to_string()))?; - - // Check if this should be a MorphTx - let has_fee_token = fee_token_id_u16 > 0; - let has_reference = reference.is_some(); - let has_memo = memo.as_ref().is_some_and(|m| !m.is_empty()); - - if !has_fee_token && !has_reference && !has_memo { - // No Morph-specific fields → standard Ethereum tx - return Ok(None); - } - - // All MorphTx are constructed as Version 1 - let version = morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1; - - let chain_id = tx_env.chain_id().unwrap_or(evm_env.cfg_env.chain_id); - let input = tx_env.input().clone(); - let to = tx_env.kind(); - let max_fee_per_gas = tx_env.max_fee_per_gas(); - let max_priority_fee_per_gas = tx_env.max_priority_fee_per_gas().unwrap_or_default(); - - Ok(Some(TxMorph { - chain_id, - nonce: tx_env.nonce(), - gas_limit: tx_env.gas_limit() as u128, - max_fee_per_gas, - max_priority_fee_per_gas, - to, - value: tx_env.value(), - access_list, - input, - fee_token_id: fee_token_id_u16, - fee_limit, - version, - reference, - memo, - })) -} - -/// Encodes a transaction for L1 fee calculation. -/// -/// Returns the RLP-encoded bytes used to calculate the L1 data fee. -fn encode_tx_for_l1_fee( - tx_env: &MorphTxEnv, - access_list: AccessList, - evm_env: &EvmEnv, - inner: alloy_rpc_types_eth::TransactionRequest, -) -> Result { - let fee_token_id = U64::from(tx_env.fee_token_id.unwrap_or_default()); - let fee_limit = tx_env.fee_limit.unwrap_or_default(); - let reference = tx_env.reference; - let memo = tx_env.memo.clone(); - - // Try to build a MorphTx; returns None if this should be a standard Ethereum tx - match try_build_morph_tx_from_env( - tx_env, - fee_token_id, - fee_limit, - access_list, - evm_env, - reference, - memo, - )? { - Some(morph_tx) => Ok(encode_2718(morph_tx)), - None => { - let envelope = inner - .build_typed_simulate_transaction() - .map_err(|err| EthApiError::InvalidParams(err.to_string()))?; - Ok(encode_2718(envelope)) - } - } -} - -/// Encodes a transaction using EIP-2718 typed transaction encoding. -fn encode_2718(tx: T) -> Bytes { - let mut out = Vec::with_capacity(tx.encode_2718_len()); - tx.encode_2718(&mut out); - Bytes::from(out) -} diff --git a/crates/txpool/Cargo.toml b/crates/txpool/Cargo.toml index 0ebfd7a..77e6e13 100644 --- a/crates/txpool/Cargo.toml +++ b/crates/txpool/Cargo.toml @@ -14,7 +14,7 @@ workspace = true [dependencies] # Morph morph-chainspec.workspace = true -morph-primitives = { workspace = true, features = ["serde-bincode-compat"] } +morph-primitives = { workspace = true, features = ["reth-codec"] } morph-revm.workspace = true # Reth diff --git a/crates/txpool/src/error.rs b/crates/txpool/src/error.rs index 169c0ec..20e054c 100644 --- a/crates/txpool/src/error.rs +++ b/crates/txpool/src/error.rs @@ -12,7 +12,7 @@ use std::fmt; /// These errors are specific to transactions that use ERC20 tokens for gas payment. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MorphTxError { - /// Token ID 0 is not allowed (reserved for ETH). + /// Transaction does not contain valid MorphTx fee fields. InvalidTokenId, /// Token is not registered in the L2TokenRegistry. @@ -75,7 +75,7 @@ impl fmt::Display for MorphTxError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidTokenId => { - write!(f, "invalid token ID: token ID 0 is reserved for ETH") + write!(f, "invalid MorphTx fee fields") } Self::TokenNotFound { token_id } => { write!( @@ -130,7 +130,7 @@ impl PoolTransactionError for MorphTxError { // MorphTx validation errors are not necessarily "bad" transactions that warrant // peer penalization. They are often just insufficient balance or inactive tokens. match self { - // Invalid token ID is a bad transaction (should never be submitted) + // Missing/invalid MorphTx fee fields indicate malformed transaction input. Self::InvalidTokenId => true, // Token not found or not active - could be due to temporary state, not penalizable Self::TokenNotFound { .. } | Self::TokenNotActive { .. } => false, @@ -164,7 +164,7 @@ mod tests { #[test] fn test_error_display() { let err = MorphTxError::InvalidTokenId; - assert!(err.to_string().contains("token ID 0")); + assert!(err.to_string().contains("invalid MorphTx fee fields")); let err = MorphTxError::TokenNotFound { token_id: 1 }; assert!(err.to_string().contains("token ID 1")); diff --git a/crates/txpool/src/lib.rs b/crates/txpool/src/lib.rs index 2754164..bac2d0c 100644 --- a/crates/txpool/src/lib.rs +++ b/crates/txpool/src/lib.rs @@ -15,7 +15,7 @@ //! The validator performs the following Morph-specific checks: //! - Rejects EIP-4844 blob transactions (not supported on L2) //! - Rejects L1 message transactions (only included by sequencer) -//! - Validates L1 data fee affordability (optional) +//! - Validates L1 data fee affordability //! - Validates MorphTx (0x7F) ERC20 token balance and fee_limit //! //! # MorphTx (0x7F) Validation @@ -23,9 +23,11 @@ //! MorphTx allows users to pay gas fees using ERC20 tokens. The validator: //! 1. Checks the token is registered and active in L2TokenRegistry //! 2. Calculates required token amount: `eth_to_token(gas_fee + l1_data_fee)` -//! 3. Verifies `fee_limit >= required_token_amount` -//! 4. Verifies `token_balance >= min(fee_limit, required_token_amount)` -//! 5. Verifies `eth_balance >= value` (transaction value is still in ETH) +//! 3. Uses effective token limit semantics: +//! - `fee_limit == 0` => treat as token balance +//! - `fee_limit > balance` => cap by token balance +//! 4. Verifies effective limit can cover required token amount +//! 5. Verifies `eth_balance >= value` (and for `fee_token_id == 0`, ETH covers full cost) #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![cfg_attr(docsrs, feature(doc_cfg), allow(unexpected_cfgs))] @@ -43,9 +45,7 @@ mod maintain; pub use maintain::maintain_morph_pool; mod morph_tx_validation; -pub use morph_tx_validation::{ - MorphTxValidationInput, MorphTxValidationResult, extract_morph_tx_fields, validate_morph_tx, -}; +pub use morph_tx_validation::{MorphTxValidationInput, MorphTxValidationResult, validate_morph_tx}; use reth_transaction_pool::{CoinbaseTipOrdering, Pool, TransactionValidationTaskExecutor}; diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 693089e..f04b227 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -21,8 +21,9 @@ //! and `demoteUnexecutables` (tx_pool.go), but implemented as a separate //! maintenance task since we cannot modify reth's internal pool logic. +use alloy_consensus::Transaction; use alloy_eips::eip2718::Encodable2718; -use alloy_primitives::{TxHash, U256}; +use alloy_primitives::{Address, TxHash, U256}; use futures::StreamExt; use morph_chainspec::hardfork::MorphHardforks; use morph_primitives::MorphTxEnvelope; @@ -34,7 +35,71 @@ use reth_revm::Database; use reth_revm::database::StateProviderDatabase; use reth_storage_api::StateProviderFactory; use reth_transaction_pool::{EthPoolTransaction, PoolTransaction, TransactionPool}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; + +/// Sender-level rolling affordability budget used during maintenance revalidation. +#[derive(Debug, Clone, Default)] +struct SenderBudget { + /// Remaining ETH budget for this sender. + eth_balance: U256, + /// Remaining token budget per `fee_token_id`. + token_balances: HashMap, +} + +/// Applies cumulative sender-budget checks and consumes budget on success. +/// +/// Returns `true` if the transaction can be afforded under the current rolling budget. +#[allow(clippy::too_many_arguments)] +fn consume_sender_budget( + budget: &mut SenderBudget, + uses_token_fee: bool, + tx_value: U256, + gas_limit: u64, + max_fee_per_gas: u128, + l1_data_fee: U256, + token_id: Option, + fee_limit: Option, + required_token_amount: U256, + state_token_balance: Option, +) -> bool { + if uses_token_fee { + let (token_id, fee_limit) = match (token_id, fee_limit) { + (Some(token_id), Some(fee_limit)) => (token_id, fee_limit), + _ => return false, + }; + + let token_budget = budget + .token_balances + .entry(token_id) + .or_insert(state_token_balance.unwrap_or(U256::ZERO)); + + // Match REVM semantics with rolling sender budget: + // - fee_limit == 0 => use remaining token budget + // - fee_limit > remaining => cap by remaining token budget + let effective_limit = if fee_limit.is_zero() || fee_limit > *token_budget { + *token_budget + } else { + fee_limit + }; + + if effective_limit < required_token_amount || tx_value > budget.eth_balance { + return false; + } + + *token_budget = (*token_budget).saturating_sub(required_token_amount); + budget.eth_balance = budget.eth_balance.saturating_sub(tx_value); + return true; + } + + // ETH-fee path: consume full tx ETH cost (value + gas fee + l1_data_fee). + let gas_fee = U256::from(gas_limit).saturating_mul(U256::from(max_fee_per_gas)); + let total_eth_cost = gas_fee.saturating_add(l1_data_fee).saturating_add(tx_value); + if total_eth_cost > budget.eth_balance { + return false; + } + budget.eth_balance = budget.eth_balance.saturating_sub(total_eth_cost); + true +} /// Maintains the Morph transaction pool by revalidating MorphTx transactions. /// @@ -126,22 +191,28 @@ where "Revalidating MorphTx transactions" ); - // Revalidate each MorphTx and collect invalid ones + // Group by sender and process in nonce order so affordability is validated cumulatively. + let mut txs_by_sender: HashMap> = HashMap::new(); + for pooled_tx in morph_txs { + let sender = pooled_tx.transaction.sender(); + txs_by_sender.entry(sender).or_default().push(pooled_tx); + } + + // Revalidate each sender's MorphTx set and collect invalid ones let mut to_remove: HashSet = HashSet::new(); - for pooled_tx in morph_txs { - let tx = &pooled_tx.transaction; - let consensus_tx = tx.clone_into_consensus(); - let sender = tx.sender(); + for (sender, mut sender_txs) in txs_by_sender { + sender_txs + .sort_by_key(|pooled_tx| pooled_tx.transaction.clone_into_consensus().nonce()); - // Get ETH balance for tx.value() check + // Initialize sender ETH budget once. let eth_balance = match db.basic(sender) { Ok(Some(account)) => account.balance, Ok(None) => U256::ZERO, Err(err) => { tracing::warn!( target: "morph_txpool::maintain", - tx_hash = ?tx.hash(), + ?sender, ?err, "Failed to get account balance" ); @@ -149,28 +220,73 @@ where } }; - // Calculate L1 data fee - let mut encoded = Vec::with_capacity(consensus_tx.encode_2718_len()); - consensus_tx.encode_2718(&mut encoded); - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(&encoded, hardfork); - - // Use shared validation logic (includes ETH balance >= tx.value() check) - let input = crate::MorphTxValidationInput { - consensus_tx: &consensus_tx, - sender, + let mut budget = SenderBudget { eth_balance, - l1_data_fee, - hardfork, + token_balances: HashMap::new(), }; - if let Err(err) = crate::validate_morph_tx(&mut db, &input) { - tracing::debug!( - target: "morph_txpool::maintain", - tx_hash = ?tx.hash(), - ?err, - "Removing MorphTx: validation failed" - ); - to_remove.insert(*tx.hash()); + for pooled_tx in sender_txs { + let tx = &pooled_tx.transaction; + let consensus_tx = tx.clone_into_consensus(); + + // Calculate L1 data fee for this transaction. + let mut encoded = Vec::with_capacity(consensus_tx.encode_2718_len()); + consensus_tx.encode_2718(&mut encoded); + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(&encoded, hardfork); + + // Use shared validation logic first with current sender ETH budget. + let input = crate::MorphTxValidationInput { + consensus_tx: &consensus_tx, + sender, + eth_balance: budget.eth_balance, + l1_data_fee, + hardfork, + }; + + let validation = match crate::validate_morph_tx(&mut db, &input) { + Ok(v) => v, + Err(err) => { + tracing::debug!( + target: "morph_txpool::maintain", + tx_hash = ?tx.hash(), + ?sender, + ?err, + "Removing MorphTx: validation failed" + ); + to_remove.insert(*tx.hash()); + continue; + } + }; + + let fields = consensus_tx.morph_fields(); + let state_token_balance = validation.token_info.as_ref().map(|info| info.balance); + let token_id = fields.as_ref().map(|f| f.fee_token_id); + let fee_limit = fields.as_ref().map(|f| f.fee_limit); + + if !consume_sender_budget( + &mut budget, + validation.uses_token_fee, + consensus_tx.value(), + consensus_tx.gas_limit(), + consensus_tx.max_fee_per_gas(), + l1_data_fee, + token_id, + fee_limit, + validation.required_token_amount, + state_token_balance, + ) { + tracing::debug!( + target: "morph_txpool::maintain", + tx_hash = ?tx.hash(), + ?sender, + uses_token_fee = validation.uses_token_fee, + token_id = ?token_id, + required_token_amount = ?validation.required_token_amount, + "Removing MorphTx: insufficient cumulative sender budget" + ); + to_remove.insert(*tx.hash()); + continue; + } } } @@ -188,3 +304,195 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn consume_eth_fee_path_updates_budget_and_rejects_when_exhausted() { + let mut budget = SenderBudget { + eth_balance: U256::from(100u64), + token_balances: HashMap::new(), + }; + + let first = consume_sender_budget( + &mut budget, + false, + U256::from(20u64), + 10, + 3, + U256::from(5u64), + None, + None, + U256::ZERO, + None, + ); + assert!(first); + // total cost = value(20) + gas(30) + l1(5) = 55 + assert_eq!(budget.eth_balance, U256::from(45u64)); + + let second = consume_sender_budget( + &mut budget, + false, + U256::from(20u64), + 10, + 3, + U256::from(5u64), + None, + None, + U256::ZERO, + None, + ); + assert!(!second); + assert_eq!(budget.eth_balance, U256::from(45u64)); + } + + #[test] + fn consume_token_fee_path_tracks_cumulative_token_budget() { + let mut budget = SenderBudget { + eth_balance: U256::from(10u64), + token_balances: HashMap::new(), + }; + + let first = consume_sender_budget( + &mut budget, + true, + U256::ZERO, + 1, + 1, + U256::ZERO, + Some(7), + Some(U256::ZERO), // fee_limit=0 => use full remaining budget + U256::from(60u64), + Some(U256::from(100u64)), + ); + assert!(first); + assert_eq!( + budget.token_balances.get(&7).copied(), + Some(U256::from(40u64)) + ); + + let second = consume_sender_budget( + &mut budget, + true, + U256::ZERO, + 1, + 1, + U256::ZERO, + Some(7), + Some(U256::ZERO), + U256::from(50u64), + None, + ); + assert!(!second); + assert_eq!( + budget.token_balances.get(&7).copied(), + Some(U256::from(40u64)) + ); + } + + #[test] + fn consume_token_fee_path_honors_fee_limit_and_eth_value() { + let mut budget = SenderBudget { + eth_balance: U256::from(5u64), + token_balances: HashMap::new(), + }; + + // fee_limit caps the payment below required amount => reject + let limited = consume_sender_budget( + &mut budget, + true, + U256::ZERO, + 1, + 1, + U256::ZERO, + Some(9), + Some(U256::from(30u64)), + U256::from(40u64), + Some(U256::from(100u64)), + ); + assert!(!limited); + + // Enough token, but ETH value exceeds remaining ETH budget => reject + let eth_value_fail = consume_sender_budget( + &mut budget, + true, + U256::from(6u64), + 1, + 1, + U256::ZERO, + Some(9), + Some(U256::from(100u64)), + U256::from(10u64), + Some(U256::from(100u64)), + ); + assert!(!eth_value_fail); + } + + #[test] + fn consume_mixed_path_sequence_tracks_eth_and_token_together() { + let mut budget = SenderBudget { + eth_balance: U256::from(100u64), + token_balances: HashMap::new(), + }; + + // Tx1: token-fee path, consumes token only for fee and ETH for value. + let tx1 = consume_sender_budget( + &mut budget, + true, + U256::from(10u64), // value in ETH + 1, + 1, + U256::ZERO, + Some(3), + Some(U256::ZERO), // unlimited by tx field => bounded by remaining token budget + U256::from(70u64), + Some(U256::from(100u64)), + ); + assert!(tx1); + assert_eq!(budget.eth_balance, U256::from(90u64)); + assert_eq!( + budget.token_balances.get(&3).copied(), + Some(U256::from(30u64)) + ); + + // Tx2: ETH-fee path, consumes full ETH cost. + let tx2 = consume_sender_budget( + &mut budget, + false, + U256::from(20u64), // value + 5, // gas_limit + 4, // max_fee_per_gas => gas fee = 20 + U256::from(10u64), // l1 fee + None, + None, + U256::ZERO, + None, + ); + assert!(tx2); + // total eth cost = 20(value) + 20(gas) + 10(l1) = 50 + assert_eq!(budget.eth_balance, U256::from(40u64)); + + // Tx3: token-fee path should now fail because remaining token budget is only 30. + let tx3 = consume_sender_budget( + &mut budget, + true, + U256::ZERO, + 1, + 1, + U256::ZERO, + Some(3), + Some(U256::ZERO), + U256::from(35u64), + None, + ); + assert!(!tx3); + // Budgets stay unchanged on failed consumption. + assert_eq!(budget.eth_balance, U256::from(40u64)); + assert_eq!( + budget.token_balances.get(&3).copied(), + Some(U256::from(30u64)) + ); + } +} diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index 673bb07..2a781d8 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -33,8 +33,10 @@ pub struct MorphTxValidationInput<'a> { /// Result of MorphTx validation. #[derive(Debug)] pub struct MorphTxValidationResult { - /// The token info fetched during validation - pub token_info: TokenFeeInfo, + /// Whether this tx uses token fee payment (`fee_token_id > 0`) + pub uses_token_fee: bool, + /// The token info fetched during validation (token-fee tx only) + pub token_info: Option, /// The required token amount pub required_token_amount: U256, /// The amount that will be paid (min of fee_limit and required) @@ -45,14 +47,13 @@ pub struct MorphTxValidationResult { /// /// This is the main entry point for MorphTx validation. It: /// 1. Validates ETH balance >= tx.value() (value is still paid in ETH) -/// 2. Validates token balance and fee_limit +/// 2. For `fee_token_id > 0`, validates token balance with REVM-compatible fee_limit semantics +/// 3. For `fee_token_id == 0`, validates ETH can cover full tx cost + L1 data fee /// pub fn validate_morph_tx( db: &mut DB, input: &MorphTxValidationInput<'_>, ) -> Result { - // Check ETH balance >= tx.value() (value is still paid in ETH) - // Reference: geth tx_pool.go:1649 - costLimit.Cmp(tx.Value()) < 0 let tx_value = input.consensus_tx.value(); if tx_value > input.eth_balance { return Err(MorphTxError::InsufficientEthForValue { @@ -61,17 +62,37 @@ pub fn validate_morph_tx( }); } - // Extract MorphTx fields - let (fee_token_id, fee_limit) = - extract_morph_tx_fields(input.consensus_tx).ok_or(MorphTxError::InvalidTokenId)?; + let fields = input + .consensus_tx + .morph_fields() + .ok_or(MorphTxError::InvalidTokenId)?; + let fee_token_id = fields.fee_token_id; + let fee_limit = fields.fee_limit; - // Token ID 0 is reserved for ETH + // Shared fee components used by both ETH-fee and token-fee branches. + let gas_limit = U256::from(input.consensus_tx.gas_limit()); + let max_fee_per_gas = U256::from(input.consensus_tx.max_fee_per_gas()); + let gas_fee = gas_limit.saturating_mul(max_fee_per_gas); + let total_eth_fee = gas_fee.saturating_add(input.l1_data_fee); + let total_eth_cost = total_eth_fee.saturating_add(tx_value); + + // fee_token_id == 0 means MorphTx uses ETH-fee path (reference/memo-only MorphTx). if fee_token_id == 0 { - return Err(MorphTxError::InvalidTokenId); + if total_eth_cost > input.eth_balance { + return Err(MorphTxError::InsufficientEthForValue { + balance: input.eth_balance, + value: total_eth_cost, + }); + } + return Ok(MorphTxValidationResult { + uses_token_fee: false, + token_info: None, + required_token_amount: U256::ZERO, + amount_to_pay: U256::ZERO, + }); } - // Fetch token info from L2TokenRegistry - let token_info = TokenFeeInfo::fetch(db, fee_token_id, input.sender, input.hardfork) + let token_info = TokenFeeInfo::load_for_caller(db, fee_token_id, input.sender, input.hardfork) .map_err(|err| MorphTxError::TokenInfoFetchFailed { token_id: fee_token_id, message: format!("{err:?}"), @@ -94,52 +115,35 @@ pub fn validate_morph_tx( }); } - // Calculate gas fee in ETH - let gas_limit = U256::from(input.consensus_tx.gas_limit()); - let max_fee_per_gas = U256::from(input.consensus_tx.max_fee_per_gas()); - let gas_fee = gas_limit.saturating_mul(max_fee_per_gas); - - // Total ETH fee = gas_fee + l1_data_fee - let total_eth_fee = gas_fee.saturating_add(input.l1_data_fee); - - // Convert ETH fee to token amount let required_token_amount = token_info.eth_to_token_amount(total_eth_fee); - // Check fee_limit >= required_token_amount - if fee_limit < required_token_amount { - return Err(MorphTxError::FeeLimitTooLow { - fee_limit, - required: required_token_amount, - }); - } - - // Check token balance >= required amount (use min of fee_limit and required) - let amount_to_pay = fee_limit.min(required_token_amount); - if token_info.balance < amount_to_pay { + // Match REVM semantics: + // - fee_limit == 0 => use token balance as effective limit + // - fee_limit > balance => cap by token balance + let effective_limit = if fee_limit.is_zero() || fee_limit > token_info.balance { + token_info.balance + } else { + fee_limit + }; + + // Check token balance against effective limit. + if effective_limit < required_token_amount { return Err(MorphTxError::InsufficientTokenBalance { token_id: fee_token_id, token_address: token_info.token_address, - balance: token_info.balance, - required: amount_to_pay, + balance: effective_limit, + required: required_token_amount, }); } Ok(MorphTxValidationResult { - token_info, + uses_token_fee: true, + token_info: Some(token_info), required_token_amount, - amount_to_pay, + amount_to_pay: required_token_amount, }) } -/// Helper function to extract MorphTx fields from a consensus transaction. -/// -/// Returns `None` if this is not a MorphTx or if required fields are missing. -pub fn extract_morph_tx_fields(tx: &MorphTxEnvelope) -> Option<(u16, U256)> { - let fee_token_id = tx.fee_token_id()?; - let fee_limit = tx.fee_limit()?; - Some((fee_token_id, fee_limit)) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index e51d499..b53ac94 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -77,7 +77,7 @@ impl MorphL1BlockInfo { /// This validator extends [`EthTransactionValidator`] with Morph-specific checks: /// - Rejects EIP-4844 blob transactions (not supported on L2) /// - Rejects L1 message transactions (only included by sequencer) -/// - Optionally validates L1 data fee affordability +/// - Validates L1 data fee affordability /// - Validates MorphTx (0x7F) ERC20 token balance and fee_limit /// /// # MorphTx Validation @@ -99,9 +99,6 @@ pub struct MorphTransactionValidator { inner: EthTransactionValidator, /// Additional block info required for validation. block_info: Arc, - /// If true, ensure that the transaction's sender has enough balance to cover the L1 gas fee - /// derived from the tracked L1 block info. - require_l1_data_gas_fee: bool, } impl MorphTransactionValidator { @@ -128,21 +125,6 @@ impl MorphTransactionValidator { self.block_info.number() } - /// Whether to ensure that the transaction's sender has enough balance to also cover the L1 gas - /// fee. - pub fn require_l1_data_gas_fee(self, require_l1_data_gas_fee: bool) -> Self { - Self { - require_l1_data_gas_fee, - ..self - } - } - - /// Returns whether this validator also requires the transaction's sender to have enough balance - /// to cover the L1 gas fee. - pub const fn requires_l1_data_gas_fee(&self) -> bool { - self.require_l1_data_gas_fee - } - /// Returns a reference to the block info tracker. pub fn block_info(&self) -> &Arc { &self.block_info @@ -182,7 +164,6 @@ where Self { inner, block_info: Arc::new(block_info), - require_l1_data_gas_fee: true, } } @@ -232,7 +213,7 @@ where /// - Rejects EIP-4844 blob transactions /// - Rejects L1 message transactions /// - Validates MorphTx (0x7F) ERC20 token balance and fee_limit - /// - Ensures that the account has enough balance to cover the L1 gas cost (if enabled) + /// - Ensures that the account has enough balance to cover the L1 gas cost pub fn validate_one( &self, origin: TransactionOrigin, @@ -287,19 +268,41 @@ where if is_morph_tx { // MorphTx: validate ERC20 token balance let sender = valid_tx.transaction().sender(); - if let Err(err) = self.validate_morph_tx_balance( + let validation = match self.validate_morph_tx_balance( valid_tx.transaction(), sender, balance, l1_data_fee, hardfork, ) { - return TransactionValidationOutcome::Invalid( - valid_tx.into_transaction(), - err.into(), - ); + Ok(v) => v, + Err(err) => { + return TransactionValidationOutcome::Invalid( + valid_tx.into_transaction(), + err.into(), + ); + } + }; + + // MorphTx with fee_token_id = 0 uses ETH fee path and must pass + // the same ETH affordability check as regular txs. + if !validation.uses_token_fee { + let cost = valid_tx.transaction().cost().saturating_add(l1_data_fee); + if cost > balance { + return TransactionValidationOutcome::Invalid( + valid_tx.into_transaction(), + InvalidTransactionError::InsufficientFunds( + GotExpected { + got: balance, + expected: cost, + } + .into(), + ) + .into(), + ); + } } - } else if self.requires_l1_data_gas_fee() { + } else { // Regular transaction: validate ETH balance covers cost + L1 fee let cost = valid_tx.transaction().cost().saturating_add(l1_data_fee); if cost > balance { @@ -333,13 +336,11 @@ where /// Validates MorphTx (0x7F) ERC20 token balance and fee_limit. /// /// This method performs the following checks (reference: go-ethereum tx_pool.go:727-791): - /// 1. Token ID must be non-zero (0 is reserved for ETH) - /// 2. Token must be registered in L2TokenRegistry - /// 3. Token must be active for gas payment - /// 4. Token price ratio must be valid (non-zero) - /// 5. fee_limit must be >= required token amount - /// 6. Token balance must be >= required token amount - /// 7. ETH balance must be >= transaction value (value is still in ETH) + /// 1. `fee_token_id == 0`: ETH-fee path, require ETH affordability for `cost + l1_fee` + /// 2. `fee_token_id > 0`: token must be registered and active in L2TokenRegistry + /// 3. Token price ratio must be valid (non-zero) + /// 4. Effective token limit must cover required token amount + /// 5. ETH balance must be >= transaction value (value is still in ETH) fn validate_morph_tx_balance( &self, tx: &Tx, @@ -347,7 +348,7 @@ where eth_balance: U256, l1_data_fee: U256, hardfork: morph_chainspec::hardfork::MorphHardfork, - ) -> Result<(), MorphTxError> { + ) -> Result { let consensus_tx = tx.clone_into_consensus(); // Get state provider for token info lookup @@ -371,20 +372,26 @@ where }; let result = crate::validate_morph_tx(&mut db, &input)?; + let token_balance = result + .token_info + .as_ref() + .map(|info| info.balance) + .unwrap_or_default(); tracing::trace!( target: "morph_txpool", fee_token_id = ?consensus_tx.fee_token_id(), fee_limit = ?consensus_tx.fee_limit(), + uses_token_fee = result.uses_token_fee, required_token_amount = ?result.required_token_amount, - token_balance = ?result.token_info.balance, + token_balance = ?token_balance, l1_data_fee = ?l1_data_fee, eth_balance = ?eth_balance, tx_value = ?consensus_tx.value(), "MorphTx validation passed" ); - Ok(()) + Ok(result) } /// Validates all given transactions. @@ -459,7 +466,7 @@ mod tests { use morph_chainspec::MORPH_MAINNET; use morph_primitives::TxL1Msg; use reth_primitives_traits::Recovered; - use reth_provider::test_utils::MockEthProvider; + use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; use reth_transaction_pool::{ blobstore::InMemoryBlobStore, validate::EthTransactionValidatorBuilder, }; @@ -525,16 +532,16 @@ mod tests { fn validate_valid_eip1559_transaction() { // Create validator with mock provider and disable balance check for simplicity let client = MockEthProvider::default().with_chain_spec(MORPH_MAINNET.clone()); + let signer = address!("0000000000000000000000000000000000000001"); + client.add_account(signer, ExtendedAccount::new(0, U256::from(10u128.pow(18)))); let eth_validator = EthTransactionValidatorBuilder::new(client) .no_shanghai() .no_cancun() .disable_balance_check() .build(InMemoryBlobStore::default()); - let validator = - MorphTransactionValidator::new(eth_validator).require_l1_data_gas_fee(false); // Disable L1 fee check for simplicity + let validator = MorphTransactionValidator::new(eth_validator); let origin = TransactionOrigin::External; - let signer = address!("0000000000000000000000000000000000000001"); // Create valid EIP-1559 transaction let tx = TxEip1559 { @@ -575,16 +582,16 @@ mod tests { fn validate_valid_legacy_transaction() { // Create validator with mock provider and disable balance check for simplicity let client = MockEthProvider::default().with_chain_spec(MORPH_MAINNET.clone()); + let signer = address!("0000000000000000000000000000000000000001"); + client.add_account(signer, ExtendedAccount::new(0, U256::from(10u128.pow(18)))); let eth_validator = EthTransactionValidatorBuilder::new(client) .no_shanghai() .no_cancun() .disable_balance_check() .build(InMemoryBlobStore::default()); - let validator = - MorphTransactionValidator::new(eth_validator).require_l1_data_gas_fee(false); // Disable L1 fee check for simplicity + let validator = MorphTransactionValidator::new(eth_validator); let origin = TransactionOrigin::External; - let signer = address!("0000000000000000000000000000000000000001"); // Create valid Legacy transaction let tx = TxLegacy {