diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 478e9f4..ad465a9 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -33,4 +33,24 @@ ignore = [ # Transitive dependency via reth-dns-discovery (reth v1.11.3) and libp2p-mdns # (libp2p 0.56.0) — both pin hickory-proto to 0.25.x; cannot update without bumping reth. "RUSTSEC-2026-0119", + + # core2 0.4.0 is unmaintained and all versions yanked; no replacement available. + # Transitive dependency via multihash 0.19.3 → multiaddr → libp2p 0.56.0. Cannot update + # without bumping the malachite/libp2p dependency chain. + "RUSTSEC-2026-0105", + + # proc-macro-error2 2.0.1 is unmaintained; no newer version available in the 2.x series. + # Transitive dependency via aquamarine → reth-transaction-pool (reth v1.11.3). + # Cannot update without bumping reth. + "RUSTSEC-2026-0173", + + # git2 0.20.4 - potential UB in Remote::list(); no patched version available. + # Transitive dependency via vergen-git2 9.1.0 → reth-node-core (reth v1.11.3). + # Cannot update without bumping reth. + "RUSTSEC-2026-0183", + + # git2 0.20.4 - potential UB with Signature from buffer-created BlameHunk; no patched version. + # Transitive dependency via vergen-git2 9.1.0 → reth-node-core (reth v1.11.3). + # Cannot update without bumping reth. + "RUSTSEC-2026-0184", ] \ No newline at end of file diff --git a/.gitignore b/.gitignore index 52df018..8e93725 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,10 @@ node_modules/ .env.test.local .env.production.local .env.contract-ops +/public-release.env +/public-sync.env +/release.env +/release-refs.env # Rust-specific **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index 47d7021..e605350 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1211,6 +1211,7 @@ name = "arc-evm-node" version = "0.0.1" dependencies = [ "alloy-consensus", + "alloy-eips", "alloy-network", "alloy-primitives", "alloy-rpc-types-engine", @@ -1448,7 +1449,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-app" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-codec", "arc-malachitebft-config", @@ -1476,7 +1477,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-app-channel" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-app", "arc-malachitebft-config", @@ -1494,7 +1495,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-codec" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "bytes", ] @@ -1502,7 +1503,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-config" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-types", "bytesize", @@ -1516,7 +1517,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-consensus" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-driver", "arc-malachitebft-core-types", @@ -1536,7 +1537,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-driver" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-state-machine", "arc-malachitebft-core-types", @@ -1549,7 +1550,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-state-machine" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-types", "derive-where", @@ -1559,7 +1560,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-types" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-peer", "async-trait", @@ -1572,7 +1573,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-core-votekeeper" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-types", "derive-where", @@ -1583,7 +1584,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-discovery" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-metrics", "either", @@ -1598,7 +1599,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-engine" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-codec", "arc-malachitebft-config", @@ -1631,7 +1632,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-metrics" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-state-machine", "prometheus-client", @@ -1640,7 +1641,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-network" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-discovery", "arc-malachitebft-metrics", @@ -1669,7 +1670,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-peer" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "bs58", "multihash", @@ -1680,7 +1681,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-proto" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "prost 0.13.5", "prost-types 0.13.5", @@ -1690,7 +1691,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-signing" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-types", "async-trait", @@ -1700,7 +1701,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-signing-ed25519" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-types", "base64 0.22.1", @@ -1713,7 +1714,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-sync" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "arc-malachitebft-core-types", "arc-malachitebft-metrics", @@ -1735,7 +1736,7 @@ dependencies = [ [[package]] name = "arc-malachitebft-wal" version = "0.7.0-pre" -source = "git+https://github.com/circlefin/malachite.git?rev=8ee5d998#8ee5d998545087e73710c37e457cc03cb56b1463" +source = "git+https://github.com/circlefin/malachite.git?rev=5cd137fb#5cd137fb158fc52b55044eee9b090fd0d34996b0" dependencies = [ "advisory-lock", "bytes", @@ -4221,7 +4222,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 2.0.117", + "syn 1.0.109", ] [[package]] @@ -4493,7 +4494,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4798,7 +4799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7407,9 +7408,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -7856,7 +7857,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9075,7 +9076,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", "heck", - "itertools 0.12.1", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -9095,7 +9096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ "heck", - "itertools 0.12.1", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -9115,7 +9116,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -9128,7 +9129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -9141,7 +9142,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -9343,9 +9344,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", @@ -9373,7 +9374,7 @@ dependencies = [ "once_cell", "socket2 0.6.2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -12992,7 +12993,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -14132,7 +14133,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -15465,7 +15466,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f4e7ac8..3ab5cab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -269,67 +269,67 @@ wiremock = "0.6" [workspace.dependencies.malachitebft-app] package = "arc-malachitebft-app" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-app-channel] package = "arc-malachitebft-app-channel" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-codec] package = "arc-malachitebft-codec" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-config] package = "arc-malachitebft-config" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-core-consensus] package = "arc-malachitebft-core-consensus" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-core-state-machine] package = "arc-malachitebft-core-state-machine" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-core-types] package = "arc-malachitebft-core-types" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-network] package = "arc-malachitebft-network" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-peer] package = "arc-malachitebft-peer" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-proto] package = "arc-malachitebft-proto" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-signing] package = "arc-malachitebft-signing" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-signing-ed25519] package = "arc-malachitebft-signing-ed25519" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [workspace.dependencies.malachitebft-sync] package = "arc-malachitebft-sync" git = "https://github.com/circlefin/malachite.git" -rev = "8ee5d998" +rev = "5cd137fb" [profile.dev] # enable basic optimizations (otherwise reth can't process txs) diff --git a/assets/localdev/genesis.json b/assets/localdev/genesis.json index 433c210..88cd647 100644 --- a/assets/localdev/genesis.json +++ b/assets/localdev/genesis.json @@ -26,6 +26,7 @@ "zero5Block": 0, "zero6Block": 0, "zero7Time": 0, + "zero8Time": 0, "osakaTime": 0 }, "nonce": "0x0", diff --git a/crates/evm-node/Cargo.toml b/crates/evm-node/Cargo.toml index becb2d7..43acd24 100644 --- a/crates/evm-node/Cargo.toml +++ b/crates/evm-node/Cargo.toml @@ -15,6 +15,7 @@ integration = ["arc-evm/integration"] [dependencies] alloy-consensus = { workspace = true, features = ["serde"] } +alloy-eips.workspace = true alloy-network.workspace = true alloy-primitives.workspace = true alloy-rpc-types-engine.workspace = true diff --git a/crates/evm-node/src/rpc/arc.rs b/crates/evm-node/src/rpc/arc.rs index 7991fa8..1b29147 100644 --- a/crates/evm-node/src/rpc/arc.rs +++ b/crates/evm-node/src/rpc/arc.rs @@ -16,7 +16,7 @@ //! Top level file of `reth` node API extension RPCs -use crate::rpc::common::ARC_DEFAULT_BASE_URL; +use crate::rpc::common::{HexOrDecimalU64, ARC_DEFAULT_BASE_URL}; use crate::rpc::get_certificate::{ rpc_get_certificate, CertificateSource, HttpCertificateSource, RpcCommitCertificate, }; @@ -35,7 +35,7 @@ pub trait ArcApi { fn version(&self) -> RpcResult; #[method(name = "getCertificate")] - async fn get_certificate(&self, height: u64) -> RpcResult; + async fn get_certificate(&self, height: HexOrDecimalU64) -> RpcResult; } pub struct ArcApiImpl { @@ -50,8 +50,8 @@ impl ArcApiImpl { #[async_trait] impl ArcApiServer for ArcApiImpl { - async fn get_certificate(&self, height: u64) -> RpcResult { - rpc_get_certificate(&self.certificate_source, height).await + async fn get_certificate(&self, height: HexOrDecimalU64) -> RpcResult { + rpc_get_certificate(&self.certificate_source, height.as_u64()).await } fn version(&self) -> RpcResult { rpc_get_version() diff --git a/crates/evm-node/src/rpc/common.rs b/crates/evm-node/src/rpc/common.rs index a6e8827..3f01326 100644 --- a/crates/evm-node/src/rpc/common.rs +++ b/crates/evm-node/src/rpc/common.rs @@ -15,9 +15,58 @@ // limitations under the License. use jsonrpsee::types::{ErrorCode, ErrorObjectOwned}; +use serde::de; +use std::fmt; pub const ARC_DEFAULT_BASE_URL: &str = "http://127.0.0.1:31000"; +/// A u64 that deserializes from either a JSON number or a hex quantity string (`"0x…"`). +/// Ethereum proxies (e.g. eRPC) rewrite numeric params to hex; this accepts both. +#[derive(Debug, Clone, Copy)] +pub struct HexOrDecimalU64(u64); + +impl HexOrDecimalU64 { + pub fn as_u64(self) -> u64 { + self.0 + } +} + +impl From for HexOrDecimalU64 { + fn from(v: u64) -> Self { + Self(v) + } +} + +impl<'de> de::Deserialize<'de> for HexOrDecimalU64 { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_any(HexOrDecimalU64Visitor) + } +} + +struct HexOrDecimalU64Visitor; + +impl<'de> de::Visitor<'de> for HexOrDecimalU64Visitor { + type Value = HexOrDecimalU64; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a u64 or hex-encoded quantity string") + } + + fn visit_u64(self, v: u64) -> Result { + Ok(HexOrDecimalU64(v)) + } + + fn visit_str(self, v: &str) -> Result { + let hex = v + .strip_prefix("0x") + .or_else(|| v.strip_prefix("0X")) + .ok_or_else(|| de::Error::custom("hex string must start with 0x"))?; + u64::from_str_radix(hex, 16) + .map(HexOrDecimalU64) + .map_err(de::Error::custom) + } +} + pub fn invalid_params(msg: impl Into) -> ErrorObjectOwned { ErrorObjectOwned::owned(ErrorCode::InvalidParams.code(), msg.into(), None::<()>) } @@ -28,3 +77,43 @@ pub mod codes { /// Upstream service unreachable (TCP connect failures). pub const UPSTREAM_UNREACHABLE: i32 = -32005; } + +#[cfg(test)] +mod tests { + use super::*; + + fn deser(json: &str) -> Result { + serde_json::from_str(json) + } + + #[test] + fn accepts_decimal_number() { + assert_eq!(deser("123").unwrap().as_u64(), 123); + } + + #[test] + fn accepts_hex_string() { + assert_eq!(deser(r#""0x7b""#).unwrap().as_u64(), 123); + } + + #[test] + fn accepts_hex_uppercase_prefix() { + assert_eq!(deser(r#""0X7B""#).unwrap().as_u64(), 123); + } + + #[test] + fn accepts_zero() { + assert_eq!(deser("0").unwrap().as_u64(), 0); + assert_eq!(deser(r#""0x0""#).unwrap().as_u64(), 0); + } + + #[test] + fn rejects_non_hex_string() { + assert!(deser(r#""abc""#).is_err()); + } + + #[test] + fn rejects_empty_hex() { + assert!(deser(r#""0x""#).is_err()); + } +} diff --git a/crates/evm-node/src/rpc/get_certificate.rs b/crates/evm-node/src/rpc/get_certificate.rs index 901f536..6eb36ed 100644 --- a/crates/evm-node/src/rpc/get_certificate.rs +++ b/crates/evm-node/src/rpc/get_certificate.rs @@ -197,7 +197,9 @@ mod tests { #[tokio::test] async fn rejects_invalid_height() { let api = ArcApiImpl::new(MockCertSource); - let err = ArcApiServer::get_certificate(&api, 0).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 0.into()) + .await + .unwrap_err(); assert!(err.message().contains("height must be")); } @@ -211,7 +213,9 @@ mod tests { } } let api = ArcApiImpl::new(EmptySource); - let err = ArcApiServer::get_certificate(&api, 5).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 5.into()) + .await + .unwrap_err(); assert_eq!(err.code(), NOT_FOUND); } struct ConnectFail; @@ -225,7 +229,9 @@ mod tests { #[tokio::test] async fn connect_failure_maps_to_custom_code() { let api = ArcApiImpl::new(ConnectFail); - let err = ArcApiServer::get_certificate(&api, 10).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 10.into()) + .await + .unwrap_err(); assert_eq!(err.code(), UPSTREAM_UNREACHABLE); assert!(err.message().contains("unreachable")); } @@ -233,7 +239,7 @@ mod tests { #[tokio::test] async fn returns_real_cert_from_source() { let api = ArcApiImpl::new(MockCertSource); - let cert = ArcApiServer::get_certificate(&api, 7).await.unwrap(); + let cert = ArcApiServer::get_certificate(&api, 7.into()).await.unwrap(); assert_eq!(cert.height, 7); assert_eq!(cert.round, 3); } @@ -248,7 +254,9 @@ mod tests { #[tokio::test] async fn timeout_maps_to_internal_error() { let api = ArcApiImpl::new(TimeoutSource); - let err = ArcApiServer::get_certificate(&api, 9).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 9.into()) + .await + .unwrap_err(); assert_eq!(err.code(), ErrorCode::InternalError.code()); assert!(err.message().contains("Timeout")); } @@ -264,7 +272,9 @@ mod tests { #[tokio::test] async fn decode_error_maps_to_internal_error() { let api = ArcApiImpl::new(DecodeFailSource); - let err = ArcApiServer::get_certificate(&api, 11).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 11.into()) + .await + .unwrap_err(); assert_eq!(err.code(), ErrorCode::InternalError.code()); assert!(err.message().contains("Failed to decode")); } @@ -280,7 +290,9 @@ mod tests { #[tokio::test] async fn network_status_maps_to_internal_error() { let api = ArcApiImpl::new(NetworkFailSource); - let err = ArcApiServer::get_certificate(&api, 13).await.unwrap_err(); + let err = ArcApiServer::get_certificate(&api, 13.into()) + .await + .unwrap_err(); assert_eq!(err.code(), ErrorCode::InternalError.code()); assert!(err.message().contains("Upstream HTTP error")); assert!(err.message().contains("500")); diff --git a/crates/evm-node/src/rpc/openapi/openapi.yaml b/crates/evm-node/src/rpc/openapi/openapi.yaml index 411cc77..8fb35c3 100644 --- a/crates/evm-node/src/rpc/openapi/openapi.yaml +++ b/crates/evm-node/src/rpc/openapi/openapi.yaml @@ -40,17 +40,21 @@ paths: description: | Single JSON-RPC 2.0 POST endpoint. Methods currently implemented under the `arc` namespace: - 1. arc_getCertificate(height: u64) -> RpcCommitCertificate + 1. arc_getCertificate(height: u64 | hex string) -> RpcCommitCertificate Returns the consensus commit certificate for the specified height if available. + The height parameter accepts either a decimal number (7) or a hex quantity string ("0x7"). Error Mapping (error.code -> meaning): -32602 Invalid params (height < 1) -32004 Not found (no certificate upstream) -32005 Upstream unreachable (connect errors) -32603 Internal (timeout / decode / generic upstream HTTP status) - Curl Example: + Curl Examples: curl -s -X POST -H 'Content-Type: application/json' \ --data '{"jsonrpc":"2.0","method":"arc_getCertificate","params":[7],"id":1}' \ http://127.0.0.1:8545 + curl -s -X POST -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"arc_getCertificate","params":["0x7"],"id":1}' \ + http://127.0.0.1:8545 2. arc_getVersion() -> RpcVersionInfo Returns version/build information for the node execution layer. @@ -108,9 +112,13 @@ components: minItems: 1 maxItems: 1 items: - type: integer - minimum: 1 - format: int64 + oneOf: + - type: integer + minimum: 1 + format: int64 + - type: string + pattern: "^0[xX][0-9a-fA-F]+$" + description: Hex-encoded quantity (e.g. "0x7"). Must be >= 1. id: oneOf: - type: integer diff --git a/crates/evm-node/src/rpc_middleware.rs b/crates/evm-node/src/rpc_middleware.rs index 0491e3b..069796a 100644 --- a/crates/evm-node/src/rpc_middleware.rs +++ b/crates/evm-node/src/rpc_middleware.rs @@ -15,13 +15,15 @@ // limitations under the License. use alloy_consensus::TxEnvelope; +use alloy_eips::{BlockId, BlockNumberOrTag}; use alloy_network::eip2718::Decodable2718; use alloy_primitives::Bytes; use jsonrpsee::{ core::middleware::{layer::Either, Batch, BatchEntry, Notification, RpcServiceT}, - types::{ErrorObject, ErrorObjectOwned, Id, Request, ResponsePayload}, + types::{ErrorObject, ErrorObjectOwned, Id, Params, Request, ResponsePayload}, BatchResponseBuilder, MethodResponse, }; +use serde::de::DeserializeOwned; use std::future::Future; use tower::Layer; @@ -29,8 +31,22 @@ const ETH_SUBSCRIBE_METHOD: &str = "eth_subscribe"; const PENDING_TX_SUBSCRIPTION_TYPE: &str = "newPendingTransactions"; const ETH_NEW_PENDING_TX_FILTER_METHOD: &str = "eth_newPendingTransactionFilter"; const ETH_GET_BLOCK_BY_NUMBER_METHOD: &str = "eth_getBlockByNumber"; -const PENDING_BLOCK_TAG: &str = "pending"; +const BLOCK_NUMBER_OBJECT_KEY: &str = "number"; +const BLOCK_ID_OBJECT_KEY_SNAKE: &str = "block_id"; +const BLOCK_ID_OBJECT_KEY_CAMEL: &str = "blockId"; const ETH_GET_TX_BY_SENDER_AND_NONCE_METHOD: &str = "eth_getTransactionBySenderAndNonce"; +const ETH_GET_BLOCK_RECEIPTS_METHOD: &str = "eth_getBlockReceipts"; +const ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD: &str = "eth_getBlockTransactionCountByNumber"; +const ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD: &str = "eth_getTransactionByBlockNumberAndIndex"; +const ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD: &str = + "eth_getRawTransactionByBlockNumberAndIndex"; +const ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD: &str = "eth_getUncleCountByBlockNumber"; +const ETH_GET_HEADER_BY_NUMBER_METHOD: &str = "eth_getHeaderByNumber"; +// jsonrpsee proc-macro field name for eth_getHeaderByNumber's BlockNumberOrTag param. +// Reth's trait declares `hash: BlockNumberOrTag` (copy-paste from getHeaderByHash) — +// see reth-rpc-eth-api/src/core.rs. If that arg is ever renamed, update this key and +// the object-form test cases; the array/positional form is unaffected. +const BLOCK_HEADER_NUMBER_OBJECT_KEY: &str = "hash"; const ETH_SEND_RAW_TRANSACTION_METHOD: &str = "eth_sendRawTransaction"; const ETH_SEND_RAW_TRANSACTION_SYNC_METHOD: &str = "eth_sendRawTransactionSync"; const PENDING_TX_SUBSCRIPTION_ERROR_CODE: i32 = -32001; @@ -45,9 +61,13 @@ pub const ARC_RPC_MAX_BATCH_ENTRIES_DEFAULT: usize = 100; /// Config for the Arc-specific RPC middleware stack. #[derive(Clone, Debug)] pub struct ArcRpcLayer { - /// When true (default), `eth_subscribe("newPendingTransactions")`, - /// `eth_newPendingTransactionFilter`, `eth_getBlockByNumber("pending")`, - /// and `eth_getTransactionBySenderAndNonce` are blocked. + /// When true (default), blocks all RPC methods that can expose pending-block + /// state: `eth_subscribe("newPendingTransactions")`, `eth_newPendingTransactionFilter`, + /// `eth_getTransactionBySenderAndNonce`, and the block-content methods + /// `eth_getBlockByNumber`, `eth_getBlockReceipts`, + /// `eth_getBlockTransactionCountByNumber`, `eth_getTransactionByBlockNumberAndIndex`, + /// `eth_getRawTransactionByBlockNumberAndIndex`, `eth_getUncleCountByBlockNumber`, + /// and `eth_getHeaderByNumber` when called with the `"pending"` tag. /// When false, the filter is bypassed and these are allowed. /// CLI users opt out of the default via `--arc.expose-pending-txs`. pub filter_pending_txs: bool, @@ -449,19 +469,59 @@ fn is_pool_pending_tx_lookup(req: &Request<'_>) -> bool { req.method_name() == ETH_GET_TX_BY_SENDER_AND_NONCE_METHOD } -/// Returns true if the request is `eth_getBlockByNumber("pending", ...)`. +fn is_pending_block_method(method: &str) -> bool { + method == ETH_GET_BLOCK_BY_NUMBER_METHOD + || method == ETH_GET_BLOCK_RECEIPTS_METHOD + || method == ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD + || method == ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD + || method == ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD + || method == ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD + || method == ETH_GET_HEADER_BY_NUMBER_METHOD +} + +/// Returns true if the request queries pending-block state via a block number/tag parameter. /// /// The consensus engine may briefly expose a pending block via `provider().pending_block()` /// even when `--rpc.pending-block=none` is set. Intercepting at the middleware layer /// guarantees a consistent `null` response regardless of consensus-engine state. fn is_pending_block_query(req: &Request<'_>) -> bool { - if req.method_name() != ETH_GET_BLOCK_BY_NUMBER_METHOD { + if !is_pending_block_method(req.method_name()) { return false; } - if let Ok(Some(block_tag)) = req.params().sequence().optional_next::() { - return block_tag == PENDING_BLOCK_TAG; + let params = req.params(); + // eth_getBlockReceipts accepts BlockId: handles string tags and EIP-1898 object form + // ({"blockNumber": "pending"}). All other methods accept BlockNumberOrTag. + if req.method_name() == ETH_GET_BLOCK_RECEIPTS_METHOD { + return extract_param::( + params, + &[BLOCK_ID_OBJECT_KEY_SNAKE, BLOCK_ID_OBJECT_KEY_CAMEL], + ) + .is_some_and(|id| id.is_pending()); + } + // Object key for named params — coupled to jsonrpsee proc-macro field names. + let key = if req.method_name() == ETH_GET_HEADER_BY_NUMBER_METHOD { + BLOCK_HEADER_NUMBER_OBJECT_KEY + } else { + BLOCK_NUMBER_OBJECT_KEY + }; + extract_param::(params, &[key]).is_some_and(|t| t.is_pending()) +} + +/// Extracts and deserializes the first positional or named RPC param. +/// +/// For object-style params, tries each key in `keys` in order. For array-style +/// params, reads the first element. Returns `None` on missing field or parse failure. +/// +/// `BlockNumberOrTag` deserialization lowercases tags internally, so case variants +/// ("Pending", "PENDING") deserialize correctly without extra normalization. +fn extract_param(params: Params<'_>, keys: &[&str]) -> Option { + if params.is_object() { + let obj = params.parse::().ok()?; + let val = keys.iter().find_map(|k| obj.get(*k))?.clone(); + serde_json::from_value(val).ok() + } else { + params.sequence().optional_next::().ok().flatten() } - false } /// Builds a JSON-RPC success response containing `null`. @@ -859,6 +919,32 @@ mod tests { ); } + #[tokio::test] + async fn test_enabled_batch_new_method_pending_returns_null() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let batch = Batch::from(vec![ + Ok(BatchEntry::Call(create_request_with_params( + "eth_blockNumber", + RawValue::from_string("[]".to_string()).unwrap(), + 1, + ))), + Ok(BatchEntry::Call(create_request_with_params( + ETH_GET_BLOCK_RECEIPTS_METHOD, + RawValue::from_string(r#"["pending"]"#.to_string()).unwrap(), + 2, + ))), + ]); + let response = middleware.batch(batch).await; + let json = response.into_json(); + let responses: Vec = serde_json::from_str(json.get()).unwrap(); + assert_eq!(responses.len(), 2); + assert_eq!(responses[0]["result"], "success"); + assert!( + responses[1]["result"].is_null(), + "batch new-method pending should return null" + ); + } + #[tokio::test] async fn test_enabled_batch_pool_lookup_returns_null() { let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); @@ -1070,6 +1156,36 @@ mod tests { ); } + #[tokio::test] + async fn test_disabled_allows_new_pending_block_methods() { + let layer = ArcRpcLayer { + filter_pending_txs: false, + ..Default::default() + }; + let middleware = layer.layer(MockRpcService); + let methods = [ + ETH_GET_BLOCK_RECEIPTS_METHOD, + ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, + ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, + ]; + for method in methods { + let params = RawValue::from_string(r#"["pending"]"#.to_string()).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "filter_pending_txs=false should allow {method}" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert_eq!( + json["result"], "success", + "filter_pending_txs=false should forward {method}" + ); + } + } + // ── ArcRpcLayer::default() ────────────────────────────────────────── #[test] @@ -1476,4 +1592,227 @@ mod tests { let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); assert_eq!(json["result"], "success"); } + + // ── Case-insensitive "pending" tag + additional block-content methods ── + // + // All six block-content methods accept a block number/tag as first param. + // When that tag is "pending" (any case) the middleware returns null. + + #[tokio::test] + async fn test_pending_block_methods_all_tags_return_null() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases = [ + (ETH_GET_BLOCK_BY_NUMBER_METHOD, "pending"), + (ETH_GET_BLOCK_BY_NUMBER_METHOD, "Pending"), + (ETH_GET_BLOCK_BY_NUMBER_METHOD, "PENDING"), + (ETH_GET_BLOCK_RECEIPTS_METHOD, "pending"), + (ETH_GET_BLOCK_RECEIPTS_METHOD, "Pending"), + (ETH_GET_BLOCK_RECEIPTS_METHOD, "PENDING"), + (ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, "pending"), + (ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, "Pending"), + (ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, "PENDING"), + (ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "pending"), + (ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "Pending"), + (ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "PENDING"), + (ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "pending"), + (ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "Pending"), + (ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, "PENDING"), + (ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, "pending"), + (ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, "Pending"), + (ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, "PENDING"), + (ETH_GET_HEADER_BY_NUMBER_METHOD, "pending"), + (ETH_GET_HEADER_BY_NUMBER_METHOD, "Pending"), + (ETH_GET_HEADER_BY_NUMBER_METHOD, "PENDING"), + ]; + for (method, tag) in cases { + let params = RawValue::from_string(format!("[\"{tag}\"]")).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "{method} with \"{tag}\" should not error" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert!( + json["result"].is_null(), + "{method} with \"{tag}\" should return null" + ); + } + } + + #[tokio::test] + async fn test_additional_pending_block_methods_non_pending_tag_passes_through() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[(&str, &str)] = &[ + (ETH_GET_BLOCK_RECEIPTS_METHOD, r#"["latest"]"#), + (ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, r#"["latest"]"#), + ( + ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + r#"["0x1", "0x0"]"#, + ), + ( + ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + r#"["0x1", "0x0"]"#, + ), + (ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, r#"["latest"]"#), + (ETH_GET_HEADER_BY_NUMBER_METHOD, r#"["latest"]"#), + ]; + for (method, params_json) in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + + assert!( + response.as_error_code().is_none(), + "{method} with non-pending tag should pass through" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert_eq!( + json["result"], "success", + "{method} with non-pending tag should return inner service response" + ); + } + } + + // ── Object-style params bypass ────────────────────────────────────── + // + // jsonrpsee handlers accept both array and object params. The middleware + // must intercept object-style pending-block queries too. + + #[tokio::test] + async fn test_object_params_pending_block_returns_null() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[(&str, &str)] = &[ + // Five methods use "number" key + ( + ETH_GET_BLOCK_BY_NUMBER_METHOD, + r#"{"number": "pending", "full": false}"#, + ), + ( + ETH_GET_BLOCK_TX_COUNT_BY_NUMBER_METHOD, + r#"{"number": "pending"}"#, + ), + ( + ETH_GET_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + r#"{"number": "pending", "index": "0x0"}"#, + ), + ( + ETH_GET_RAW_TX_BY_BLOCK_NUMBER_AND_INDEX_METHOD, + r#"{"number": "pending", "index": "0x0"}"#, + ), + ( + ETH_GET_UNCLE_COUNT_BY_BLOCK_NUMBER_METHOD, + r#"{"number": "pending"}"#, + ), + // eth_getBlockReceipts uses "block_id" (snake) or "blockId" (camel) + (ETH_GET_BLOCK_RECEIPTS_METHOD, r#"{"block_id": "pending"}"#), + (ETH_GET_BLOCK_RECEIPTS_METHOD, r#"{"blockId": "pending"}"#), + // eth_getHeaderByNumber uses "hash" (Reth's proc-macro arg name) + (ETH_GET_HEADER_BY_NUMBER_METHOD, r#"{"hash": "pending"}"#), + // Case-insensitive variant via object params — guards that BlockNumberOrTag deserialization lowercases tags + ( + ETH_GET_BLOCK_BY_NUMBER_METHOD, + r#"{"number": "PENDING", "full": false}"#, + ), + ]; + for (method, params_json) in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "{method} with object params should not error" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert!( + json["result"].is_null(), + "{method} with object params {{...\"pending\"...}} should return null" + ); + } + } + + #[tokio::test] + async fn test_object_params_non_pending_passes_through() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[(&str, &str)] = &[ + ( + ETH_GET_BLOCK_BY_NUMBER_METHOD, + r#"{"number": "0x1", "full": false}"#, + ), + (ETH_GET_BLOCK_RECEIPTS_METHOD, r#"{"block_id": "latest"}"#), + (ETH_GET_HEADER_BY_NUMBER_METHOD, r#"{"hash": "latest"}"#), + // Unknown field → passes through (Reth returns parse error, not middleware) + (ETH_GET_BLOCK_BY_NUMBER_METHOD, r#"{"garbage": "pending"}"#), + ]; + for (method, params_json) in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(method, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "{method} with non-pending object params should pass through" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert_eq!( + json["result"], "success", + "{method} with non-pending object params should reach inner service" + ); + } + } + + // ── EIP-1898 BlockId object form ──────────────────────────────────── + // + // eth_getBlockReceipts accepts BlockId, which allows an EIP-1898 object + // form in addition to the plain string form. {"blockNumber": "pending"} + // deserializes to BlockId::Number(Pending) and must also be intercepted. + + #[tokio::test] + async fn test_eip1898_block_receipts_pending_returns_null() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[&str] = &[ + // Array-style: element is an EIP-1898 object + r#"[{"blockNumber": "pending"}]"#, + // Named params (snake_case key) with EIP-1898 value + r#"{"block_id": {"blockNumber": "pending"}}"#, + // Named params (camelCase key) with EIP-1898 value + r#"{"blockId": {"blockNumber": "pending"}}"#, + ]; + for params_json in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(ETH_GET_BLOCK_RECEIPTS_METHOD, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "eth_getBlockReceipts EIP-1898 pending should not error" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert!( + json["result"].is_null(), + "eth_getBlockReceipts with EIP-1898 pending ({params_json}) should return null" + ); + } + } + + #[tokio::test] + async fn test_eip1898_block_receipts_non_pending_passes_through() { + let middleware = NoPendingTransactionsRpcMiddleware::new(MockRpcService); + let cases: &[&str] = &[ + r#"[{"blockNumber": "0x1"}]"#, + r#"{"block_id": {"blockNumber": "0x1"}}"#, + ]; + for params_json in cases { + let params = RawValue::from_string(params_json.to_string()).unwrap(); + let request = create_request_with_params(ETH_GET_BLOCK_RECEIPTS_METHOD, params, 1); + let response = middleware.call(request).await; + assert!( + response.as_error_code().is_none(), + "eth_getBlockReceipts EIP-1898 non-pending should pass through" + ); + let json: serde_json::Value = serde_json::from_str(response.into_json().get()).unwrap(); + assert_eq!( + json["result"], "success", + "eth_getBlockReceipts with EIP-1898 non-pending ({params_json}) should reach inner service" + ); + } + } } diff --git a/crates/evm/src/subcall.rs b/crates/evm/src/subcall.rs index ccea548..fbf7ea3 100644 --- a/crates/evm/src/subcall.rs +++ b/crates/evm/src/subcall.rs @@ -131,3 +131,27 @@ impl std::fmt::Debug for SubcallRegistry { .finish() } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::address; + + #[test] + fn allowed_callers_unrestricted_allows_any_address() { + let callers = AllowedCallers::Unrestricted; + assert!(callers.is_allowed(&address!("0000000000000000000000000000000000000001"))); + assert!(callers.is_allowed(&Address::ZERO)); + } + + #[test] + fn allowed_callers_only_allows_listed_rejects_others() { + let allowed = address!("1800000000000000000000000000000000000005"); + let callers = AllowedCallers::Only(HashSet::from([allowed])); + assert!(callers.is_allowed(&allowed)); + assert!(!callers.is_allowed(&address!("dead00000000000000000000000000000000beef"))); + // Empty allowlist rejects every caller. + let empty = AllowedCallers::Only(HashSet::new()); + assert!(!empty.is_allowed(&allowed)); + } +} diff --git a/crates/execution-config/src/chainspec.rs b/crates/execution-config/src/chainspec.rs index ba1c6d9..2f236cc 100644 --- a/crates/execution-config/src/chainspec.rs +++ b/crates/execution-config/src/chainspec.rs @@ -397,6 +397,9 @@ pub fn localdev_with_hardforks(hardforks: &[(ArcHardfork, ForkCondition)]) -> Ar ArcHardfork::Zero7 => inner .hardforks .insert(ArcHardfork::Zero7.boxed(), condition), + ArcHardfork::Zero8 => inner + .hardforks + .insert(ArcHardfork::Zero8.boxed(), condition), }; } @@ -951,7 +954,7 @@ mod tests { .expect("Failed to parse arc-localdev"); assert_eq!(spec.chain().id(), LOCALDEV_CHAIN_ID); assert_arc_chainspec_evm_hardforks(&spec); - assert_eq!(spec.forks_iter().count(), 23); + assert_eq!(spec.forks_iter().count(), 24); assert!(spec.is_osaka_active_at_timestamp(0)); // verify zero3 hardfork block @@ -980,12 +983,18 @@ mod tests { spec.is_fork_active_at_timestamp(ArcHardfork::Zero7, 0), "Zero7 should be active at timestamp 0 in hardfork.rs, and load by chainspec" ); + // Zero8 activates by timestamp (Arc convention from Zero7 onward). + assert!( + spec.is_fork_active_at_timestamp(ArcHardfork::Zero8, 0), + "Zero8 should be active at timestamp 0 in hardfork.rs, and load by chainspec" + ); let flags = spec.get_hardfork_flags(0, 0); assert!(flags.is_active(ArcHardfork::Zero3)); assert!(flags.is_active(ArcHardfork::Zero4)); assert!(flags.is_active(ArcHardfork::Zero5)); assert!(flags.is_active(ArcHardfork::Zero6)); assert!(flags.is_active(ArcHardfork::Zero7)); + assert!(flags.is_active(ArcHardfork::Zero8)); } #[test] @@ -994,7 +1003,7 @@ mod tests { assert_eq!(spec.chain().id(), LOCALDEV_CHAIN_ID); assert_arc_chainspec_evm_hardforks(&spec); assert!(spec.is_osaka_active_at_timestamp(0)); - assert_eq!(spec.forks_iter().count(), 23); + assert_eq!(spec.forks_iter().count(), 24); // verify zero3 hardfork block assert!(!spec.is_fork_active_at_timestamp(ArcHardfork::Zero3, 1762732800)); @@ -1011,12 +1020,16 @@ mod tests { // Zero7 activates by timestamp (Arc convention from Zero7 onward). assert!(spec.is_fork_active_at_timestamp(ArcHardfork::Zero7, 0)); assert!(!spec.is_fork_active_at_block(ArcHardfork::Zero7, 0)); + // Zero8 activates by timestamp (Arc convention from Zero7 onward). + assert!(spec.is_fork_active_at_timestamp(ArcHardfork::Zero8, 0)); + assert!(!spec.is_fork_active_at_block(ArcHardfork::Zero8, 0)); let flags = spec.get_hardfork_flags(0, 0); assert!(flags.is_active(ArcHardfork::Zero3)); assert!(flags.is_active(ArcHardfork::Zero4)); assert!(flags.is_active(ArcHardfork::Zero5)); assert!(flags.is_active(ArcHardfork::Zero6)); assert!(flags.is_active(ArcHardfork::Zero7)); + assert!(flags.is_active(ArcHardfork::Zero8)); assert_eq!( spec.display_hardforks().to_string(), r#"Pre-merge hard forks (block based): @@ -1044,7 +1057,8 @@ Post-merge hard forks (timestamp based): - Cancun @0 blob: (target: 6, max: 9, fraction: 5007716) - Prague @0 blob: (target: 6, max: 9, fraction: 5007716) - Osaka @0 blob: (target: 6, max: 9, fraction: 5007716) -- Zero7 @0 blob: (target: 6, max: 9, fraction: 5007716)"# +- Zero7 @0 blob: (target: 6, max: 9, fraction: 5007716) +- Zero8 @0 blob: (target: 6, max: 9, fraction: 5007716)"# ); } @@ -1167,6 +1181,7 @@ Post-merge hard forks (timestamp based): (ArcHardfork::Zero5, true), (ArcHardfork::Zero6, true), (ArcHardfork::Zero7, false), // deferred — not active at launch + (ArcHardfork::Zero8, false), // deferred — not active at launch ]; let paths: [(&str, &ArcChainSpec); 3] = [ ("parser", &from_parser), diff --git a/crates/execution-config/src/hardforks.rs b/crates/execution-config/src/hardforks.rs index 71fbd08..81077a9 100644 --- a/crates/execution-config/src/hardforks.rs +++ b/crates/execution-config/src/hardforks.rs @@ -33,6 +33,7 @@ hardfork!( #[default] Zero6, // v0.6 hardfork Zero7, // v0.7 hardfork — batch (Multicall3From) and memo contracts + Zero8, // v0.8 hardfork — delegatecall rejections charge the uniform early-revert penalty } ); @@ -54,6 +55,8 @@ pub struct ArcGenesisInfo { /// Built-in network schedules are defined in `ARC_*_HARDFORKS` and may activate /// earlier Arc hardforks by timestamp, e.g. testnet Zero5/Zero6. pub zero_7_time: Option, + /// v0.8 hardfork timestamp for genesis-configured chains. + pub zero_8_time: Option, } impl ArcGenesisInfo { @@ -78,6 +81,9 @@ impl ArcGenesisInfo { if let Some(time) = self.zero_7_time { hardforks.push((ArcHardfork::Zero7, ForkCondition::Timestamp(time))); } + if let Some(time) = self.zero_8_time { + hardforks.push((ArcHardfork::Zero8, ForkCondition::Timestamp(time))); + } hardforks } } @@ -101,6 +107,7 @@ pub struct ArcHardforkFlags { zero5: bool, zero6: bool, zero7: bool, + zero8: bool, } impl ArcHardforkFlags { @@ -111,6 +118,7 @@ impl ArcHardforkFlags { ArcHardfork::Zero5, ArcHardfork::Zero6, ArcHardfork::Zero7, + ArcHardfork::Zero8, ]; /// Check if a specific hardfork is active. @@ -121,6 +129,7 @@ impl ArcHardforkFlags { ArcHardfork::Zero5 => self.zero5, ArcHardfork::Zero6 => self.zero6, ArcHardfork::Zero7 => self.zero7, + ArcHardfork::Zero8 => self.zero8, } } @@ -132,6 +141,7 @@ impl ArcHardforkFlags { ArcHardfork::Zero5 => self.zero5 = value, ArcHardfork::Zero6 => self.zero6 = value, ArcHardfork::Zero7 => self.zero7 = value, + ArcHardfork::Zero8 => self.zero8 = value, } } @@ -278,6 +288,7 @@ pub static ARC_LOCALDEV_HARDFORKS: LazyLock = LazyLock::new(|| { // mixed-version peers (block-based forks declared after a timestamp fork break // ForkFilter's BTreeMap ordering). forks.insert(ArcHardfork::Zero7.boxed(), ForkCondition::Timestamp(0)); + forks.insert(ArcHardfork::Zero8.boxed(), ForkCondition::Timestamp(0)); forks }); @@ -389,6 +400,7 @@ mod tests { assert_eq!(ArcHardfork::Zero5.name(), "Zero5"); assert_eq!(ArcHardfork::Zero6.name(), "Zero6"); assert_eq!(ArcHardfork::Zero7.name(), "Zero7"); + assert_eq!(ArcHardfork::Zero8.name(), "Zero8"); } #[test] @@ -400,6 +412,7 @@ mod tests { assert!(!flags.is_active(ArcHardfork::Zero5)); assert!(!flags.is_active(ArcHardfork::Zero6)); assert!(!flags.is_active(ArcHardfork::Zero7)); + assert!(!flags.is_active(ArcHardfork::Zero8)); // Test from chain hardforks - localdev has all Arc hardforks active at genesis let flags = ArcHardforkFlags::from_chain_hardforks(&ARC_LOCALDEV_HARDFORKS, 0, 0); @@ -408,6 +421,7 @@ mod tests { assert!(flags.is_active(ArcHardfork::Zero5)); assert!(flags.is_active(ArcHardfork::Zero6)); assert!(flags.is_active(ArcHardfork::Zero7)); + assert!(flags.is_active(ArcHardfork::Zero8)); // Test from chain hardforks - devnet has Zero3 and Zero4 active after their activation blocks let flags = ArcHardforkFlags::from_chain_hardforks( @@ -420,6 +434,7 @@ mod tests { assert!(!flags.is_active(ArcHardfork::Zero5)); assert!(!flags.is_active(ArcHardfork::Zero6)); assert!(!flags.is_active(ArcHardfork::Zero7)); + assert!(!flags.is_active(ArcHardfork::Zero8)); // Test from chain hardforks - devnet before Zero4 activation let flags = ArcHardforkFlags::from_chain_hardforks( @@ -456,9 +471,9 @@ mod tests { assert!(!flags.is_active(ArcHardfork::Zero3)); assert!(!flags.is_active(ArcHardfork::Zero4)); - // Test all_combinations() helper - should yield 32 combinations (2^5) + // Test all_combinations() helper - should yield 64 combinations (2^6) let combinations: Vec<_> = ArcHardforkFlags::all_combinations().collect(); - assert_eq!(combinations.len(), 32); + assert_eq!(combinations.len(), 64); // Verify some key combinations are present assert!(combinations.contains(&ArcHardforkFlags::with(&[]))); @@ -474,12 +489,13 @@ mod tests { ArcHardfork::Zero5, ArcHardfork::Zero6, ArcHardfork::Zero7, + ArcHardfork::Zero8, ]))); } #[test] fn test_parse_arc_hardfork_from_genesis() { - let s = r#"{ "config": { "zero3Block": 123123, "zero4Block": 223881, "zero5Block": 323496, "zero6Block": 423000, "zero7Time": 1800000000 } }"#; + let s = r#"{ "config": { "zero3Block": 123123, "zero4Block": 223881, "zero5Block": 323496, "zero6Block": 423000, "zero7Time": 1800000000, "zero8Time": 1900000000 } }"#; let genesis = serde_json::from_str::(s).expect("Failed to parse genesis"); let info = ArcGenesisInfo::extract_from(&genesis.config.extra_fields) @@ -489,6 +505,7 @@ mod tests { assert_eq!(info.zero_5_block, Some(323496)); assert_eq!(info.zero_6_block, Some(423000)); assert_eq!(info.zero_7_time, Some(1800000000)); + assert_eq!(info.zero_8_time, Some(1900000000)); } // Verify ethereum hardforks are supported for all networks. @@ -531,7 +548,7 @@ mod tests { fn test_arc_localdev_forks() { let forks = ARC_LOCALDEV_HARDFORKS.clone(); assert_base_hardforks(&forks); - assert_eq!(forks.len(), 23); + assert_eq!(forks.len(), 24); // verify hardfork zero3 block assert!(!forks.is_fork_active_at_timestamp(ArcHardfork::Zero3, 0)); @@ -552,6 +569,10 @@ mod tests { // Zero7 activates by timestamp (Arc convention from Zero7 onward). assert!(forks.is_fork_active_at_timestamp(ArcHardfork::Zero7, 0)); assert!(!forks.is_fork_active_at_block(ArcHardfork::Zero7, 0)); + + // Zero8 activates by timestamp (Arc convention from Zero7 onward). + assert!(forks.is_fork_active_at_timestamp(ArcHardfork::Zero8, 0)); + assert!(!forks.is_fork_active_at_block(ArcHardfork::Zero8, 0)); } #[test] diff --git a/crates/execution-e2e/tests/hardfork_transition.rs b/crates/execution-e2e/tests/hardfork_transition.rs index 576dbd4..5851642 100644 --- a/crates/execution-e2e/tests/hardfork_transition.rs +++ b/crates/execution-e2e/tests/hardfork_transition.rs @@ -17,7 +17,7 @@ //! Hardfork transition e2e tests for Arc Chain. //! //! These tests verify that block production works correctly across -//! hardfork boundaries for Zero4, Zero5, Zero6, and Zero7 hardforks. +//! hardfork boundaries for Zero4, Zero5, Zero6, Zero7, and Zero8 hardforks. use arc_execution_config::hardforks::ArcHardfork; use arc_execution_e2e::{ @@ -39,6 +39,7 @@ async fn test_hardfork_active_at_genesis() -> Result<()> { .with_action(AssertHardfork::is_active(ArcHardfork::Zero5)) .with_action(AssertHardfork::is_active(ArcHardfork::Zero6)) .with_action(AssertHardfork::is_active(ArcHardfork::Zero7)) + .with_action(AssertHardfork::is_active(ArcHardfork::Zero8)) .run() .await } @@ -54,6 +55,7 @@ async fn test_sequential_hardfork_transitions() -> Result<()> { (ArcHardfork::Zero5, ForkCondition::Block(6)), (ArcHardfork::Zero6, ForkCondition::Block(8)), (ArcHardfork::Zero7, ForkCondition::Block(10)), + (ArcHardfork::Zero8, ForkCondition::Block(12)), ]); ArcTestBuilder::new() @@ -64,6 +66,7 @@ async fn test_sequential_hardfork_transitions() -> Result<()> { .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero5)) .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero6)) .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero7)) + .with_action(AssertHardfork::is_not_active(ArcHardfork::Zero8)) // Produce block 1-2 - Zero3 activates .with_action(ProduceBlocks::new(2)) .with_action(AssertBlockNumber::new(2)) @@ -84,6 +87,10 @@ async fn test_sequential_hardfork_transitions() -> Result<()> { .with_action(ProduceBlocks::new(2)) .with_action(AssertBlockNumber::new(10)) .with_action(AssertHardfork::is_active(ArcHardfork::Zero7)) + // Produce block 11-12 - Zero8 activates + .with_action(ProduceBlocks::new(2)) + .with_action(AssertBlockNumber::new(12)) + .with_action(AssertHardfork::is_active(ArcHardfork::Zero8)) .run() .await } diff --git a/crates/execution-validation/src/consensus.rs b/crates/execution-validation/src/consensus.rs index 9bafc95..3117058 100644 --- a/crates/execution-validation/src/consensus.rs +++ b/crates/execution-validation/src/consensus.rs @@ -1095,4 +1095,61 @@ mod tests { "Before Zero6, zero beneficiary should be allowed: {result:?}" ); } + + // Drives the public `HeaderValidator::validate_header` path so that + // accidentally removing the beneficiary check from `validate_header()` + // would be caught at the trait boundary, not just at the helper. + #[test] + fn validate_header_rejects_zero_beneficiary_at_zero6() { + use alloy_primitives::Address; + + // Activate Zero6 at genesis but defer Zero5 so the Zero5-gated + // extra_data/base_fee/gas_limit checks don't fail before reaching + // the beneficiary check. + let mut inner = ChainSpecBuilder::mainnet().build(); + inner + .hardforks + .insert(ArcHardfork::Zero5, ForkCondition::Block(100)); + inner + .hardforks + .insert(ArcHardfork::Zero6, ForkCondition::Block(0)); + let consensus = ArcConsensus::new(Arc::new(ArcChainSpec::new(inner))); + + let header = Header { + number: 1, + beneficiary: Address::ZERO, + ..Default::default() + }; + let sealed = SealedHeader::new(header.clone(), header.hash_slow()); + let result = + as HeaderValidator
>::validate_header(&consensus, &sealed); + assert!( + matches!(result, Err(ConsensusError::Other(_))), + "validate_header should reject zero beneficiary under Zero6: {result:?}" + ); + } + + #[test] + fn validate_header_accepts_zero_beneficiary_before_zero6() { + use alloy_primitives::Address; + + let mut inner = ChainSpecBuilder::mainnet().build(); + inner + .hardforks + .insert(ArcHardfork::Zero6, ForkCondition::Block(100)); + let consensus = ArcConsensus::new(Arc::new(ArcChainSpec::new(inner))); + + let header = Header { + number: 99, + beneficiary: Address::ZERO, + ..Default::default() + }; + let sealed = SealedHeader::new(header.clone(), header.hash_slow()); + let result = + as HeaderValidator
>::validate_header(&consensus, &sealed); + assert!( + result.is_ok(), + "validate_header should accept zero beneficiary before Zero6: {result:?}" + ); + } } diff --git a/crates/malachite-app/METRICS.md b/crates/malachite-app/METRICS.md index b73e0d4..1e380b7 100644 --- a/crates/malachite-app/METRICS.md +++ b/crates/malachite-app/METRICS.md @@ -71,9 +71,41 @@ This application exposes Prometheus metrics on the `/metrics` endpoint. The foll - `api`: The Engine API method name. - **Buckets:** Provides a histogram of API call times with exponential buckets from 0.001 to 2.0 seconds. +- **`arc_malachite_app_rpc_request_time`** (Histogram) + - **Description:** Time taken to serve RPC requests, in seconds. + - **Labels:** + - `endpoint`: The RPC endpoint path. + - **Buckets:** Provides a histogram of RPC request times with exponential buckets from 0.001 to 2.0 seconds. + + The `app_request_*` metric names below intentionally render with `app_app` in + Prometheus because the `arc_malachite_app` registry prefix is combined with + the registered `app_request_*` metric names. + +- **`arc_malachite_app_app_request_full_count`** (Counter) + - **Description:** Number of app requests rejected because the app request channel was full. + - **Labels:** + - `request`: The app request type. + +- **`arc_malachite_app_app_request_queue_time`** (Histogram) + - **Description:** Time app requests spend waiting in the app request channel, in seconds. + - **Labels:** + - `request`: The app request type. + - **Buckets:** Provides a histogram of app request queue times with exponential buckets from 0.001 to 2.0 seconds. + +- **`arc_malachite_app_app_request_process_time`** (Histogram) + - **Description:** Time taken to process app requests, in seconds. + - **Labels:** + - `request`: The app request type. + - **Buckets:** Provides a histogram of app request processing times with exponential buckets from 0.001 to 2.0 seconds. + - **`arc_malachite_app_height_restart_count`** (Counter) - **Description:** Number of times the consensus height has been restarted due to errors or recovery scenarios. +- **`arc_malachite_app_consensus_round_missed`** (Counter) + - **Description:** Number of consensus rounds that failed to decide before advancing to the next round. + - **Labels:** + - `proposer`: Address of the validator that was proposer for the missed round. + - **`arc_malachite_app_sync_fell_behind_count`** (Counter) - **Description:** Number of times the node fell behind and transitioned from InSync to CatchingUp. diff --git a/crates/malachite-app/src/app.rs b/crates/malachite-app/src/app.rs index a8193af..59802a0 100644 --- a/crates/malachite-app/src/app.rs +++ b/crates/malachite-app/src/app.rs @@ -16,19 +16,24 @@ use eyre::{eyre, Context as _}; use tokio::sync::mpsc::Receiver; +use tokio::sync::watch; +use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; use malachitebft_app_channel::{AppMsg, Channels}; use malachitebft_core_types::utils::height::DisplayRange; -use malachitebft_core_types::Context; +use arc_consensus_types::proposer::{ProposerSelector, RoundRobin}; use arc_consensus_types::{ArcContext, StoredCommitCertificate}; use arc_eth_engine::engine::Engine; use crate::handlers::*; +use crate::metrics::AppMetrics; use crate::request::{AppRequest, CommitCertificateInfo}; -use crate::state::State; +use crate::state::{State, StatusSnapshot}; +use crate::stats::Stats; +use crate::store::Store; pub async fn run( mut state: State, @@ -41,11 +46,36 @@ pub async fn run( warn!("Consensus configured to halt at block height: {halt_height}"); } + let (status_tx, status_rx) = watch::channel(state.status_snapshot()); + + let ctx = AppRequestContext { + store: state.store().clone(), + engine: engine.clone(), + stats: state.stats().clone(), + metrics: state.metrics().clone(), + proposer_selector: state.ctx.proposer_selector, + status_rx, + }; + + let mut app_req_task: JoinHandle> = + tokio::spawn(process_app_requests(rx_app_req, ctx)); + let result = cancel_token - .run_until_cancelled_owned(go(&mut state, channels, &engine, rx_app_req)) + .run_until_cancelled_owned(go( + &mut state, + channels, + &engine, + status_tx, + &mut app_req_task, + )) .await; + // Always abort the app-request task, including on graceful cancellation + // where `go` is dropped at an await point and would otherwise detach it. + app_req_task.abort(); + let result = match result { + Some(Ok(never)) => match never {}, Some(Err(e)) => { error!("🔴 Error in application: {e:#}"); error!("🔴 Shutting down"); @@ -68,10 +98,42 @@ pub async fn run( /// Used to indicate that the function never returns normally. enum Never {} +/// Context shared with the spawned app-request processing task. +/// +/// All fields are cheap to clone (`Arc`-based or `Copy`). +struct AppRequestContext { + store: Store, + engine: Engine, + stats: Stats, + metrics: AppMetrics, + proposer_selector: RoundRobin, + status_rx: watch::Receiver, +} + +/// Runs in a dedicated tokio task, processing app requests concurrently +/// with the consensus event loop. +async fn process_app_requests( + mut rx: Receiver, + ctx: AppRequestContext, +) -> eyre::Result { + loop { + match rx.recv().await { + Some(req) => { + if let Err(e) = handle_app_request(req, &ctx).await { + error!("🔴 Error handling application request: {e:#}"); + } + } + None => { + return Err(eyre!("Application request channel closed unexpectedly")); + } + } + } +} + /// The main event loop of the application. /// -/// It listens for messages from consensus and application requests, -/// and dispatches them to the appropriate handlers. +/// It listens for messages from consensus and monitors the app-request task. +/// App requests are processed in a separate task so they don't block consensus. /// /// # Errors /// Returns an error if handling a message fails or one of the channels is closed unexpectedly. @@ -81,7 +143,8 @@ async fn go( state: &mut State, mut channels: Channels, engine: &Engine, - mut rx_app_req: Receiver, + status_tx: watch::Sender, + app_req_task: &mut JoinHandle>, ) -> eyre::Result { loop { tokio::select! { @@ -92,25 +155,33 @@ async fn go( // Abort on error to shut down the application. handle_consensus(msg, state, &mut channels, engine).await .wrap_err("Error handling consensus message")?; + + // Skip the publish when nothing changed: most consensus messages + // (sync queries, restream requests, vote-extension hooks) leave the + // snapshot untouched, so there is no point updating the watch. + status_tx.send_if_modified(|current| { + let new = state.status_snapshot(); + if *current != new { + *current = new; + true + } else { + false + } + }); }, None => { return Err(eyre!("Consensus channel closed unexpectedly")); } }, - req = rx_app_req.recv() => match req { - Some(req) => { - if let Err(e) = handle_app_request(req, state, engine).await { - error!("🔴 Error handling application request: {e:#}"); - - // We continue processing other requests even if one fails. - continue; - } - }, - None => { - return Err(eyre!("Application request channel closed unexpectedly")); + result = &mut *app_req_task => { + // The app-request task should run forever; if it exits, propagate the error. + match result { + Ok(Ok(never)) => match never {}, + Ok(Err(e)) => return Err(e.wrap_err("App request task failed")), + Err(e) => return Err(eyre!("App request task panicked: {e}")), } - } + }, } } } @@ -302,20 +373,35 @@ async fn handle_consensus( Ok(()) } -#[allow(clippy::unit_arg)] -async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> eyre::Result<()> { +async fn handle_app_request(req: AppRequest, ctx: &AppRequestContext) -> eyre::Result<()> { match req { - AppRequest::GetCertificate(height, reply) => { - let result = state - .store() - .get_certificate(height) - .await - .wrap_err_with(|| { - format!("GetCertificate: Failed to get certificate for height {height:?}") - })?; + AppRequest::GetCertificate { + height, + enqueued_at, + reply, + } => { + ctx.metrics.observe_app_request_queue_time( + "GetCertificate", + enqueued_at.elapsed().as_secs_f64(), + ); + let _guard = ctx + .metrics + .start_app_request_process_timer("GetCertificate"); + + let result = ctx.store.get_certificate(height).await.wrap_err_with(|| { + format!("GetCertificate: Failed to get certificate for height {height:?}") + })?; let info = match result { - Some(certificate) => get_certificate_info(&state.ctx, engine, certificate).await, + Some(certificate) => { + get_certificate_info( + ctx.proposer_selector, + &ctx.engine, + &ctx.metrics, + certificate, + ) + .await + } None => None, }; @@ -325,7 +411,7 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetMisbehaviorEvidence(height, reply) => { - let evidence = state.store().get_misbehavior_evidence(height).await.wrap_err_with(|| { + let evidence = ctx.store.get_misbehavior_evidence(height).await.wrap_err_with(|| { format!( "GetMisbehaviorEvidence: Failed to get misbehavior evidence for height {height:?}", ) @@ -336,8 +422,7 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetProposalMonitorData(height, reply) => { - let data = state - .store() + let data = ctx.store .get_proposal_monitor_data(height) .await .wrap_err_with(|| { @@ -352,7 +437,7 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetInvalidPayloads(height, reply) => { - let payloads = state.store().get_invalid_payloads(height).await.wrap_err_with(|| { + let payloads = ctx.store.get_invalid_payloads(height).await.wrap_err_with(|| { format!( "Failed to get invalid payloads for height {:?} in response to a GetInvalidPayloads request", height, ) @@ -363,8 +448,9 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetStatus(reply) => { - let status = state - .get_status() + let snapshot = ctx.status_rx.borrow().clone(); + let status = snapshot + .get_status(&ctx.store, &ctx.stats) .await .wrap_err("GetStatus: Failed to get the current status")?; @@ -374,13 +460,14 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } AppRequest::GetHealth(reply) => { - if let Err(e) = reply.send(state.get_health()) { + if let Err(e) = reply.send(()) { error!("GetHealth: Failed to reply: {e:?}"); } } AppRequest::GetSyncState(reply) => { - if let Err(e) = reply.send(state.sync_state) { + let sync_state = ctx.status_rx.borrow().sync_state; + if let Err(e) = reply.send(sync_state) { error!("GetSyncState: Failed to reply: {e:?}"); } } @@ -390,27 +477,39 @@ async fn handle_app_request(req: AppRequest, state: &State, engine: &Engine) -> } async fn get_certificate_info( - ctx: &ArcContext, + proposer_selector: impl ProposerSelector, engine: &Engine, + metrics: &AppMetrics, stored: StoredCommitCertificate, ) -> Option { + if let Some(proposer) = stored.proposer { + return Some(CommitCertificateInfo { + certificate: stored.certificate, + certificate_type: stored.certificate_type, + proposer, + }); + } + // The validator set that signed the certificate is the one *before* executing that block, // since the block itself could contain validator set changes. let prev_height = stored.certificate.height.as_u64().saturating_sub(1); - let validator_set = engine - .eth - .get_active_validator_set(prev_height) - .await - .ok()?; + let validator_set = { + let _guard = + metrics.start_engine_api_timer("get_certificate_info.get_active_validator_set"); + engine + .eth + .get_active_validator_set(prev_height) + .await + .ok()? + }; - let proposer = stored.proposer.unwrap_or_else(|| { - ctx.select_proposer( + let proposer = proposer_selector + .select_proposer( &validator_set, stored.certificate.height, stored.certificate.round, ) - .address - }); + .address; Some(CommitCertificateInfo { certificate: stored.certificate, @@ -423,13 +522,17 @@ async fn get_certificate_info( mod tests { use super::*; + use arc_consensus_types::signing::PrivateKey; use arc_consensus_types::{ - Address, BlockHash, CommitCertificate, CommitCertificateType, Height, Round, ValueId, + Address, BlockHash, CommitCertificate, CommitCertificateType, Height, Round, Validator, + ValidatorSet, ValueId, }; use arc_eth_engine::engine::{MockEngineAPI, MockEthereumAPI}; use mockall::predicate::eq; + use rand::rngs::StdRng; + use rand::SeedableRng; - fn stored_cert(height: u64) -> StoredCommitCertificate { + fn stored_cert(height: u64, proposer: Option
) -> StoredCommitCertificate { StoredCommitCertificate { certificate: CommitCertificate::new( Height::new(height), @@ -438,53 +541,105 @@ mod tests { vec![], ), certificate_type: CommitCertificateType::Minimal, - // Set so get_certificate_info skips select_proposer (keeps the test focused - // on the validator set lookup). - proposer: Some(Address::new([0x42; 20])), + proposer, } } + fn validator_set() -> ValidatorSet { + let mut rng = StdRng::seed_from_u64(0x42); + let signing_key = PrivateKey::generate(&mut rng); + ValidatorSet::new(vec![Validator::new(signing_key.public_key(), 1)]) + } + + #[tokio::test] + async fn get_certificate_info_uses_stored_proposer_without_validator_set_lookup() { + let stored_proposer = Address::new([0x42; 20]); + let engine = Engine::new( + Box::new(MockEngineAPI::new()), + Box::new(MockEthereumAPI::new()), + ); + let metrics = AppMetrics::default(); + let ctx = ArcContext::default(); + + let info = get_certificate_info( + ctx.proposer_selector, + &engine, + &metrics, + stored_cert(42, Some(stored_proposer)), + ) + .await + .expect("should return Some"); + + assert_eq!(info.proposer, stored_proposer); + assert_eq!(info.certificate.height, Height::new(42)); + } + /// get_certificate_info must fetch the validator set at `certificate.height - 1` — the /// set that signed the certificate, i.e. the state *before* executing the certified block. #[tokio::test] - async fn get_certificate_info_queries_validator_set_at_prev_height() { + async fn get_certificate_info_queries_validator_set_at_prev_height_when_proposer_is_missing() { let cert_height = 42u64; + let fallback_validator_set = validator_set(); + let expected_proposer = fallback_validator_set + .get_by_index(0) + .expect("test validator set is non-empty") + .address; let mut mock_eth = MockEthereumAPI::new(); mock_eth .expect_get_active_validator_set() .with(eq(cert_height - 1)) .once() - .returning(|_| Ok(Default::default())); + .returning(move |_| Ok(fallback_validator_set.clone())); let engine = Engine::new(Box::new(MockEngineAPI::new()), Box::new(mock_eth)); + let metrics = AppMetrics::default(); let ctx = ArcContext::default(); - let info = get_certificate_info(&ctx, &engine, stored_cert(cert_height)) - .await - .expect("should return Some"); + let info = get_certificate_info( + ctx.proposer_selector, + &engine, + &metrics, + stored_cert(cert_height, None), + ) + .await + .expect("should return Some"); assert_eq!(info.certificate.height, Height::new(cert_height)); + assert_eq!(info.proposer, expected_proposer); } - /// At genesis (height 0), the saturating subtraction must keep the query at 0 rather - /// than underflowing. + /// At the first certificate height, the previous-height validator-set lookup + /// must query height 0. #[tokio::test] - async fn get_certificate_info_handles_genesis_height() { + async fn get_certificate_info_queries_validator_set_at_zero_for_first_certificate_height() { + let fallback_validator_set = validator_set(); + let expected_proposer = fallback_validator_set + .get_by_index(0) + .expect("test validator set is non-empty") + .address; + let mut mock_eth = MockEthereumAPI::new(); mock_eth .expect_get_active_validator_set() .with(eq(0u64)) .once() - .returning(|_| Ok(Default::default())); + .returning(move |_| Ok(fallback_validator_set.clone())); let engine = Engine::new(Box::new(MockEngineAPI::new()), Box::new(mock_eth)); + let metrics = AppMetrics::default(); let ctx = ArcContext::default(); - let info = get_certificate_info(&ctx, &engine, stored_cert(0)) - .await - .expect("should return Some"); + let info = get_certificate_info( + ctx.proposer_selector, + &engine, + &metrics, + stored_cert(1, None), + ) + .await + .expect("should return Some"); - assert_eq!(info.certificate.height, Height::new(0)); + assert_eq!(info.certificate.height, Height::new(1)); + assert_eq!(info.proposer, expected_proposer); } } diff --git a/crates/malachite-app/src/handlers/started_round.rs b/crates/malachite-app/src/handlers/started_round.rs index 76e7730..836097c 100644 --- a/crates/malachite-app/src/handlers/started_round.rs +++ b/crates/malachite-app/src/handlers/started_round.rs @@ -97,6 +97,15 @@ async fn on_started_round( assert!(round != Round::Nil, "Round cannot be Nil"); assert!(round >= state.current_round, "Round cannot go backwards"); + record_missed_rounds( + state.metrics(), + &state.ctx.proposer_selector, + state.validator_set(), + height, + state.current_round, + round, + ); + state.current_round = round; state.current_proposer = Some(proposer); @@ -113,6 +122,44 @@ async fn on_started_round( .await } +/// Increments the `consensus_round_missed` counter once for every round that was +/// started but skipped over between `prev_round` and `new_round`, attributing each +/// skipped round to the validator that round-robin would have made its proposer. +/// +/// `prev_round` is the height's previously started round (`state.current_round`), +/// with `Round::Nil` treated as round 0; the skipped rounds are the half-open +/// range `prev_round..new_round`, so the round that actually started is never +/// counted as missed. +/// +/// This is an at-least-once alerting signal, not an exact ledger. On a mid-height +/// restart `state.current_round` resets to `Round::Nil`, so a replayed +/// `StartedRound` for a round the node had already advanced past will re-count +/// rounds that were counted before the restart. The counter therefore never +/// under-counts missed rounds but may over-count across restarts — acceptable for +/// the alerting use case it serves. +fn record_missed_rounds( + metrics: &AppMetrics, + proposer_selector: &dyn ProposerSelector, + validator_set: &ValidatorSet, + height: Height, + prev_round: Round, + new_round: Round, +) { + let mut missed_round = prev_round.or(Round::Some(0)); + + while missed_round < new_round { + let missed_proposer = proposer_selector + .select_proposer(validator_set, height, missed_round) + .address; + + warn!(%missed_proposer, %height, %missed_round, "Consensus round missed"); + + metrics.inc_consensus_round_missed(missed_proposer); + + missed_round = missed_round.increment(); + } +} + #[allow(clippy::too_many_arguments)] async fn fetch_and_process_pending_proposals( height: Height, @@ -373,6 +420,110 @@ mod tests { (store, dir) } + #[test] + fn record_missed_rounds_counts_each_skipped_round() { + // Advancing from a fresh height (previous round Nil) straight to round 3 + // means rounds 0, 1 and 2 were started but never decided: three missed + // rounds, each attributed to that round's round-robin proposer. The round + // that actually started (3) is not itself a miss. + let keys: Vec = (0..4) + .map(|_| PrivateKey::generate(rand::rngs::OsRng)) + .collect(); + let validator_set = + ValidatorSet::new(keys.iter().map(|k| Validator::new(k.public_key(), 1))); + let selector = RoundRobin; + let height = Height::new(1); + let metrics = AppMetrics::default(); + + record_missed_rounds( + &metrics, + &selector, + &validator_set, + height, + Round::Nil, + Round::new(3), + ); + + // At height 1 the round-robin index is `round % 4`, so rounds 0..=3 map to + // four distinct proposers; assert exactly one increment per skipped round. + for missed in [Round::new(0), Round::new(1), Round::new(2)] { + let proposer = selector + .select_proposer(&validator_set, height, missed) + .address; + assert_eq!( + metrics.get_consensus_round_missed_count(proposer), + 1, + "round {missed} should be counted once against its proposer", + ); + } + + let started = selector + .select_proposer(&validator_set, height, Round::new(3)) + .address; + assert_eq!( + metrics.get_consensus_round_missed_count(started), + 0, + "the round that actually started is not a missed round", + ); + } + + #[test] + fn record_missed_rounds_starting_round_zero_is_noop() { + // Starting round 0 of a fresh height (previous round Nil) skips nothing. + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator_set = ValidatorSet::new(vec![Validator::new(signing_key.public_key(), 1)]); + let selector = RoundRobin; + let height = Height::new(1); + let metrics = AppMetrics::default(); + + record_missed_rounds( + &metrics, + &selector, + &validator_set, + height, + Round::Nil, + Round::new(0), + ); + + let proposer = selector + .select_proposer(&validator_set, height, Round::new(0)) + .address; + assert_eq!( + metrics.get_consensus_round_missed_count(proposer), + 0, + "no round is skipped when starting at round 0", + ); + } + + #[test] + fn record_missed_rounds_same_round_is_noop() { + // prev_round == new_round exercises the `missed_round < new_round` loop + // boundary: re-entering the current round counts nothing. + let signing_key = PrivateKey::generate(rand::rngs::OsRng); + let validator_set = ValidatorSet::new(vec![Validator::new(signing_key.public_key(), 1)]); + let selector = RoundRobin; + let height = Height::new(1); + let metrics = AppMetrics::default(); + + record_missed_rounds( + &metrics, + &selector, + &validator_set, + height, + Round::new(2), + Round::new(2), + ); + + let proposer = selector + .select_proposer(&validator_set, height, Round::new(2)) + .address; + assert_eq!( + metrics.get_consensus_round_missed_count(proposer), + 0, + "re-entering the same round is not a miss", + ); + } + #[tokio::test] async fn validate_undecided_blocks_all_valid() { let height = Height::new(1); diff --git a/crates/malachite-app/src/metrics/app.rs b/crates/malachite-app/src/metrics/app.rs index c8eca57..7674c8e 100644 --- a/crates/malachite-app/src/metrics/app.rs +++ b/crates/malachite-app/src/metrics/app.rs @@ -92,6 +92,18 @@ pub struct Inner { /// Time taken for each Engine API call engine_api_time: Family, + /// Time taken to serve RPC requests + rpc_request_time: Family, + + /// Number of app requests rejected because the app request channel was full + app_request_full_count: Family, + + /// Time app requests spend waiting in the app request channel + app_request_queue_time: Family, + + /// Time taken to process app requests + app_request_process_time: Family, + /// The number of times the consensus height has been restarted height_restart_count: Counter, @@ -111,6 +123,9 @@ pub struct Inner { /// Number of blocks replayed from CL to EL during startup handshake handshake_replay_blocks: Gauge, + /// Number of consensus rounds that failed to decide before advancing to the next round + consensus_round_missed: Family, + /// Internal state recording the previous validator set. /// Useful field to manage validators' metrics. /// This field is only accessible internally, and is not a metrics itself. @@ -139,12 +154,23 @@ impl Inner { engine_api_time: Family::new_with_constructor(|| { Histogram::new(exponential_buckets_range(0.001, 2.0, 10)) }), + rpc_request_time: Family::new_with_constructor(|| { + Histogram::new(exponential_buckets_range(0.001, 2.0, 16)) + }), + app_request_full_count: Family::default(), + app_request_queue_time: Family::new_with_constructor(|| { + Histogram::new(exponential_buckets_range(0.001, 2.0, 16)) + }), + app_request_process_time: Family::new_with_constructor(|| { + Histogram::new(exponential_buckets_range(0.001, 2.0, 16)) + }), height_restart_count: Counter::default(), sync_fell_behind_count: Counter::default(), invalid_payloads_count: Family::default(), pending_proposal_parts_count: Gauge::default(), consensus_params: Family::default(), handshake_replay_blocks: Gauge::default(), + consensus_round_missed: Family::default(), } } } @@ -250,6 +276,30 @@ impl AppMetrics { metrics.engine_api_time.clone(), ); + registry.register( + "rpc_request_time", + "Time taken to serve RPC requests, in seconds", + metrics.rpc_request_time.clone(), + ); + + registry.register( + "app_request_full_count", + "Number of app requests rejected because the app request channel was full", + metrics.app_request_full_count.clone(), + ); + + registry.register( + "app_request_queue_time", + "Time app requests spend waiting in the app request channel, in seconds", + metrics.app_request_queue_time.clone(), + ); + + registry.register( + "app_request_process_time", + "Time taken to process app requests, in seconds", + metrics.app_request_process_time.clone(), + ); + registry.register( "height_restart_count", "The number of times the consensus height has been restarted", @@ -280,6 +330,12 @@ impl AppMetrics { metrics.handshake_replay_blocks.clone(), ); + registry.register( + "consensus_round_missed", + "Number of consensus rounds that failed to decide before advancing to the next round", + metrics.consensus_round_missed.clone(), + ); + // Register version info as a separate Info metric let version_info = Info::new(VersionInfoLabel { version: arc_version::SHORT_VERSION, @@ -419,6 +475,46 @@ impl AppMetrics { }) } + /// Start a timer for an RPC request. + /// + /// The returned guard will record the time taken for the request when dropped. + #[must_use] + pub fn start_rpc_request_timer(&self, endpoint: &'static str) -> MetricsGuard { + MetricsGuard::new(self.clone(), endpoint, |metrics, endpoint, elapsed| { + metrics + .rpc_request_time + .get_or_create(&RpcEndpointLabel::new(endpoint)) + .observe(elapsed.as_secs_f64()); + }) + } + + /// Increment the number of app requests rejected by a full app request channel. + pub fn inc_app_request_full_count(&self, request: &'static str) { + self.app_request_full_count + .get_or_create(&AppRequestLabel::new(request)) + .inc(); + } + + /// Observe the time an app request spent waiting in the app request channel. + pub fn observe_app_request_queue_time(&self, request: &'static str, seconds: f64) { + self.app_request_queue_time + .get_or_create(&AppRequestLabel::new(request)) + .observe(seconds); + } + + /// Start a timer for app request processing. + /// + /// The returned guard will record processing time when dropped. + #[must_use] + pub fn start_app_request_process_timer(&self, request: &'static str) -> MetricsGuard { + MetricsGuard::new(self.clone(), request, |metrics, request, elapsed| { + metrics + .app_request_process_time + .get_or_create(&AppRequestLabel::new(request)) + .observe(elapsed.as_secs_f64()); + }) + } + /// Increment the number of times the consensus height has been restarted pub fn inc_height_restart_count(&self) { self.height_restart_count.inc(); @@ -468,6 +564,21 @@ impl AppMetrics { pub fn get_handshake_replay_blocks(&self) -> u64 { self.handshake_replay_blocks.get() } + + /// Record that a consensus round failed to decide before advancing. + pub fn inc_consensus_round_missed(&self, proposer: Address) { + self.consensus_round_missed + .get_or_create(&RoundMissedLabel::new(proposer)) + .inc(); + } + + /// Total number of missed consensus rounds since start. + #[cfg(test)] + pub fn get_consensus_round_missed_count(&self, proposer: Address) -> u64 { + self.consensus_round_missed + .get_or_create(&RoundMissedLabel::new(proposer)) + .get() + } } impl Default for AppMetrics { @@ -505,6 +616,19 @@ impl AddressLabel { } } +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct RoundMissedLabel { + proposer: AsLabelValue
, +} + +impl RoundMissedLabel { + fn new(proposer: Address) -> Self { + Self { + proposer: AsLabelValue(proposer), + } + } +} + #[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] struct ProcessMsgLabel { msg: &'static str, @@ -527,6 +651,28 @@ impl EngineApiLabel { } } +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct RpcEndpointLabel { + endpoint: &'static str, +} + +impl RpcEndpointLabel { + fn new(endpoint: &'static str) -> Self { + Self { endpoint } + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct AppRequestLabel { + request: &'static str, +} + +impl AppRequestLabel { + fn new(request: &'static str) -> Self { + Self { request } + } +} + /// Source of an invalid-payload record, used to label the counter. #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum InvalidPayloadSource { @@ -641,4 +787,16 @@ mod tests { "Metrics should not contain 0x prefix: {buf}" ); } + + #[test] + fn test_consensus_round_missed_counter() { + let metrics = AppMetrics::new(); + let proposer = Address::new([0xAA; 20]); + + metrics.inc_consensus_round_missed(proposer); + metrics.inc_consensus_round_missed(proposer); + metrics.inc_consensus_round_missed(proposer); + + assert_eq!(metrics.get_consensus_round_missed_count(proposer), 3); + } } diff --git a/crates/malachite-app/src/node.rs b/crates/malachite-app/src/node.rs index ae51a75..e8a6576 100644 --- a/crates/malachite-app/src/node.rs +++ b/crates/malachite-app/src/node.rs @@ -566,6 +566,7 @@ impl App { fn start_rpc_server( &self, channels: &Channels, + metrics: AppMetrics, ) -> ( mpsc::Sender, mpsc::Receiver, @@ -578,11 +579,13 @@ impl App { let listen_addr = self.config.rpc.listen_addr; let request_handle = channels.requests.clone(); let net_request_handle = channels.net_requests.clone(); - crate::rpc::serve( + let metrics = metrics.clone(); + crate::rpc::serve_with_metrics( listen_addr, request_handle, tx_rpc_req.clone(), net_request_handle, + metrics, ) }); Some(join_handle) @@ -710,7 +713,8 @@ impl App { let (channels, engine_handle) = self.start_consensus_engine(ctx, identity).await?; // Start the application RPC server - let (tx_app_req, rx_app_req, rpc_handle) = self.start_rpc_server(&channels); + let (tx_app_req, rx_app_req, rpc_handle) = + self.start_rpc_server(&channels, state.metrics().clone()); let tx_event = channels.events.clone(); let cancel_token = CancellationToken::new(); diff --git a/crates/malachite-app/src/request.rs b/crates/malachite-app/src/request.rs index 304eab8..2791e26 100644 --- a/crates/malachite-app/src/request.rs +++ b/crates/malachite-app/src/request.rs @@ -14,7 +14,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::time::SystemTime; +use std::time::{Instant, SystemTime}; use tokio::sync::{mpsc, oneshot}; use tracing::error; @@ -80,12 +80,14 @@ pub struct CommitCertificateInfo { #[allow(clippy::enum_variant_names)] pub enum AppRequest { /// Retrieves a commit certificate at the given height - GetCertificate( + GetCertificate { /// The height to get the certificate for. If None, get the latest certificate. - Option, + height: Option, + /// The time the request was enqueued. + enqueued_at: Instant, /// The channel to send the certificate back on. - oneshot::Sender>, - ), + reply: oneshot::Sender>, + }, /// Retrieves misbehavior evidence at the given height GetMisbehaviorEvidence( /// The height to get the evidence for. If None, use the latest height. @@ -127,7 +129,11 @@ impl AppRequest { ) -> Result, AppRequestError> { let (tx, rx) = oneshot::channel(); tx_app_req - .try_send(Self::GetCertificate(height, tx)) + .try_send(Self::GetCertificate { + height, + enqueued_at: Instant::now(), + reply: tx, + }) .inspect_err(|e| error!("Failed to send GetCertificate request to consensus: {e}"))?; let cert = rx.await.inspect_err(|e| { diff --git a/crates/malachite-app/src/rpc/handlers.rs b/crates/malachite-app/src/rpc/handlers.rs index 81b45a5..9b1b0b0 100644 --- a/crates/malachite-app/src/rpc/handlers.rs +++ b/crates/malachite-app/src/rpc/handlers.rs @@ -25,8 +25,9 @@ use serde_json::json; use malachitebft_app_channel::ConsensusRequest; use malachitebft_app_channel::NetworkRequest; -use crate::request::AppRequest; +use crate::metrics::AppMetrics; use crate::request::TxAppReq; +use crate::request::{AppRequest, AppRequestError}; use crate::rpc::types::persistent_peer_error_to_response; use crate::rpc::types::request_error_to_response; use crate::rpc::types::RpcVersion; @@ -72,14 +73,20 @@ pub(crate) async fn get_network_state( } pub(crate) async fn get_commit( + metrics: State, tx_app_req: State, query: Query, Extension(version): Extension, ) -> impl IntoResponse { + let _guard = metrics.start_rpc_request_timer("/commit"); tracing::debug!(?version, "get_commit called"); - AppRequest::get_certificate(query.height, &tx_app_req) - .await + let result = AppRequest::get_certificate(query.height, &tx_app_req).await; + if matches!(result, Err(AppRequestError::Full)) { + metrics.inc_app_request_full_count("GetCertificate"); + } + + result .map_err(request_error_to_response)? .map(|cert| Json(RpcCommitCertificate::from(cert))) .ok_or_else(|| { diff --git a/crates/malachite-app/src/rpc/mod.rs b/crates/malachite-app/src/rpc/mod.rs index 54d8e4c..69c9498 100644 --- a/crates/malachite-app/src/rpc/mod.rs +++ b/crates/malachite-app/src/rpc/mod.rs @@ -27,3 +27,4 @@ mod version; pub use routes::build_router; pub use routes::serve; +pub(crate) use routes::serve_with_metrics; diff --git a/crates/malachite-app/src/rpc/routes.rs b/crates/malachite-app/src/rpc/routes.rs index 16a4f8c..2bb0ae7 100644 --- a/crates/malachite-app/src/rpc/routes.rs +++ b/crates/malachite-app/src/rpc/routes.rs @@ -28,6 +28,7 @@ use tracing::{error, info}; use super::middleware::extract_version; use super::types::{EndpointInfo, RpcState, TxConsensusReq, TxNetworkReq}; use super::version::ApiVersion; +use crate::metrics::AppMetrics; use crate::request::TxAppReq; // List of RPC routes. @@ -131,7 +132,32 @@ pub async fn serve( tx_app_req: TxAppReq, tx_network_req: TxNetworkReq, ) { - if let Err(e) = inner(listen_addr, tx_consensus_req, tx_app_req, tx_network_req).await { + serve_with_metrics( + listen_addr, + tx_consensus_req, + tx_app_req, + tx_network_req, + AppMetrics::default(), + ) + .await +} + +pub(crate) async fn serve_with_metrics( + listen_addr: impl ToSocketAddrs, + tx_consensus_req: TxConsensusReq, + tx_app_req: TxAppReq, + tx_network_req: TxNetworkReq, + metrics: AppMetrics, +) { + if let Err(e) = inner( + listen_addr, + tx_consensus_req, + tx_app_req, + tx_network_req, + metrics, + ) + .await + { error!("RPC server failed: {e}"); } } @@ -144,11 +170,26 @@ pub fn build_router( tx_consensus_req: TxConsensusReq, tx_app_req: TxAppReq, tx_network_req: TxNetworkReq, +) -> Router { + build_router_with_metrics( + tx_consensus_req, + tx_app_req, + tx_network_req, + AppMetrics::default(), + ) +} + +pub(crate) fn build_router_with_metrics( + tx_consensus_req: TxConsensusReq, + tx_app_req: TxAppReq, + tx_network_req: TxNetworkReq, + metrics: AppMetrics, ) -> Router { let rpc_state = RpcState { tx_consensus_req, tx_app_req, tx_network_req, + metrics, }; let routes = build_routes(); @@ -179,8 +220,9 @@ async fn inner( tx_consensus_req: TxConsensusReq, tx_app_req: TxAppReq, tx_network_req: TxNetworkReq, + metrics: AppMetrics, ) -> Result<()> { - let app = build_router(tx_consensus_req, tx_app_req, tx_network_req); + let app = build_router_with_metrics(tx_consensus_req, tx_app_req, tx_network_req, metrics); let listener = TcpListener::bind(listen_addr).await?; let address = listener.local_addr()?; @@ -347,7 +389,12 @@ mod tests { let _ = reply.send(state); } MockConfig::AppGetCertificate(ret) => { - let Some(AppRequest::GetCertificate(None, reply_port)) = msg else { + let Some(AppRequest::GetCertificate { + height: None, + reply: reply_port, + .. + }) = msg + else { panic!("Unexpected msg"); }; let _ = reply_port.send(match ret { diff --git a/crates/malachite-app/src/rpc/types.rs b/crates/malachite-app/src/rpc/types.rs index be05ab2..2703c49 100644 --- a/crates/malachite-app/src/rpc/types.rs +++ b/crates/malachite-app/src/rpc/types.rs @@ -45,6 +45,7 @@ use malachitebft_core_state_machine::state::{RoundValue, State as MState}; use malachitebft_core_types::{NilOrVal, VoteType}; use malachitebft_network::PersistentPeerError; +use crate::metrics::AppMetrics; use crate::request::{AppRequestError, CommitCertificateInfo, Status, TxAppReq}; use crate::utils::sync_state::SyncState; use alloy_rpc_types_engine::ExecutionPayloadV3; @@ -60,6 +61,7 @@ pub(crate) struct RpcState { pub tx_consensus_req: TxConsensusReq, pub tx_app_req: TxAppReq, pub tx_network_req: TxNetworkReq, + pub metrics: AppMetrics, } pub(crate) struct RouteDef { @@ -805,6 +807,58 @@ impl From for RpcInvalidPayload { } } +#[cfg(test)] +mod tests_rendering { + use super::*; + use arc_consensus_types::signing::PrivateKey; + use malachitebft_core_types::Round; + use rand::rngs::OsRng; + use std::time::SystemTime; + + #[test] + fn rpc_validator_renders_public_key_as_0x_lowercase_hex() { + let private_key = PrivateKey::generate(OsRng); + let public_key = private_key.public_key(); + let key_bytes = public_key.as_bytes().to_vec(); + let validator = Validator::new(public_key, 100); + + let rpc = RpcValidator::from(&validator); + + // Renders as `0x` + lowercase hex (hex::encode is lowercase by construction). + let expected = format!("0x{}", hex::encode(&key_bytes)); + assert_eq!(rpc.public_key_hex, expected); + } + + #[test] + fn rpc_app_status_renders_public_key_as_0x_lowercase_hex() { + let private_key = PrivateKey::generate(OsRng); + let public_key = private_key.public_key(); + let key_bytes = public_key.as_bytes().to_vec(); + + let status = Status { + height: Height::new(1), + round: Round::new(0), + address: Address::from_public_key(&public_key), + public_key, + proposer: None, + height_start_time: SystemTime::UNIX_EPOCH, + prev_payload_hash: None, + db_latest_height: Height::default(), + db_earliest_height: Height::default(), + undecided_blocks_count: 0, + pending_proposal_parts: Vec::new(), + validator_set: ValidatorSet::default(), + sync_state: SyncState::InSync, + }; + + let rpc = RpcAppStatus::from(status); + + // Renders as `0x` + lowercase hex (hex::encode is lowercase by construction). + let expected = format!("0x{}", hex::encode(&key_bytes)); + assert_eq!(rpc.public_key, expected); + } +} + #[cfg(test)] mod tests_network { use super::*; diff --git a/crates/malachite-app/src/state.rs b/crates/malachite-app/src/state.rs index ea3d239..f996e9a 100644 --- a/crates/malachite-app/src/state.rs +++ b/crates/malachite-app/src/state.rs @@ -28,26 +28,98 @@ use malachitebft_app_channel::app::types::core::Round; use crate::streaming; use arc_consensus_types::{ - Address, AlloyAddress, ArcContext, BlockHash, ChainId, Config, ConsensusParams, ConsensusSpec, - Height, NetworkId, ValidatorSet, + signing::PublicKey, Address, AlloyAddress, ArcContext, BlockHash, ChainId, Config, + ConsensusParams, ConsensusSpec, Height, NetworkId, ValidatorSet, }; use arc_eth_engine::json_structures::ExecutionBlock; use arc_eth_engine::persistence_meter::{NoopPersistenceMeter, PersistenceMeter}; use arc_signer::ArcSigningProvider; use malachitebft_core_types::HeightParams; -use crate::block::ConsensusBlock; use crate::env_config::EnvConfig; use crate::metrics::app::AppMetrics; use crate::node::ConsensusIdentity; use crate::request::Status; use crate::stats::Stats; -use crate::store::repositories::UndecidedBlocksRepository; use crate::store::Store; use crate::streaming::PartStreamsMap; use crate::utils::sync_state::SyncState; use arc_consensus_types::proposal_monitor::ProposalMonitor; +/// A snapshot of the volatile consensus state fields needed to serve status queries. +/// +/// This is published via a `tokio::sync::watch` channel after each consensus message, +/// so that the app-request task can read it without blocking the consensus loop. +/// +/// The snapshot is eventually consistent with `State`: it is republished after the +/// consensus loop finishes handling a message, so a reader may observe a snapshot +/// that is up to one consensus-message stale. Fields derived from it (e.g. the +/// `(height, round)` used to query `get_undecided_blocks`) inherit that staleness. +/// This is acceptable for the `/status` RPC — strict freshness would require +/// re-coupling the request handler to the consensus loop. +#[derive(Clone, Debug, PartialEq)] +pub struct StatusSnapshot { + pub height: Height, + pub round: Round, + pub proposer: Option
, + pub address: Address, + pub public_key: PublicKey, + pub previous_block: Option, + pub validator_set: ValidatorSet, + pub sync_state: SyncState, +} + +impl StatusSnapshot { + /// Build the full `Status` response from this snapshot plus live DB/stats queries. + pub async fn get_status(&self, store: &Store, stats: &Stats) -> eyre::Result { + let undecided_blocks_count = store + .get_undecided_blocks(self.height, self.round) + .await + .wrap_err_with(|| { + format!( + "Failed to get undecided blocks for height {} and round {} from the state", + self.height, self.round, + ) + })? + .len(); + + let db_latest_height = store + .max_height() + .await + .wrap_err("Failed to get the latest height from the state")? + .unwrap_or_default(); + + let db_earliest_height = store + .min_height() + .await + .wrap_err("Failed to get earliest height from the state")? + .unwrap_or_default(); + + let pending_proposal_parts = store + .get_pending_proposal_parts_counts() + .await + .wrap_err("Failed to get pending proposal parts counts from the state")?; + + Ok(Status { + height: self.height, + round: self.round, + address: self.address, + public_key: self.public_key, + proposer: self.proposer, + // elapsed() is always <= time since epoch, so this won't underflow + #[allow(clippy::arithmetic_side_effects)] + height_start_time: SystemTime::now() - stats.height_started().elapsed(), + prev_payload_hash: self.previous_block.map(|b| b.block_hash), + db_latest_height, + db_earliest_height, + undecided_blocks_count, + pending_proposal_parts, + validator_set: self.validator_set.clone(), + sync_state: self.sync_state, + }) + } +} + /// Information needed to start the next height after a decision is reached. #[derive(Debug)] pub struct NextHeightInfo { @@ -386,69 +458,18 @@ impl State { limit } - /// Return important current information. - pub async fn get_status(&self) -> eyre::Result { - let undecided_blocks_count = self - .get_undecided_blocks(self.current_height, self.current_round) - .await - .wrap_err_with(|| { - format!( - "Failed to get undecided blocks for height {} and round {} from the state", - self.current_height, self.current_round, - ) - })? - .len(); - - let pending_proposal_parts = self - .store - .get_pending_proposal_parts_counts() - .await - .wrap_err("Failed to get pending proposal parts counts from the state")?; - - Ok(Status { + /// Create a snapshot of the volatile consensus state fields. + pub fn status_snapshot(&self) -> StatusSnapshot { + StatusSnapshot { height: self.current_height, round: self.current_round, + proposer: self.current_proposer, address: self.address(), public_key: *self.identity.public_key(), - proposer: self.current_proposer, - // elapsed() is always <= time since epoch, so this won't underflow - #[allow(clippy::arithmetic_side_effects)] - height_start_time: SystemTime::now() - self.stats.height_started().elapsed(), - prev_payload_hash: self.previous_block.map(|b| b.block_hash), - db_latest_height: self - .store() - .max_height() - .await - .wrap_err("Failed to get the latest height from the state")? - .unwrap_or_default(), - db_earliest_height: self - .store() - .min_height() - .await - .wrap_err("Failed to get earliest height from the state")? - .unwrap_or_default(), - undecided_blocks_count, - pending_proposal_parts, - validator_set: self.validator_set().to_owned(), + previous_block: self.previous_block, + validator_set: self.validator_set().clone(), sync_state: self.sync_state, - }) - } - - /// Return unit type. Used to check the app is active. - pub fn get_health(&self) {} - - /// Retrieves all undecided blocks at the given height and round. - pub async fn get_undecided_blocks( - &self, - height: Height, - round: Round, - ) -> eyre::Result> { - self.store - .get_by_round(height, round) - .await - .wrap_err_with(|| { - format!("Failed to get undecided blocks for height {height} and round {round} from the database") - }) + } } /// Move to the next height, updating the previous block, validator set, and consensus params. diff --git a/crates/malachite-app/tests/rpc_integration.rs b/crates/malachite-app/tests/rpc_integration.rs index 498ad5e..95afb94 100644 --- a/crates/malachite-app/tests/rpc_integration.rs +++ b/crates/malachite-app/tests/rpc_integration.rs @@ -160,7 +160,7 @@ async fn test_commit_endpoint_with_height() { // Respond to certificate request server.expect_app_request(|req| match req { - AppRequest::GetCertificate(height, reply) => { + AppRequest::GetCertificate { height, reply, .. } => { assert_eq!(height, Some(Height::new(42))); reply.send(None).ok(); } @@ -184,7 +184,7 @@ async fn test_commit_endpoint_without_height() { // Respond to certificate request server.expect_app_request(|req| match req { - AppRequest::GetCertificate(height, reply) => { + AppRequest::GetCertificate { height, reply, .. } => { assert_eq!(height, None); reply.send(None).ok(); } diff --git a/crates/precompiles/src/call_from.rs b/crates/precompiles/src/call_from.rs index 328c876..84968dd 100644 --- a/crates/precompiles/src/call_from.rs +++ b/crates/precompiles/src/call_from.rs @@ -190,6 +190,8 @@ impl SubcallPrecompile for CallFromPrecompile { #[cfg(test)] mod tests { use super::*; + use alloy_primitives::Bytes; + use revm::interpreter::{CallOutcome, Gas, InstructionResult, InterpreterResult}; /// Guard against silent upstream changes to the EVM COPY gas cost. /// An unexpected change would alter `abi_decode_gas` and `abi_encode_gas` results, @@ -282,4 +284,155 @@ mod tests { "is_static mismatch" ); } + + fn make_call_frame_result(result: InstructionResult, output: Bytes) -> FrameResult { + FrameResult::Call(CallOutcome { + result: InterpreterResult::new(result, output, Gas::new(0)), + memory_offset: 0..0, + was_precompile_called: false, + precompile_call_logs: Default::default(), + }) + } + + fn empty_continuation() -> SubcallContinuationData { + SubcallContinuationData { + state: Box::new(()), + } + } + + #[test] + fn complete_subcall_child_success_encodes_success_true() { + let precompile = CallFromPrecompile; + let output: Bytes = vec![0xde, 0xad, 0xbe, 0xef].into(); + let child = make_call_frame_result(InstructionResult::Return, output.clone()); + + let completion = precompile + .complete_subcall(empty_continuation(), &child) + .expect("Call frame result should produce Ok"); + + let decoded = ICallFrom::callFromCall::abi_decode_returns(&completion.output) + .expect("output should decode as callFrom return"); + assert!(decoded.success, "child Return ⇒ success=true"); + assert_eq!(decoded.returnData.as_ref(), output.as_ref()); + assert_eq!(completion.gas_overhead, abi_encode_gas(output.len())); + assert!( + completion.success, + "precompile completion is always success=true; the caller inspects the encoded bool" + ); + } + + #[test] + fn complete_subcall_child_revert_encodes_success_false() { + let precompile = CallFromPrecompile; + let revert_output: Bytes = vec![0x08, 0xc3, 0x79, 0xa0].into(); + let child = make_call_frame_result(InstructionResult::Revert, revert_output.clone()); + + let completion = precompile + .complete_subcall(empty_continuation(), &child) + .expect("Call frame result with Revert should still produce Ok"); + + let decoded = ICallFrom::callFromCall::abi_decode_returns(&completion.output) + .expect("output should decode as callFrom return"); + assert!(!decoded.success, "child Revert ⇒ success=false"); + assert_eq!(decoded.returnData.as_ref(), revert_output.as_ref()); + assert_eq!(completion.gas_overhead, abi_encode_gas(revert_output.len())); + } + + #[test] + fn complete_subcall_non_call_frame_result_errors() { + use revm_interpreter::CreateOutcome; + + let precompile = CallFromPrecompile; + let create_result = FrameResult::Create(CreateOutcome { + result: InterpreterResult::new(InstructionResult::Return, Bytes::new(), Gas::new(0)), + address: None, + }); + + let result = precompile.complete_subcall(empty_continuation(), &create_result); + assert!( + matches!(result, Err(SubcallError::UnexpectedFrameResult)), + "non-Call FrameResult must yield UnexpectedFrameResult" + ); + } + + fn call_from_inputs(data: Vec, gas_limit: u64) -> CallInputs { + use alloy_primitives::address; + use alloy_sol_types::SolCall; + use revm::interpreter::interpreter_action::{CallInput, CallScheme, CallValue}; + + let calldata = ICallFrom::callFromCall { + sender: address!("e000000000000000000000000000000000000001"), + target: address!("c000000000000000000000000000000000000002"), + data: data.into(), + } + .abi_encode(); + + CallInputs { + scheme: CallScheme::Call, + target_address: CALL_FROM_ADDRESS, + bytecode_address: CALL_FROM_ADDRESS, + known_bytecode: None, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(calldata.into()), + gas_limit, + is_static: false, + caller: address!("c000000000000000000000000000000000000001"), + return_memory_offset: 0..0, + } + } + + #[test] + fn abi_decode_gas_base_plus_per_word() { + // 0 bytes → base only (div_ceil(0, 32) = 0) + assert_eq!(abi_decode_gas(0), ABI_DECODE_BASE_GAS); + // 1 byte → 1 word + assert_eq!(abi_decode_gas(1), ABI_DECODE_BASE_GAS + gas::COPY); + // 32 bytes → 1 word + assert_eq!(abi_decode_gas(32), ABI_DECODE_BASE_GAS + gas::COPY); + // 33 bytes → 2 words + assert_eq!(abi_decode_gas(33), ABI_DECODE_BASE_GAS + 2 * gas::COPY); + // 64 bytes → 2 words + assert_eq!(abi_decode_gas(64), ABI_DECODE_BASE_GAS + 2 * gas::COPY); + } + + #[test] + fn init_subcall_below_abi_decode_overhead_returns_insufficient_gas() { + let precompile = CallFromPrecompile; + // Empty data: overhead = abi_decode_gas(0). One gas short must revert. + let inputs = call_from_inputs(vec![], abi_decode_gas(0) - 1); + assert!( + matches!( + precompile.init_subcall(&inputs), + Err(SubcallError::InsufficientGas(_)) + ), + "gas_limit one below the ABI decode overhead must return InsufficientGas" + ); + } + + #[test] + fn init_subcall_at_abi_decode_overhead_succeeds() { + let precompile = CallFromPrecompile; + // Exactly the overhead leaves zero gas for the child but still completes init. + let inputs = call_from_inputs(vec![], abi_decode_gas(0)); + let result = precompile + .init_subcall(&inputs) + .expect("gas_limit equal to overhead should succeed"); + assert_eq!(result.gas_overhead, abi_decode_gas(0)); + } + + #[test] + fn init_subcall_below_per_word_overhead_returns_insufficient_gas() { + let precompile = CallFromPrecompile; + // 33 bytes → 2 words → overhead = base + 2*COPY. One gas short must revert. + let data = vec![0u8; 33]; + let overhead = abi_decode_gas(data.len()); + let inputs = call_from_inputs(data, overhead - 1); + assert!( + matches!( + precompile.init_subcall(&inputs), + Err(SubcallError::InsufficientGas(_)) + ), + "nonempty data: gas below base+per-word overhead must return InsufficientGas" + ); + } } diff --git a/crates/precompiles/src/helpers.rs b/crates/precompiles/src/helpers.rs index 13e4493..6cad823 100644 --- a/crates/precompiles/src/helpers.rs +++ b/crates/precompiles/src/helpers.rs @@ -82,6 +82,8 @@ pub fn revert_message_to_bytes(msg: &str) -> Bytes { /// Zero6+: also applied to authorization and validation reverts (unauthorized /// caller, blocklist, zero address, zero amount, overflow) via /// [`new_reverted_with_early_penalty`]. +/// +/// Zero8+: also applied to delegatecall rejections via [`check_delegatecall`]. pub(crate) const PRECOMPILE_EARLY_REVERT_GAS_PENALTY: u64 = 200; /// Enum to represent either a reverted precompile output or an error @@ -552,14 +554,20 @@ pub(crate) fn check_delegatecall( precompile_address: Address, precompile_input: &PrecompileInput, gas_counter: &Gas, + hardfork_flags: ArcHardforkFlags, ) -> Result<(), PrecompileErrorOrRevert> { if precompile_input.target_address != precompile_address || precompile_input.bytecode_address != precompile_address { - return Err(PrecompileErrorOrRevert::new_reverted( - *gas_counter, - ERR_DELEGATE_CALL_NOT_ALLOWED, - )); + return Err(if hardfork_flags.is_active(ArcHardfork::Zero8) { + PrecompileErrorOrRevert::new_reverted_with_penalty( + *gas_counter, + PRECOMPILE_EARLY_REVERT_GAS_PENALTY, + ERR_DELEGATE_CALL_NOT_ALLOWED, + ) + } else { + PrecompileErrorOrRevert::new_reverted(*gas_counter, ERR_DELEGATE_CALL_NOT_ALLOWED) + }); } Ok(()) } @@ -1071,4 +1079,43 @@ mod tests { assert!(matches!(result, Err(PrecompileError::OutOfGas))); } + + #[test] + fn new_reverted_with_early_penalty_is_zero6_gated() { + let pre_zero6 = ArcHardforkFlags::with(&[ArcHardfork::Zero5]); + let zero6 = ArcHardforkFlags::with(&[ArcHardfork::Zero6]); + + let pre = new_reverted_with_early_penalty(Gas::new(10_000), "err", pre_zero6); + let pre_output: Result = pre.into(); + let pre_output = pre_output.expect("pre-Zero6 returns Revert"); + assert_eq!(pre_output.gas_used, 0); + assert!(pre_output.reverted); + + let post = new_reverted_with_early_penalty(Gas::new(10_000), "err", zero6); + let post_output: Result = post.into(); + let post_output = post_output.expect("Zero6 returns Revert"); + assert_eq!(post_output.gas_used, PRECOMPILE_EARLY_REVERT_GAS_PENALTY); + assert!(post_output.reverted); + } + + #[test] + fn new_reverted_with_early_penalty_oog_under_zero6() { + let zero6 = ArcHardforkFlags::with(&[ArcHardfork::Zero6]); + let result = new_reverted_with_early_penalty( + Gas::new(PRECOMPILE_EARLY_REVERT_GAS_PENALTY - 1), + "err", + zero6, + ); + assert!(matches!( + result, + PrecompileErrorOrRevert::Error(PrecompileError::OutOfGas) + )); + } + + #[test] + fn precompile_early_revert_gas_penalty_is_200() { + // Zero6 consensus rule: early-path reverts on stateful precompiles charge a uniform + // 200-gas penalty. Pin the literal so a silent change is caught. + assert_eq!(PRECOMPILE_EARLY_REVERT_GAS_PENALTY, 200); + } } diff --git a/crates/precompiles/src/native_coin_authority.rs b/crates/precompiles/src/native_coin_authority.rs index d1749d6..66be4fd 100644 --- a/crates/precompiles/src/native_coin_authority.rs +++ b/crates/precompiles/src/native_coin_authority.rs @@ -247,6 +247,7 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { NATIVE_COIN_AUTHORITY_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; // Reject minting to zero address (Zero5+) @@ -360,6 +361,7 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { NATIVE_COIN_AUTHORITY_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; // Reject burning from zero address (Zero5+) @@ -476,6 +478,7 @@ precompile!(run_native_coin_authority, precompile_input, hardfork_flags; { NATIVE_COIN_AUTHORITY_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; // Reject transfers involving zero address (Zero5+) @@ -760,6 +763,14 @@ mod tests { } else { tc.pre_zero5_gas_used.unwrap_or(tc.gas_used) }; + // Zero8: a delegatecall rejection charges the uniform early-revert penalty. + let expected_gas_used = if hardfork_flags.is_active(ArcHardfork::Zero8) + && tc.expected_revert_str == Some(ERR_DELEGATE_CALL_NOT_ALLOWED) + { + expected_gas_used.saturating_add(PRECOMPILE_EARLY_REVERT_GAS_PENALTY) + } else { + expected_gas_used + }; assert_eq!( result.gas.used(), expected_gas_used, diff --git a/crates/precompiles/src/native_coin_control.rs b/crates/precompiles/src/native_coin_control.rs index 211ff27..5597457 100644 --- a/crates/precompiles/src/native_coin_control.rs +++ b/crates/precompiles/src/native_coin_control.rs @@ -167,6 +167,18 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { hardfork_flags, )); } + // Zero8: surface the delegatecall penalty before the + // success-path floor; otherwise an authorized delegatecall + // in the 200..floor gas window OOGs before it can revert + // with the 200-gas penalty. + if hardfork_flags.is_active(ArcHardfork::Zero8) { + check_delegatecall( + NATIVE_COIN_CONTROL_ADDRESS, + &precompile_input, + &gas_counter, + hardfork_flags, + )?; + } check_gas_remaining( &gas_counter, BLOCKLIST_GAS_COST - PRECOMPILE_SLOAD_GAS_COST, @@ -206,6 +218,7 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { NATIVE_COIN_CONTROL_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; // Add to blocklist @@ -307,6 +320,18 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { hardfork_flags, )); } + // Zero8: surface the delegatecall penalty before the + // success-path floor; otherwise an authorized delegatecall + // in the 200..floor gas window OOGs before it can revert + // with the 200-gas penalty. + if hardfork_flags.is_active(ArcHardfork::Zero8) { + check_delegatecall( + NATIVE_COIN_CONTROL_ADDRESS, + &precompile_input, + &gas_counter, + hardfork_flags, + )?; + } check_gas_remaining( &gas_counter, UNBLOCKLIST_GAS_COST - PRECOMPILE_SLOAD_GAS_COST, @@ -346,6 +371,7 @@ precompile!(run_native_coin_control, precompile_input, hardfork_flags; { NATIVE_COIN_CONTROL_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; // Remove from blocklist @@ -458,6 +484,60 @@ mod tests { zero6_only: bool, /// If true, skip this test case for hardfork combinations with Zero6. pre_zero6_only: bool, + /// If true, skip this test case for hardfork combinations without Zero8. + /// Used for cases whose result shape only holds once the Zero8 + /// delegatecall reorder is active (penalty revert vs OOG). + zero8_only: bool, + /// If true, skip this test case for hardfork combinations with Zero8. + pre_zero8_only: bool, + } + + impl NativeCoinControlTest { + /// Whether this case should be skipped for the given hardfork combination. + fn skip_for(&self, hardfork_flags: ArcHardforkFlags) -> bool { + let zero5 = hardfork_flags.is_active(ArcHardfork::Zero5); + let zero6 = hardfork_flags.is_active(ArcHardfork::Zero6); + let zero8 = hardfork_flags.is_active(ArcHardfork::Zero8); + // ZeroX hardforks are cumulative; Zero6 implies Zero5. + let invalid_combo = zero6 && !zero5; + invalid_combo + || (self.zero6_only && !zero6) + || (self.pre_zero6_only && zero6) + || (self.zero8_only && !zero8) + || (self.pre_zero8_only && zero8) + } + } + + fn run_native_coin_control_case(tc: &NativeCoinControlTest, hardfork_flags: ArcHardforkFlags) { + let tc_name = format!("{} (hardfork_flags: {hardfork_flags:?})", tc.name); + + // Sanity check that we're not configuring test cases incorrectly + if !matches!( + tc.expected_result, + InstructionResult::Revert | InstructionResult::Return + ) { + assert!( + tc.return_data.is_none(), + "{tc_name}: expected no return data" + ); + } + + let mut ctx = mock_context(hardfork_flags); + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: tc.target_address, + bytecode_address: tc.bytecode_address, + known_bytecode: None, + caller: tc.caller, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(tc.calldata.clone()), + gas_limit: tc.gas_limit, + is_static: false, + return_memory_offset: 0..0, + }; + + let precompile_res = call_native_coin_control(&mut ctx, &inputs, hardfork_flags); + assert_precompile_result(precompile_res, tc, hardfork_flags, &tc_name); } // Test constants @@ -513,6 +593,14 @@ mod tests { } else { tc.pre_zero5_gas_used.unwrap_or(tc.gas_used) }; + // Zero8: a delegatecall rejection charges the uniform early-revert penalty. + let expected_gas_used = if hardfork_flags.is_active(ArcHardfork::Zero8) + && tc.expected_revert_str == Some(ERR_DELEGATE_CALL_NOT_ALLOWED) + { + expected_gas_used.saturating_add(PRECOMPILE_EARLY_REVERT_GAS_PENALTY) + } else { + expected_gas_used + }; assert_eq!( result.gas.used(), expected_gas_used, @@ -544,6 +632,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -563,6 +653,8 @@ mod tests { zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -581,6 +673,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -597,6 +691,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -616,6 +712,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: ADDRESS_B, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -635,6 +733,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: ADDRESS_B, }, @@ -654,6 +754,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -672,6 +774,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -688,6 +792,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -707,6 +813,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -726,6 +834,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: ADDRESS_B, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -745,6 +855,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: ADDRESS_B, }, @@ -764,6 +876,8 @@ mod tests { zero6_gas_used: Some(PRECOMPILE_EARLY_REVERT_GAS_PENALTY), zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -782,6 +896,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -798,6 +914,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -819,6 +937,8 @@ mod tests { zero6_gas_used: None, zero6_only: true, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -840,6 +960,8 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: true, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -858,6 +980,8 @@ mod tests { zero6_gas_used: None, zero6_only: true, pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, @@ -876,59 +1000,106 @@ mod tests { zero6_gas_used: None, zero6_only: false, pre_zero6_only: true, + zero8_only: false, + pre_zero8_only: false, target_address: NATIVE_COIN_CONTROL_ADDRESS, bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, }, + // Zero8: an authorized delegatecall with gas in the + // 200..floor window reverts with the delegatecall penalty, + // because the delegatecall check now runs before the + // success-path gas floor. + NativeCoinControlTest { + name: "blocklist() Zero8 low-gas authorized delegatecall reverts with penalty", + caller: ALLOWED_CALLER_ADDRESS, + calldata: INativeCoinControl::blocklistCall { account: ADDRESS_B } + .abi_encode() + .into(), + gas_limit: 1000, + expected_result: InstructionResult::Revert, + expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), + return_data: None, + gas_used: 0, + pre_zero5_gas_used: None, + zero6_gas_used: None, + zero6_only: true, + pre_zero6_only: false, + zero8_only: true, + pre_zero8_only: false, + target_address: ADDRESS_B, + bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, + }, + // Pre-Zero8: the same call OOGs on the success-path floor + // before reaching the delegatecall check. Locks in that the + // reorder is Zero8-gated and does not touch live networks. + NativeCoinControlTest { + name: "blocklist() pre-Zero8 low-gas authorized delegatecall OOGs", + caller: ALLOWED_CALLER_ADDRESS, + calldata: INativeCoinControl::blocklistCall { account: ADDRESS_B } + .abi_encode() + .into(), + gas_limit: 1000, + expected_result: InstructionResult::PrecompileOOG, + expected_revert_str: None, + return_data: None, + gas_used: 0, + pre_zero5_gas_used: None, + zero6_gas_used: None, + zero6_only: false, + pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: true, + target_address: ADDRESS_B, + bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, + }, + NativeCoinControlTest { + name: "unBlocklist() Zero8 low-gas authorized delegatecall reverts with penalty", + caller: ALLOWED_CALLER_ADDRESS, + calldata: INativeCoinControl::unBlocklistCall { account: ADDRESS_B } + .abi_encode() + .into(), + gas_limit: 1000, + expected_result: InstructionResult::Revert, + expected_revert_str: Some(ERR_DELEGATE_CALL_NOT_ALLOWED), + return_data: None, + gas_used: 0, + pre_zero5_gas_used: None, + zero6_gas_used: None, + zero6_only: true, + pre_zero6_only: false, + zero8_only: true, + pre_zero8_only: false, + target_address: ADDRESS_B, + bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, + }, + NativeCoinControlTest { + name: "unBlocklist() pre-Zero8 low-gas authorized delegatecall OOGs", + caller: ALLOWED_CALLER_ADDRESS, + calldata: INativeCoinControl::unBlocklistCall { account: ADDRESS_B } + .abi_encode() + .into(), + gas_limit: 1000, + expected_result: InstructionResult::PrecompileOOG, + expected_revert_str: None, + return_data: None, + gas_used: 0, + pre_zero5_gas_used: None, + zero6_gas_used: None, + zero6_only: false, + pre_zero6_only: false, + zero8_only: false, + pre_zero8_only: true, + target_address: ADDRESS_B, + bytecode_address: NATIVE_COIN_CONTROL_ADDRESS, + }, ]; for tc in cases { for hardfork_flags in ArcHardforkFlags::all_combinations() { - // ZeroX hardforks are cumulative; Zero6 implies Zero5. - if hardfork_flags.is_active(ArcHardfork::Zero6) - && !hardfork_flags.is_active(ArcHardfork::Zero5) - { - continue; - } - - if tc.zero6_only && !hardfork_flags.is_active(ArcHardfork::Zero6) { - continue; - } - if tc.pre_zero6_only && hardfork_flags.is_active(ArcHardfork::Zero6) { + if tc.skip_for(hardfork_flags) { continue; } - - let tc_name = - tc.name.to_string() + &format!(" (hardfork_flags: {:?})", hardfork_flags); - - // Sanity check that we're not configuring test cases incorrectly - match tc.expected_result { - InstructionResult::Revert | InstructionResult::Return => {} - _ => { - assert!( - tc.return_data.is_none(), - "{tc_name}: expected no return data", - ); - } - } - - let mut ctx = mock_context(hardfork_flags); - - // Prepare inputs - let inputs = CallInputs { - scheme: CallScheme::Call, - target_address: tc.target_address, - bytecode_address: tc.bytecode_address, - known_bytecode: None, - caller: tc.caller, - value: CallValue::Transfer(U256::ZERO), - input: CallInput::Bytes(tc.calldata.clone()), - gas_limit: tc.gas_limit, - is_static: false, - return_memory_offset: 0..0, - }; - - let precompile_res = call_native_coin_control(&mut ctx, &inputs, hardfork_flags); - assert_precompile_result(precompile_res, tc, hardfork_flags, &tc_name); + run_native_coin_control_case(tc, hardfork_flags); } } } diff --git a/crates/precompiles/src/system_accounting.rs b/crates/precompiles/src/system_accounting.rs index fee3f24..0f0e0cd 100644 --- a/crates/precompiles/src/system_accounting.rs +++ b/crates/precompiles/src/system_accounting.rs @@ -173,6 +173,7 @@ precompile!(run_system_accounting, precompile_input, hardfork_flags; { SYSTEM_ACCOUNTING_ADDRESS, &precompile_input, &gas_counter, + hardfork_flags, )?; // Update storage @@ -631,31 +632,103 @@ mod tests { } } - #[test] - fn store_gas_values_table_tests() { - struct StoreCase { - name: &'static str, - caller: Address, - calldata: Bytes, - gas_limit: u64, - /// If set, overrides `gas_limit` when Zero6 is active. Needed when - /// the Zero6 early-revert penalty pushes required gas above the - /// Zero5 limit. - zero6_gas_limit: Option, - expected_result: InstructionResult, - expected_revert_str: Option<&'static str>, - return_data: Option, - gas_used: u64, - /// If set, overrides `gas_used` for pre-Zero5 hardforks (fixed - /// SSTORE cost vs. EIP-2929/EIP-2200 warm/cold pricing). - pre_zero5_gas_used: Option, - /// If set, overrides `gas_used` when Zero6 is active (auth reverts - /// charge `PRECOMPILE_EARLY_REVERT_GAS_PENALTY`). - zero6_gas_used: Option, - target_address: Address, - bytecode_address: Address, + struct StoreCase { + name: &'static str, + caller: Address, + calldata: Bytes, + gas_limit: u64, + /// If set, overrides `gas_limit` when Zero6 is active. Needed when + /// the Zero6 early-revert penalty pushes required gas above the + /// Zero5 limit. + zero6_gas_limit: Option, + expected_result: InstructionResult, + expected_revert_str: Option<&'static str>, + return_data: Option, + gas_used: u64, + /// If set, overrides `gas_used` for pre-Zero5 hardforks (fixed + /// SSTORE cost vs. EIP-2929/EIP-2200 warm/cold pricing). + pre_zero5_gas_used: Option, + /// If set, overrides `gas_used` when Zero6 is active (auth reverts + /// charge `PRECOMPILE_EARLY_REVERT_GAS_PENALTY`). + zero6_gas_used: Option, + target_address: Address, + bytecode_address: Address, + } + + /// Resolves the expected gas used for a `StoreCase` under the given hardfork flags. + fn expected_store_gas_used(tc: &StoreCase, hardfork_flags: ArcHardforkFlags) -> u64 { + let base = if hardfork_flags.is_active(ArcHardfork::Zero6) { + tc.zero6_gas_used.unwrap_or(tc.gas_used) + } else if hardfork_flags.is_active(ArcHardfork::Zero5) { + tc.gas_used + } else { + tc.pre_zero5_gas_used.unwrap_or(tc.gas_used) + }; + // Zero8: a delegatecall rejection charges the uniform early-revert penalty. + if hardfork_flags.is_active(ArcHardfork::Zero8) + && tc.expected_revert_str == Some(ERR_DELEGATE_CALL_NOT_ALLOWED) + { + base.saturating_add(PRECOMPILE_EARLY_REVERT_GAS_PENALTY) + } else { + base + } + } + + /// Runs one `StoreCase` under a single hardfork combination and asserts the outcome. + /// Non-cumulative combinations (Zero6 without Zero5) are skipped. + fn run_store_gas_values_case(tc: &StoreCase, hardfork_flags: ArcHardforkFlags) { + if hardfork_flags.is_active(ArcHardfork::Zero6) + && !hardfork_flags.is_active(ArcHardfork::Zero5) + { + return; + } + + let tc_name = format!("{} (hardfork_flags: {:?})", tc.name, hardfork_flags); + + let gas_limit = if hardfork_flags.is_active(ArcHardfork::Zero6) { + tc.zero6_gas_limit.unwrap_or(tc.gas_limit) + } else { + tc.gas_limit + }; + let expected_gas_used = expected_store_gas_used(tc, hardfork_flags); + + let mut ctx = Context::mainnet(); + ctx.journal_mut() + .load_account(SYSTEM_ACCOUNTING_ADDRESS) + .expect("Unable to load system accounting account"); + + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: tc.target_address, + bytecode_address: tc.bytecode_address, + known_bytecode: None, + caller: tc.caller, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(tc.calldata.clone()), + gas_limit, + is_static: false, + return_memory_offset: 0..0, + }; + + let res = call_system_accounting(&mut ctx, &inputs, hardfork_flags) + .unwrap() + .unwrap(); + assert_eq!(res.result, tc.expected_result, "{tc_name}"); + + if let Some(expected_revert_str) = tc.expected_revert_str { + let reason = bytes_to_revert_message(res.output.as_ref()).expect("revert reason"); + assert_eq!(reason, expected_revert_str, "{tc_name}"); + } + + if let Some(expected_return) = &tc.return_data { + assert_eq!(res.output, *expected_return, "{tc_name}"); } + assert_eq!(res.gas.used(), expected_gas_used, "{tc_name}"); + } + + #[test] + fn store_gas_values_table_tests() { let bn_ok = 1024u64; let val_ok = GasValues { gasUsed: 11, @@ -816,70 +889,77 @@ mod tests { for tc in cases { for hardfork_flags in ArcHardforkFlags::all_combinations() { - // ZeroX hardforks are cumulative; Zero6 implies Zero5. - if hardfork_flags.is_active(ArcHardfork::Zero6) - && !hardfork_flags.is_active(ArcHardfork::Zero5) - { - continue; - } - - let tc_name = format!("{} (hardfork_flags: {:?})", tc.name, hardfork_flags); - - let gas_limit = if hardfork_flags.is_active(ArcHardfork::Zero6) { - tc.zero6_gas_limit.unwrap_or(tc.gas_limit) - } else { - tc.gas_limit - }; - - let expected_gas_used = if hardfork_flags.is_active(ArcHardfork::Zero6) { - tc.zero6_gas_used.unwrap_or(tc.gas_used) - } else if hardfork_flags.is_active(ArcHardfork::Zero5) { - tc.gas_used - } else { - tc.pre_zero5_gas_used.unwrap_or(tc.gas_used) - }; - - let mut ctx = Context::mainnet(); - ctx.journal_mut() - .load_account(SYSTEM_ACCOUNTING_ADDRESS) - .expect("Unable to load system accounting account"); - - let inputs = CallInputs { - scheme: CallScheme::Call, - target_address: tc.target_address, - bytecode_address: tc.bytecode_address, - known_bytecode: None, - caller: tc.caller, - value: CallValue::Transfer(U256::ZERO), - input: CallInput::Bytes(tc.calldata.clone()), - gas_limit, - is_static: false, - return_memory_offset: 0..0, - }; - - let res = call_system_accounting(&mut ctx, &inputs, hardfork_flags) - .unwrap() - .unwrap(); - // Check result - assert_eq!(res.result, tc.expected_result, "{tc_name}"); - - // Revert string - if let Some(expected_revert_str) = tc.expected_revert_str { - let reason = - bytes_to_revert_message(res.output.as_ref()).expect("revert reason"); - assert_eq!(reason, expected_revert_str, "{tc_name}"); - } - - // Return data - if let Some(expected_return) = &tc.return_data { - assert_eq!(res.output, *expected_return, "{tc_name}"); - } - // Gas used - assert_eq!(res.gas.used(), expected_gas_used, "{tc_name}"); + run_store_gas_values_case(tc, hardfork_flags); } } } + /// A delegatecall into a stateful precompile is rejected by `check_delegatecall`. + /// Under Zero8 the rejection charges the uniform 200-gas early-revert penalty; + /// before Zero8 it reverts with no penalty. + #[test] + fn delegatecall_charges_early_revert_penalty_only_under_zero8() { + let calldata = ISystemAccounting::storeGasValuesCall { + blockNumber: 1, + gasValues: GasValues { + gasUsed: 1, + gasUsedSmoothed: 2, + nextBaseFee: 3, + }, + } + .abi_encode(); + + let gas_used_for = |hardfork_flags: ArcHardforkFlags| -> u64 { + let mut ctx = Context::mainnet(); + ctx.journal_mut() + .load_account(SYSTEM_ACCOUNTING_ADDRESS) + .expect("load system accounting account"); + let inputs = CallInputs { + scheme: CallScheme::Call, + target_address: SYSTEM_ACCOUNTING_ADDRESS, + // bytecode_address != precompile address => delegatecall shape. + bytecode_address: address!("0x0000000000000000000000000000000000000123"), + known_bytecode: None, + caller: ARC_SYSTEM_CALLER, + value: CallValue::Transfer(U256::ZERO), + input: CallInput::Bytes(calldata.clone().into()), + gas_limit: 1_000_000, + is_static: false, + return_memory_offset: 0..0, + }; + let res = call_system_accounting(&mut ctx, &inputs, hardfork_flags) + .unwrap() + .unwrap(); + assert_eq!(res.result, InstructionResult::Revert); + assert_eq!( + bytes_to_revert_message(res.output.as_ref()).expect("revert reason"), + ERR_DELEGATE_CALL_NOT_ALLOWED, + ); + res.gas.used() + }; + + let pre_zero8 = gas_used_for(ArcHardforkFlags::with(&[ + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ArcHardfork::Zero7, + ])); + let zero8 = gas_used_for(ArcHardforkFlags::with(&[ + ArcHardfork::Zero5, + ArcHardfork::Zero6, + ArcHardfork::Zero7, + ArcHardfork::Zero8, + ])); + + assert_eq!( + pre_zero8, 0, + "pre-Zero8 delegatecall rejection charges no penalty" + ); + assert_eq!( + zero8, PRECOMPILE_EARLY_REVERT_GAS_PENALTY, + "Zero8 delegatecall rejection charges the 200-gas early-revert penalty", + ); + } + #[test] fn read_write_workflow() { let mut ctx = Context::mainnet(); diff --git a/crates/quake/scenarios/nightly-upgrade.toml b/crates/quake/scenarios/nightly-upgrade.toml index 3d60aae..c375e55 100644 --- a/crates/quake/scenarios/nightly-upgrade.toml +++ b/crates/quake/scenarios/nightly-upgrade.toml @@ -10,7 +10,7 @@ image_cl="${IMAGE_REGISTRY_URL}/arc-consensus:latest" image_el="${IMAGE_REGISTRY_URL}/arc-execution:latest" # Set the execution layer initial hardfork to the highest version supported by the image. -el_init_hardfork="zero4" +el_init_hardfork="zero7" # Upgrade versions (what we upgrade to during the test) image_cl_upgrade="arc_consensus:latest" diff --git a/crates/quake/templates/local/compose.yaml.hbs b/crates/quake/templates/local/compose.yaml.hbs index d94e7eb..9351ae7 100644 --- a/crates/quake/templates/local/compose.yaml.hbs +++ b/crates/quake/templates/local/compose.yaml.hbs @@ -321,7 +321,7 @@ services: environment: <<: *aws-base APP_ENV: ${APP_ENV:-dev} - APP_PROVIDER_ENCLAVE_CLIENT_BASEURL: http://{{name}}-signer-enclave:10350 + APP_PROVIDER_ENCLAVE_CLIENT_BASEURL: {{name}}-signer-enclave:10350 APP_PROVIDER_SECRETS_LOCALSTACK_ENABLED: false APP_SERVICE_SIGNER_KEYID: ${APP_SERVICE_SIGNER{{inc @index}}_KEYID:-dev/arc/remote-signer/{{remote_signer}}} APP_PROVIDER_AWSKMS_ARNS: ${APP_PROVIDER_AWSKMS_ARNS} @@ -356,7 +356,7 @@ services: memlock: -1 entrypoint: ["/usr/local/circle/run_enclave.dev.sh"] healthcheck: - test: ["CMD-SHELL", "curl -f --insecure http://localhost:10350/ping"] + test: ["CMD", "grpc_health_probe", "-addr=localhost:10350"] <<: *health-retry {{/if}} diff --git a/crates/types/src/commit_http.rs b/crates/types/src/commit_http.rs index 7d4d6fe..dd65b36 100644 --- a/crates/types/src/commit_http.rs +++ b/crates/types/src/commit_http.rs @@ -148,4 +148,31 @@ mod tests { let back: HttpCommitSignature = serde_json::from_str(&json).unwrap(); assert_eq!(http, back); } + + #[test] + fn http_commit_signature_serializes_as_base64_wire_format() { + let mut b = [0u8; 64]; + b[0] = 1; + b[63] = 2; + let http = HttpCommitSignature::from(CommitSignature { + address: Address::default(), + signature: Signature::from_bytes(b), + }); + + let value: serde_json::Value = + serde_json::from_str(&serde_json::to_string(&http).unwrap()).unwrap(); + let wire = value["signature"] + .as_str() + .expect("signature must serialize as a JSON string"); + + // Standard padded Base64 of the 64-byte signature, not hex. + let expected_b64 = format!("AQAA{}Ag==", "AAAA".repeat(20)); + assert_eq!( + wire, expected_b64, + "signature must be standard padded Base64" + ); + + let hex = format!("01{}02", "00".repeat(62)); + assert_ne!(wire, hex, "signature must not be hex-encoded"); + } } diff --git a/deployments/monitoring/config-blockscout/frontend/.curlrc b/deployments/monitoring/config-blockscout/frontend/.curlrc index 5bcdf03..5688b97 100644 --- a/deployments/monitoring/config-blockscout/frontend/.curlrc +++ b/deployments/monitoring/config-blockscout/frontend/.curlrc @@ -1,2 +1,2 @@ -# Disable TLS Verification for asset download; needed due to Zscaler +# Disable TLS certificate verification for asset download (needed behind TLS-inspecting proxies) insecure \ No newline at end of file diff --git a/docs/adr/0004-base-fee-validation.md b/docs/adr/0004-base-fee-validation.md index 7438c50..3be5202 100644 --- a/docs/adr/0004-base-fee-validation.md +++ b/docs/adr/0004-base-fee-validation.md @@ -5,23 +5,23 @@ | Status | Draft | | Author(s) | @asoghoian | | Created | 2026-03-03 | -| Updated | 2026-05-12 | +| Updated | 2026-06-08 | | Supersedes | - | | Superseded by | - | ## Context -On Arc, the base fee calculation combines: 1) an EIP-1559-style algorithm, 2) several at-runtime-governance-tunable values, and 3) an exponentially-smoothed view of historical gas use. +On Arc, the base fee calculation combines: 1) an EIP-1559-style algorithm, 2) several at-runtime-governance-tunable values, and 3) an exponentially-smoothed view of historical gas use. The value of 1) is that it is familiar and well-understood. The value of 2) is to enable rapid adjusting of fee parameters and strictly enforcing block gas limits through governance. The value of 3) is to dampen fee increases against sudden shocks in usage with Arc's fast blocktimes. -The combination of these makes it challenging for integrators to precisely understand the next block base fee as the calculation is more involved. To address this, Arc includes the next block base fee in the header, computed at the end of the parent's block execution, since it is fully known at that point. +The combination of these makes it challenging for integrators to precisely understand the next block base fee as the calculation is more involved. To address this, Arc includes the next block base fee in the header, computed at the end of the parent's block execution, since it is fully known at that point. Currently, the implementation has several gaps in validation. This ADR, similar to ADR-003, seeks to document solutions to these gaps. ### Gap 1: No validation of the base fee computation -There is currently no validation of the base fee calculation, just a check that the parent's header contains the next block's base fee. This is clearly insufficient -- a proposer could miscalculate a base fee, store it in the block header, and force the next proposer to use it. +There is currently no validation of the base fee calculation, just a check that the parent's header contains the next block's base fee. This is clearly insufficient -- a proposer could miscalculate a base fee, store it in the block header, and force the next proposer to use it. ### Gap 2: The current fallback behavior is complex for integrators @@ -31,7 +31,7 @@ This adds complexity as: 1) an integrator may not know how to "derive" the base ## Decision -Always include the next block base fee, use a consistent algorithm, and apply strict validation. +Always include the next block base fee, use a consistent algorithm, and apply strict validation. ### Chainspec Updates @@ -41,8 +41,8 @@ Two new types are attached to the chainspec via `base_fee_config(block_height)`: /// Bounds on the three calculation parameters from ProtocolConfig. struct BaseFeeCalcParams { alpha: u64, // EMA smoothing factor [0, 100] - k_rate: u64, // Max change rate in basis points (200 = 2%) - elasticity_multiplier: u64, // Target gas ratio in basis points (5000 = 50%) + k_rate: u64, // Base fee adjustment coefficient, in basis points (200 = 2%) + inverse_elasticity_multiplier: u64, // Target gas ratio in basis points (5000 = 50%) } /// Complete base fee configuration for a network. @@ -72,7 +72,7 @@ The two absolute bounds control the *output*: after computation and after the Pr | testnet | 1 | 20 | 100 | | mainnet | 1 | 20 | 100 | -**k_rate** — max base fee change rate per block (basis points). 200 = 2%. +**k_rate** — Base fee adjustment coefficient per block (basis points). 200 = 2%. | Network | min | default | max | |---------|-----|---------|-----| @@ -81,7 +81,7 @@ The two absolute bounds control the *output*: after computation and after the Pr | testnet | 1 | 200 | 1000 | | mainnet | 1 | 200 | 1000 | -**elasticity_multiplier** — target gas utilization (basis points). 5000 = 50%. +**inverse_elasticity_multiplier** — target gas utilization (basis points). 5000 = 50%. | Network | min | default | max | |---------|-----|---------|-----| @@ -119,14 +119,14 @@ else: calc = BaseFeeCalcParams( alpha = validated(fee_params.alpha, alpha), k_rate = validated(fee_params.kRate, k_rate), - elasticity_multiplier = validated(fee_params.elasticityMultiplier, elasticity_multiplier), - ) + inverse_elasticity_multiplier = validated(fee_params.inverseElasticityMultiplier, inverse_elasticity_multiplier), + ) # 1. Compute smoothed gas smoothed_gas = ema(parent_smoothed_gas, block_gas_used, calc.alpha) # 2. Compute next base fee -next_base_fee = arc_calc_next_block_base_fee(smoothed_gas, gas_limit, base_fee, calc.k_rate, calc.elasticity_multiplier) +next_base_fee = arc_calc_next_block_base_fee(smoothed_gas, gas_limit, base_fee, calc.k_rate, calc.inverse_elasticity_multiplier) # 3. Apply ProtocolConfig's own minBaseFee/maxBaseFee clamp (if available) if fee_params is not None: @@ -148,13 +148,8 @@ The assembler always writes `next_base_fee` from SystemAccounting to `extra_data ```python config = chainspec.base_fee_config(block_height) -# 1. Absolute bounds check +# Absolute bounds check assert config.absolute_min_base_fee <= header.base_fee <= config.absolute_max_base_fee - -# 2. Rate check (proportional, using max allowed k_rate) -if parent.number > 0: - max_delta = parent.base_fee * config.params_max.k_rate / 10000 - assert abs(header.base_fee - parent.base_fee) <= max_delta ``` **Stateful** (after block execution): diff --git a/scripts/create-release-tag.sh b/scripts/create-release-tag.sh new file mode 100755 index 0000000..e459cff --- /dev/null +++ b/scripts/create-release-tag.sh @@ -0,0 +1,670 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + create-release-tag.sh --source --release-kind patch|minor|major [--as-release-candidate] [--release-ref-prefix test-] [--base-branch main] + create-release-tag.sh --tag [--release-ref-prefix test-] [--base-branch main] + +Environment: + PUSH_TAG=false Create the tag locally but do not push it to origin. + RELEASE_REF_PREFIX + Namespace prefix used to derive release branches and tags. + Empty derives release/ and v. test- derives test-release/ + and test-v. + TAG_PREFIX Internal release tag prefix derived from RELEASE_REF_PREFIX. + RELEASE_BRANCH_PREFIX + Internal release branch prefix derived from RELEASE_REF_PREFIX. + BASE_BRANCH=main + Base branch used for minor and major releases. + MAIN_BRANCH Deprecated alias for BASE_BRANCH. + ALLOW_STALE_RELEASE_REFS=true + Continue if fetching origin refs fails. Intended only for + local/offline testing. +USAGE +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/release-config.sh +source "${SCRIPT_DIR}/release-config.sh" + +SOURCE_REF="latest" +RELEASE_KIND="" +TAG="" +AS_RELEASE_CANDIDATE=false +PUSH_TAG="${PUSH_TAG:-true}" +CREATED_NEW_TAG=false +CREATED_RELEASE_BRANCH=false +RESOLVED_SOURCE_REF="" +SOURCE_BRANCH="" +RELEASE_REF_PREFIX="${RELEASE_REF_PREFIX:-}" +BASE_BRANCH="${BASE_BRANCH:-${MAIN_BRANCH:-main}}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --source) SOURCE_REF="${2:?missing source ref}"; shift 2 ;; + --release-kind) RELEASE_KIND="${2:?missing release kind}"; shift 2 ;; + --tag) TAG="${2:?missing tag}"; shift 2 ;; + --as-release-candidate) AS_RELEASE_CANDIDATE=true; shift ;; + --release-ref-prefix) RELEASE_REF_PREFIX="${2-}"; shift 2 ;; + --base-branch) BASE_BRANCH="${2:?missing base branch}"; shift 2 ;; + --main-branch) BASE_BRANCH="${2:?missing main branch}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage; exit 1 ;; + esac +done + +if [[ -n "${RELEASE_KIND}" && -n "${TAG}" ]]; then + echo "Set either --release-kind or tag, not both" >&2 + exit 1 +fi + +if [[ -z "${RELEASE_KIND}" && -z "${TAG}" ]]; then + echo "Set release_kind or tag" >&2 + exit 1 +fi + +validate_namespace_ref() { + local label="$1" + local ref="$2" + + if ! git check-ref-format "${ref}" >/dev/null 2>&1; then + echo "Invalid ${label}: ${ref}" >&2 + exit 1 + fi +} + +TAG_PREFIX="$(release_tag_prefix_from_ref_prefix "${RELEASE_REF_PREFIX}")" +RELEASE_BRANCH_PREFIX="$(release_branch_prefix_from_ref_prefix "${RELEASE_REF_PREFIX}")" +BASE_BRANCH="${BASE_BRANCH:-main}" + +if [[ -z "${TAG_PREFIX}" ]]; then + echo "tag prefix cannot be empty" >&2 + exit 1 +fi +if [[ -z "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "release branch prefix cannot be empty" >&2 + exit 1 +fi +if [[ -z "${BASE_BRANCH}" ]]; then + echo "base branch cannot be empty" >&2 + exit 1 +fi +validate_namespace_ref "tag prefix" "refs/tags/${TAG_PREFIX}0.0.0" +validate_namespace_ref "release branch prefix" "refs/heads/${RELEASE_BRANCH_PREFIX}0.0" +validate_namespace_ref "base branch" "refs/heads/${BASE_BRANCH}" + +if ! git fetch origin '+refs/heads/*:refs/remotes/origin/*' --tags --prune >/dev/null 2>&1; then + if [[ "${ALLOW_STALE_RELEASE_REFS:-false}" != true ]]; then + echo "Unable to fetch origin refs; refusing to calculate a release from stale local state" >&2 + exit 1 + fi + echo "Warning: unable to fetch origin; continuing with local refs because ALLOW_STALE_RELEASE_REFS=true." >&2 +fi + +tag_version() { + local tag="$1" + local prefix="${2:-${TAG_PREFIX}}" + if [[ "${tag:0:${#prefix}}" != "${prefix}" ]]; then + return 1 + fi + printf '%s\n' "${tag:${#prefix}}" +} + +validate_tag() { + local tag="$1" + local version + + version="$(tag_version "${tag}")" || return 1 + [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?$ ]] +} + +format_final_tag() { + printf '%s%s.%s.%s\n' "${TAG_PREFIX}" "$1" "$2" "$3" +} + +format_final_tag_with_prefix() { + printf '%s%s.%s.%s\n' "$1" "$2" "$3" "$4" +} + +format_rc_tag() { + printf '%s%s.%s.%s-rc%s\n' "${TAG_PREFIX}" "$1" "$2" "$3" "$4" +} + +release_line_from_branch() { + local branch="$1" + branch="${branch#origin/}" + if [[ "${branch:0:${#RELEASE_BRANCH_PREFIX}}" != "${RELEASE_BRANCH_PREFIX}" ]]; then + return 1 + fi + + local line="${branch:${#RELEASE_BRANCH_PREFIX}}" + [[ "${line}" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+|x))?$ ]] +} + +tag_points_to_ref() { + local tag="$1" + local ref="$2" + + if ! git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then + return 1 + fi + + local tag_sha ref_sha + tag_sha="$(git rev-list -n1 "${tag}")" + ref_sha="$(git rev-parse "${ref}^{commit}")" + [[ "${tag_sha}" == "${ref_sha}" ]] +} + +configure_tag_identity() { + if [[ "${GITHUB_ACTIONS:-}" != "true" ]]; then + return + fi + + if ! git config user.name >/dev/null; then + git config user.name "github-actions[bot]" + fi + if ! git config user.email >/dev/null; then + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + fi +} + +latest_release_branch() { + local branches latest + branches="$( + while IFS= read -r branch; do + branch="${branch#origin/}" + if release_line_from_branch "${branch}"; then + printf '%s\t%s\n' "${branch:${#RELEASE_BRANCH_PREFIX}}" "${branch}" + fi + done < <(git for-each-ref --format='%(refname:short)' refs/heads refs/remotes/origin 2>/dev/null) | + sort -k1,1V | + awk -F'\t' '!seen[$2]++ {print $2}' || true + )" + latest="$(printf '%s\n' "${branches}" | tail -n1)" + if [[ -z "${latest}" ]]; then + echo "No release branch found matching ${RELEASE_BRANCH_PREFIX}X.Y, ${RELEASE_BRANCH_PREFIX}X.Y.Z, or ${RELEASE_BRANCH_PREFIX}X.Y.x" >&2 + exit 1 + fi + echo "${latest}" +} + +resolve_ref() { + local ref="$1" + local kind="$2" + if [[ "${ref}" == "latest" ]]; then + if [[ "${kind}" == "patch" ]]; then + ref="$(latest_release_branch)" + echo "Resolved latest release branch to ${ref}" >&2 + else + ref="${BASE_BRANCH}" + echo "Resolved latest source branch to ${ref} for ${kind} release" >&2 + fi + fi + if git rev-parse --verify --quiet "${ref}^{commit}" >/dev/null; then + echo "${ref}" + elif git rev-parse --verify --quiet "origin/${ref}^{commit}" >/dev/null; then + echo "origin/${ref}" + else + echo "Cannot resolve ref: ${ref}" >&2 + exit 1 + fi +} + +normalize_branch_name() { + local ref="$1" + ref="${ref#origin/}" + if [[ "${ref}" == "${BASE_BRANCH}" || "${ref:0:${#RELEASE_BRANCH_PREFIX}}" == "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "${ref}" + return + fi + + local containing + containing="$( + while IFS= read -r branch; do + branch="${branch#origin/}" + if [[ "${branch}" == "${BASE_BRANCH}" || "${branch:0:${#RELEASE_BRANCH_PREFIX}}" == "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "${branch}" + break + fi + done < <(git branch -r --contains "${ref}" --format='%(refname:short)' 2>/dev/null) || true + )" + if [[ -z "${containing}" ]]; then + containing="$( + while IFS= read -r branch; do + if [[ "${branch}" == "${BASE_BRANCH}" || "${branch:0:${#RELEASE_BRANCH_PREFIX}}" == "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "${branch}" + break + fi + done < <(git branch --contains "${ref}" --format='%(refname:short)' 2>/dev/null) || true + )" + fi + echo "${containing}" +} + +latest_global_tag() { + local prefix="${1:-${TAG_PREFIX}}" + local tag version final_tag + + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + + version="$(tag_version "${tag}" "${prefix}")" || continue + if [[ "${version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-rc[0-9]+$ ]]; then + final_tag="$(format_final_tag_with_prefix "${prefix}" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}")" + if git rev-parse --verify --quiet "refs/tags/${final_tag}" >/dev/null; then + continue + fi + fi + + echo "${tag}" + return + done < <(git tag --list "${prefix}[0-9]*" --sort=-v:refname) +} + +latest_tag() { + local source="$1" + local tag_filter="$2" + local prefix="${3:-${TAG_PREFIX}}" + local tag version final_tag final_sha + + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + + version="$(tag_version "${tag}" "${prefix}")" || continue + if [[ "${version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-rc[0-9]+$ ]]; then + final_tag="$(format_final_tag_with_prefix "${prefix}" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}")" + if git rev-parse --verify --quiet "refs/tags/${final_tag}" >/dev/null; then + final_sha="$(git rev-list -n1 "${final_tag}")" + if git merge-base --is-ancestor "${final_sha}" "${source}"; then + continue + fi + fi + fi + + echo "${tag}" + return + done < <(git tag --merged "${source}" --list "${tag_filter}" --sort=-v:refname) +} + +latest_matching_rc_tag() { + local source="$1" + local final_tag="$2" + + git tag --merged "${source}" --list "${final_tag}-rc[0-9]*" --sort=-v:refname | head -n1 +} + +latest_final_tag_for_candidate() { + local candidate_tag="$1" + local candidate_version major minor tag tag_filter + + candidate_version="$(tag_version "${candidate_tag}")" || return + if [[ ! "${candidate_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-rc[0-9]+)?$ ]]; then + return + fi + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + + tag_filter="${TAG_PREFIX}[0-9]*" + if [[ "${RELEASE_KIND}" == "patch" ]]; then + tag_filter="${TAG_PREFIX}${major}.${minor}.*" + fi + + if tag="$(latest_final_tag_for_prefix "${candidate_tag}" "${tag_filter}" "${TAG_PREFIX}")"; then + echo "${tag}" + return + fi + +} + +latest_final_tag_for_prefix() { + local candidate_tag="$1" + local tag_filter="$2" + local prefix="$3" + local tag version + + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + [[ -n "${candidate_tag}" && "${tag}" == "${candidate_tag}" ]] && continue + + version="$(tag_version "${tag}" "${prefix}")" || continue + if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "${tag}" + return + fi + done < <(git tag --list "${tag_filter}" --sort=-v:refname) + + return 1 +} + +ensure_candidate_has_diff_from_previous_final() { + local candidate_tag="$1" + local target_sha="$2" + local previous_final_tag + + previous_final_tag="$(latest_final_tag_for_candidate "${candidate_tag}")" + if [[ -z "${previous_final_tag}" ]]; then + return + fi + + if git diff --quiet --no-ext-diff "${previous_final_tag}" "${target_sha}" -- .; then + echo "No changes since ${previous_final_tag}; refusing to create ${candidate_tag} at ${target_sha}" >&2 + exit 1 + fi +} + +continues_rc_line() { + local kind="$1" + local minor="$2" + local patch="$3" + + case "${kind}" in + patch) return 0 ;; + minor) [[ "${patch}" == 0 ]] ;; + major) [[ "${minor}" == 0 && "${patch}" == 0 ]] ;; + *) echo "Invalid release kind: ${kind}" >&2; exit 1 ;; + esac +} + +next_tag() { + local kind="$1" + local source="$2" + local latest latest_version major minor patch rc latest_is_rc tag_filter branch_name base_tag latest_uses_release_prefix + + tag_filter="${TAG_PREFIX}[0-9]*" + branch_name="$(normalize_branch_name "${source}")" + if release_line_from_branch "${branch_name}"; then + tag_filter="${TAG_PREFIX}${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.*" + fi + + if [[ "${kind}" == "patch" ]]; then + latest="$(latest_tag "${source}" "${tag_filter}")" + else + latest="$(latest_global_tag)" + fi + base_tag="$(format_final_tag 0 0 0)" + latest="${latest:-${base_tag}}" + latest_version="$(tag_version "${latest}")" + latest_uses_release_prefix=false + if tag_version "${latest}" "${TAG_PREFIX}" >/dev/null 2>&1; then + latest_uses_release_prefix=true + fi + + latest_is_rc=false + rc=0 + if [[ "${latest_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-rc([0-9]+)$ ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + rc="${BASH_REMATCH[4]}" + latest_is_rc=true + elif [[ "${latest_version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + else + echo "Latest tag has unsupported format: ${latest}" >&2 + exit 1 + fi + + if [[ "${AS_RELEASE_CANDIDATE}" == true ]]; then + if [[ "${latest_uses_release_prefix}" == true && "${latest_is_rc}" == true ]] && continues_rc_line "${kind}" "${minor}" "${patch}"; then + if tag_points_to_ref "${latest}" "${source}"; then + echo "Reusing ${latest}; source commit is already tagged" >&2 + printf '%s\n' "${latest}" + return + fi + + format_rc_tag "${major}" "${minor}" "${patch}" "$((rc + 1))" + return + fi + + case "${kind}" in + patch) patch=$((patch + 1)) ;; + minor) minor=$((minor + 1)); patch=0 ;; + major) major=$((major + 1)); minor=0; patch=0 ;; + *) echo "Invalid release kind: ${kind}" >&2; exit 1 ;; + esac + format_rc_tag "${major}" "${minor}" "${patch}" 1 + return + fi + + if [[ "${latest_uses_release_prefix}" == true && "${kind}" == "patch" && "${latest}" != "${base_tag}" && "${latest_is_rc}" == false ]] && tag_points_to_ref "${latest}" "${source}"; then + echo "Reusing ${latest}; source commit is already tagged" >&2 + printf '%s\n' "${latest}" + return + fi + + if [[ "${latest_uses_release_prefix}" == true && "${latest_is_rc}" == true ]] && continues_rc_line "${kind}" "${minor}" "${patch}"; then + echo "Promoting ${latest} to $(format_final_tag "${major}" "${minor}" "${patch}")" >&2 + format_final_tag "${major}" "${minor}" "${patch}" + return + fi + + case "${kind}" in + patch) patch=$((patch + 1)) ;; + minor) minor=$((minor + 1)); patch=0 ;; + major) major=$((major + 1)); minor=0; patch=0 ;; + *) echo "Invalid release kind: ${kind}" >&2; exit 1 ;; + esac + + format_final_tag "${major}" "${minor}" "${patch}" +} + +next_available_rc_tag() { + local tag="$1" + local version major minor patch rc + + while git rev-parse --verify "refs/tags/${tag}" >/dev/null 2>&1; do + version="$(tag_version "${tag}")" || { + echo "Tag already exists: ${tag}" >&2 + exit 1 + } + if [[ ! "${version}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-rc([0-9]+)$ ]]; then + echo "Tag already exists: ${tag}" >&2 + exit 1 + fi + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + rc="${BASH_REMATCH[4]}" + tag="$(format_rc_tag "${major}" "${minor}" "${patch}" "$((rc + 1))")" + done + + echo "${tag}" +} + +ensure_next_line_release_branch() { + local release_branch="$1" + local target_sha="$2" + + if [[ -z "${RELEASE_KIND}" || "${RELEASE_KIND}" == "patch" ]]; then + return + fi + if [[ -z "${release_branch}" || "${SOURCE_BRANCH}" == "${release_branch}" ]]; then + return + fi + + if git rev-parse --verify --quiet "refs/remotes/origin/${release_branch}^{commit}" >/dev/null; then + if git merge-base --is-ancestor "${target_sha}" "origin/${release_branch}"; then + echo "Release branch already exists for ${TAG}: ${release_branch}" >&2 + return + fi + + echo "Release branch ${release_branch} exists but does not contain ${TAG}@${target_sha}" >&2 + exit 1 + fi + + if git rev-parse --verify --quiet "refs/heads/${release_branch}^{commit}" >/dev/null; then + if ! git merge-base --is-ancestor "${target_sha}" "${release_branch}"; then + echo "Local release branch ${release_branch} exists but does not contain ${TAG}@${target_sha}" >&2 + exit 1 + fi + else + git branch "${release_branch}" "${target_sha}" + fi + + CREATED_RELEASE_BRANCH=true +} + +ref_is_contained_by_release_branch() { + local ref="$1" + local branch + + while IFS= read -r branch; do + branch="${branch#origin/}" + if release_line_from_branch "${branch}"; then + return 0 + fi + done < <(git branch -r --contains "${ref}" --format='%(refname:short)' 2>/dev/null) + + while IFS= read -r branch; do + if release_line_from_branch "${branch}"; then + return 0 + fi + done < <(git branch --contains "${ref}" --format='%(refname:short)' 2>/dev/null) + + return 1 +} + +validate_release_source() { + local kind="$1" + local source_arg="$2" + local resolved_source_ref="$3" + local source_branch="$4" + local source_arg_branch="${source_arg#origin/}" + + if [[ "${kind}" == "patch" ]]; then + if [[ "${source_arg_branch}" == "${BASE_BRANCH}" ]]; then + echo "patch releases must start from ${RELEASE_BRANCH_PREFIX}X.Y or an explicit commit contained by a ${RELEASE_BRANCH_PREFIX}X.Y branch, not ${source_arg}" >&2 + exit 1 + fi + + if release_line_from_branch "${source_branch}" || + release_line_from_branch "${source_arg_branch}" || + ref_is_contained_by_release_branch "${resolved_source_ref}"; then + return + fi + + echo "patch releases must start from ${RELEASE_BRANCH_PREFIX}X.Y or an explicit commit contained by a ${RELEASE_BRANCH_PREFIX}X.Y branch, not ${source_arg}" >&2 + exit 1 + fi + + if release_line_from_branch "${source_branch}"; then + echo "${kind} releases must start from ${BASE_BRANCH} or an explicit ${BASE_BRANCH} commit, not ${source_branch}" >&2 + exit 1 + fi + + if [[ "${source_arg_branch:0:${#RELEASE_BRANCH_PREFIX}}" == "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "${kind} releases must not use release branch source_ref=${source_arg}" >&2 + exit 1 + fi +} + +push_created_refs() { + local refs=() + + if [[ "${PUSH_TAG}" != true ]]; then + return + fi + + if [[ "${CREATED_NEW_TAG}" == true ]]; then + refs+=("refs/tags/${TAG}") + fi + if [[ "${CREATED_RELEASE_BRANCH}" == true ]]; then + refs+=("refs/heads/${RELEASE_BRANCH}") + fi + + if [[ ${#refs[@]} -gt 0 ]]; then + git push origin "${refs[@]}" + fi +} + +if [[ -n "${RELEASE_KIND}" ]]; then + RESOLVED_SOURCE_REF="$(resolve_ref "${SOURCE_REF}" "${RELEASE_KIND}")" + SOURCE_BRANCH="$(normalize_branch_name "${RESOLVED_SOURCE_REF}")" + validate_release_source "${RELEASE_KIND}" "${SOURCE_REF}" "${RESOLVED_SOURCE_REF}" "${SOURCE_BRANCH}" + TAG="$(next_tag "${RELEASE_KIND}" "${RESOLVED_SOURCE_REF}")" + TAG_TARGET_REF="${RESOLVED_SOURCE_REF}" + if [[ "${AS_RELEASE_CANDIDATE}" == true ]]; then + if ! tag_points_to_ref "${TAG}" "${RESOLVED_SOURCE_REF}"; then + TAG="$(next_available_rc_tag "${TAG}")" + fi + TAG_TARGET_REF="${RESOLVED_SOURCE_REF}" + fi + validate_tag "${TAG}" || { echo "Generated invalid tag: ${TAG}" >&2; exit 1; } + if git rev-parse --verify "refs/tags/${TAG}" >/dev/null 2>&1; then + TAG_TARGET_SHA="$(git rev-parse "${TAG_TARGET_REF}^{commit}")" + ensure_candidate_has_diff_from_previous_final "${TAG}" "${TAG_TARGET_SHA}" + if tag_points_to_ref "${TAG}" "${TAG_TARGET_REF}"; then + echo "Tag already exists at target commit: ${TAG}" >&2 + else + echo "Tag already exists: ${TAG}" >&2 + exit 1 + fi + else + TAG_VERSION="$(tag_version "${TAG}")" + if [[ "${AS_RELEASE_CANDIDATE}" != true && "${TAG_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + PROMOTED_RC_TAG="$(latest_matching_rc_tag "${RESOLVED_SOURCE_REF}" "${TAG}")" + if [[ -n "${PROMOTED_RC_TAG}" ]]; then + TAG_TARGET_REF="${PROMOTED_RC_TAG}" + fi + fi + configure_tag_identity + TAG_TARGET_SHA="$(git rev-parse "${TAG_TARGET_REF}^{commit}")" + ensure_candidate_has_diff_from_previous_final "${TAG}" "${TAG_TARGET_SHA}" + git -c tag.gpgSign=false tag -a "${TAG}" -m "Release ${TAG}" "${TAG_TARGET_SHA}" + CREATED_NEW_TAG=true + fi +else + validate_tag "${TAG}" || { echo "Invalid tag: ${TAG}" >&2; exit 1; } + if ! git rev-parse --verify "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Explicit tag does not exist: ${TAG}" >&2 + exit 1 + fi + RESOLVED_SOURCE_REF="${TAG}" + SOURCE_BRANCH="$(normalize_branch_name "${TAG}")" +fi + +SHA="$(git rev-list -n1 "${TAG}")" +SHORT_SHA="$(git rev-parse --short=8 "${SHA}")" +VERSION="$(tag_version "${TAG}")" +IS_RELEASE_CANDIDATE=false +RELEASE_BRANCH="" +if [[ "${TAG}" =~ -rc[0-9]+$ ]]; then + IS_RELEASE_CANDIDATE=true +fi +if [[ "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.[0-9]+(-rc[0-9]+)?$ ]]; then + RELEASE_BRANCH="${RELEASE_BRANCH_PREFIX}${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" +fi + +if [[ -n "${RELEASE_KIND}" ]]; then + ensure_next_line_release_branch "${RELEASE_BRANCH}" "${SHA}" + push_created_refs +fi + +CHANNEL="preview" +if git merge-base --is-ancestor "${SHA}" "origin/${BASE_BRANCH}" 2>/dev/null || git merge-base --is-ancestor "${SHA}" "${BASE_BRANCH}" 2>/dev/null; then + CHANNEL="latest" +elif git branch -r --contains "${SHA}" --format='%(refname:short)' 2>/dev/null | grep -qF "origin/${RELEASE_BRANCH_PREFIX}"; then + CHANNEL="release" +elif git branch --contains "${SHA}" --format='%(refname:short)' 2>/dev/null | grep -qF "${RELEASE_BRANCH_PREFIX}"; then + CHANNEL="release" +fi + +{ + echo "tag=${TAG}" + echo "sha=${SHA}" + echo "short_sha=${SHORT_SHA}" + echo "version=${VERSION}" + echo "is_release_candidate=${IS_RELEASE_CANDIDATE}" + echo "channel=${CHANNEL}" + echo "created_new_tag=${CREATED_NEW_TAG}" + echo "created_release_branch=${CREATED_RELEASE_BRANCH}" + echo "source_ref=${RESOLVED_SOURCE_REF}" + echo "source_branch=${SOURCE_BRANCH}" + echo "release_branch=${RELEASE_BRANCH}" +} > release.env + +cat release.env diff --git a/scripts/genesis/genesis.ts b/scripts/genesis/genesis.ts index 2bb3cfc..34cf3aa 100644 --- a/scripts/genesis/genesis.ts +++ b/scripts/genesis/genesis.ts @@ -92,6 +92,7 @@ export const schemaGenesisConfig = z zero5Block: z.number().optional(), zero6Block: z.number().optional(), zero7Time: z.number().optional(), + zero8Time: z.number().optional(), osakaTime: z.number().optional(), }) .optional(), @@ -127,7 +128,7 @@ export const schemaGenesisConfig = z export type GenesisConfig = z.infer // Defines hardfork name, this is used for genesis builder command line arguments. -export const hardforkNameSchema = z.enum(['zero3', 'zero4', 'zero5', 'zero6', 'zero7']) +export const hardforkNameSchema = z.enum(['zero3', 'zero4', 'zero5', 'zero6', 'zero7', 'zero8']) // Defines the mapping from hardfork name to genesis hardforks initialize setting. export function initialHardforksByName(hardforkName: z.infer): GenesisConfig['hardforks'] { @@ -137,6 +138,15 @@ export function initialHardforksByName(hardforkName: z.infer 0 ? { - validatorNames: parsedValidatorNames, - outputControllersConfig: outputPathWithSuffix('controllers-config'), - } + validatorNames: parsedValidatorNames, + outputControllersConfig: outputPathWithSuffix('controllers-config'), + } : {}), ...(parsedVotingPowers && parsedVotingPowers.length > 0 ? { votingPowers: parsedVotingPowers } : {}), outputGenesisConfig: outputPathWithSuffix('config'), diff --git a/scripts/release-config.sh b/scripts/release-config.sh index 5a5a053..3498ca1 100644 --- a/scripts/release-config.sh +++ b/scripts/release-config.sh @@ -1,17 +1,29 @@ #!/usr/bin/env bash +release_tag_prefix_from_ref_prefix() { + local ref_prefix="${1:-}" + + printf '%sv\n' "${ref_prefix}" +} + +release_branch_prefix_from_ref_prefix() { + local ref_prefix="${1:-}" + + printf '%srelease/\n' "${ref_prefix}" +} + release_docker_image_version_from_tag() { local tag="$1" - local version + local image_version - if [[ ! "${tag}" =~ ^v([0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?)$ ]]; then + if [[ ! "${tag}" =~ ^([A-Za-z0-9._/-]*[-/])?v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then return 1 fi - version="${BASH_REMATCH[1]}" - if [[ ! "${version}" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then + image_version="${tag//\//-}" + if [[ ! "${image_version}" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then return 1 fi - printf '%s\n' "${version}" + printf '%s\n' "${image_version}" } diff --git a/scripts/release-package.sh b/scripts/release-package.sh index 831bce7..6974c54 100755 --- a/scripts/release-package.sh +++ b/scripts/release-package.sh @@ -7,11 +7,12 @@ set -euo pipefail TAG="${1:?Usage: release-package.sh [TARGET]}" TARGET="${2:-$(rustc -vV | awk '/^host:/ {print $2}')}" +ASSET_TAG="${TAG//\//-}" BINARIES=(arc-node-execution arc-node-consensus arc-snapshots) BUILD_DIR="target/release" OUT_DIR="release-assets" -ARCHIVE_NAME="arc-node-${TAG}-${TARGET}.tar.gz" +ARCHIVE_NAME="arc-node-${ASSET_TAG}-${TARGET}.tar.gz" mkdir -p "$OUT_DIR" diff --git a/scripts/release-refs.sh b/scripts/release-refs.sh new file mode 100755 index 0000000..e780c13 --- /dev/null +++ b/scripts/release-refs.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + release-refs.sh --tag vX.Y.Z[-rcN] [--release-ref-prefix test-] + +Outputs shell-style key=value metadata for the release tag. +USAGE +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=scripts/release-config.sh +source "${SCRIPT_DIR}/release-config.sh" + +TAG="" +RELEASE_REF_PREFIX="${RELEASE_REF_PREFIX:-}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) TAG="${2:?missing tag}"; shift 2 ;; + --release-ref-prefix) RELEASE_REF_PREFIX="${2-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage; exit 1 ;; + esac +done + +if [[ -z "${TAG}" ]]; then + echo "Set --tag" >&2 + exit 1 +fi + +TAG_PREFIX="$(release_tag_prefix_from_ref_prefix "${RELEASE_REF_PREFIX}")" +RELEASE_BRANCH_PREFIX="$(release_branch_prefix_from_ref_prefix "${RELEASE_REF_PREFIX}")" + +if [[ -z "${TAG_PREFIX}" ]]; then + echo "tag prefix cannot be empty" >&2 + exit 1 +fi +if [[ -z "${RELEASE_BRANCH_PREFIX}" ]]; then + echo "release branch prefix cannot be empty" >&2 + exit 1 +fi +if ! git check-ref-format "refs/tags/${TAG_PREFIX}0.0.0" >/dev/null 2>&1; then + echo "Invalid tag prefix: ${TAG_PREFIX}" >&2 + exit 1 +fi +if ! git check-ref-format "refs/heads/${RELEASE_BRANCH_PREFIX}0.0" >/dev/null 2>&1; then + echo "Invalid release branch prefix: ${RELEASE_BRANCH_PREFIX}" >&2 + exit 1 +fi + +if [[ "${TAG:0:${#TAG_PREFIX}}" != "${TAG_PREFIX}" ]]; then + echo "Invalid release tag: ${TAG} does not start with ${TAG_PREFIX}" >&2 + exit 1 +fi + +VERSION="${TAG:${#TAG_PREFIX}}" +if [[ ! "${VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-rc([0-9]+))?$ ]]; then + echo "Invalid release tag: ${TAG}" >&2 + exit 1 +fi + +MAJOR="${BASH_REMATCH[1]}" +MINOR="${BASH_REMATCH[2]}" +PATCH="${BASH_REMATCH[3]}" +RC="${BASH_REMATCH[5]:-}" +IS_RELEASE_CANDIDATE=false +if [[ -n "${RC}" ]]; then + IS_RELEASE_CANDIDATE=true +fi +RELEASE_BRANCH="${RELEASE_BRANCH_PREFIX}${MAJOR}.${MINOR}" +COPYBARA_BRANCH_VERSION="${TAG//\//-}" +COPYBARA_BRANCH_VERSION="${COPYBARA_BRANCH_VERSION//./_}" + +{ + echo "tag=${TAG}" + echo "version=${VERSION}" + echo "major=${MAJOR}" + echo "minor=${MINOR}" + echo "patch=${PATCH}" + echo "rc=${RC}" + echo "is_release_candidate=${IS_RELEASE_CANDIDATE}" + echo "release_branch=${RELEASE_BRANCH}" + echo "copybara_pr_branch=sync/${COPYBARA_BRANCH_VERSION}" +} diff --git a/scripts/scenarios/nightly-chaos-testing.sh b/scripts/scenarios/nightly-chaos-testing.sh index 15356f7..e6cf156 100755 --- a/scripts/scenarios/nightly-chaos-testing.sh +++ b/scripts/scenarios/nightly-chaos-testing.sh @@ -71,9 +71,16 @@ QUAKE="./target/debug/quake" echo "[1/6] Building (genesis, Docker images, quake)..." make genesis -make build-docker - -cargo build --bin quake +# When QUAKE_SKIP_BUILD is set, the caller has already provided the +# arc_execution:latest / arc_consensus:latest images and the quake binary at +# target/debug/quake (e.g. restored from a build cache), so skip the expensive +# node image and quake builds. +if [[ -n "${QUAKE_SKIP_BUILD:-}" ]]; then + echo "QUAKE_SKIP_BUILD set: reusing prebuilt node images and quake binary, skipping build" +else + make build-docker + cargo build --bin quake +fi echo "[2/6] Running quake cleanup..." "$QUAKE" --seed "$SEED" -f "$SCENARIO" clean --all 2>/dev/null diff --git a/scripts/scenarios/nightly-random-manifests.sh b/scripts/scenarios/nightly-random-manifests.sh index 86e71b9..a7579cf 100644 --- a/scripts/scenarios/nightly-random-manifests.sh +++ b/scripts/scenarios/nightly-random-manifests.sh @@ -75,8 +75,17 @@ run_phase() { } echo "[1/3] Building (genesis, Docker images, quake)..." -make genesis build-docker -cargo build --bin quake +make genesis +# When QUAKE_SKIP_BUILD is set, the caller has already provided the +# arc_execution:latest / arc_consensus:latest images and the quake binary at +# target/debug/quake (e.g. restored from a build cache), so skip the expensive +# node image and quake builds. +if [[ -n "${QUAKE_SKIP_BUILD:-}" ]]; then + echo "QUAKE_SKIP_BUILD set: reusing prebuilt node images and quake binary, skipping build" +else + make build-docker + cargo build --bin quake +fi echo "[2/3] Generating random manifests..." if [[ -n "${QUAKE_GENERATE_SEED:-}" ]]; then diff --git a/scripts/scenarios/nightly-upgrade.sh b/scripts/scenarios/nightly-upgrade.sh index 9f85edd..6c257be 100755 --- a/scripts/scenarios/nightly-upgrade.sh +++ b/scripts/scenarios/nightly-upgrade.sh @@ -31,7 +31,7 @@ echo "Repository root: $REPO_ROOT" SCENARIO="${1:-crates/quake/scenarios/nightly-upgrade.toml}" LOAD_DURATION="${2:-90}" LOAD_RATE="${3:-100}" -NEXT_HARDFORK_NAME="${4:-zero6}" +NEXT_HARDFORK_NAME="${4:-zero8}" UPGRADE_TO_HARDFORK_BLOCKS=60 # block-based hardfork: activate this many blocks ahead UPGRADE_TO_HARDFORK_SECONDS=60 # timestamp-based hardfork: activate this many seconds ahead UPGRADE_TO_OSAKA_SECONDS=300 # osakaTime is always timestamp-patched; activate this many seconds ahead