diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6314f5229..0b5999168 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -7,24 +7,52 @@ on: jobs: coverage: - # sadly, for now we have to "rebuild" for the coverage + permissions: + pull-requests: write runs-on: self-hosted steps: - uses: actions/checkout@v4 - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@v2 # use a different cache key as coverae uses custom rustc args + - uses: dtolnay/rust-toolchain@master with: - shared-key: "cache" - save-if: false + toolchain: 1.78 - name: Setup build deps run: | sudo apt-get update sudo apt-get install -y clang llvm libudev-dev protobuf-compiler + - uses: rui314/setup-mold@v1 - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov - - name: Coverage - run: cargo llvm-cov --codecov --output-path codecov.json - - name: Upload coverage to codecov.io - uses: codecov/codecov-action@v3 + + - uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + - name: Launch Anvil + run: anvil --fork-url $ANVIL_FORK_URL --fork-block-number $ANVIL_BLOCK_NUMBER & + env: + ANVIL_FORK_URL: "https://eth.merkle.io" + ANVIL_BLOCK_NUMBER: 20395662 + - name: Wait for Anvil to be ready + run: | + while ! nc -z localhost 8545; do + sleep 1 + done + + - name: Build and run tests + run: | + source <(cargo llvm-cov show-env --export-prefix) + cargo build --bin deoxys --profile dev + export COVERAGE_BIN=$(realpath target/debug/deoxys) + rm -f target/madara-* lcov.info + cargo test --profile dev + + - name: Generate coverage info + run: | + source <(cargo llvm-cov show-env --export-prefix) + cargo llvm-cov report --cobertura --output-path coverage.cobertura.xml + + - name: Display coverage + uses: ewjoachim/coverage-comment-action@v1 with: - files: codecov.json - fail_ci_if_error: false + GITHUB_TOKEN: ${{ github.token }} + COVERAGE_FILE: coverage.cobertura.xml diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index ec8aafdfa..11fe46392 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -10,6 +10,9 @@ concurrency: group: pr-checks-${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +permissions: + pull-requests: write + jobs: changelog: name: Enforce CHANGELOG @@ -30,7 +33,7 @@ jobs: uses: ./.github/workflows/linters-cargo.yml needs: rust_check - rust_test: - name: Run Cargo tests - uses: ./.github/workflows/rust-test.yml - needs: rust_check + coverage: + name: Run Coverage + uses: ./.github/workflows/coverage.yml + needs: changelog diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index ed393535c..2fcdab677 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -19,8 +19,3 @@ jobs: name: Run Cargo linters uses: ./.github/workflows/linters-cargo.yml needs: rust_check - - rust_test: - name: Run Cargo tests - uses: ./.github/workflows/rust-test.yml - needs: rust_check diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml deleted file mode 100644 index e837854c1..000000000 --- a/.github/workflows/rust-test.yml +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: Task - Run Cargo tests - -on: - workflow_dispatch: - workflow_call: - -jobs: - rust_test: - runs-on: self-hosted - steps: - - uses: actions/checkout@v4 - - uses: Swatinem/rust-cache@v2 - with: - shared-key: "cache" - - uses: dtolnay/rust-toolchain@master - with: - toolchain: 1.78 - - - uses: foundry-rs/foundry-toolchain@v1 - with: - version: nightly - - name: Launch Anvil - run: anvil --fork-url $ANVIL_FORK_URL --fork-block-number $ANVIL_BLOCK_NUMBER & - env: - ANVIL_FORK_URL: "https://eth.merkle.io" - ANVIL_BLOCK_NUMBER: 20395662 - - name: Wait for Anvil to be ready - run: | - while ! nc -z localhost 8545; do - sleep 1 - done - - - name: Run unit tests - run: | - cargo test diff --git a/CHANGELOG.md b/CHANGELOG.md index 69f55a08e..376319022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Next release +- tests: add e2e tests for the node - fix: fixed some readme stuff - feat: gas price provider added for block production - feat: l1 sync service diff --git a/Cargo.lock b/Cargo.lock index 6c9640879..97950ca59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3646,6 +3646,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "dc-e2e-tests" +version = "0.1.0" +dependencies = [ + "anyhow", + "env_logger 0.11.3", + "lazy_static", + "log", + "reqwest 0.12.5", + "rstest 0.18.2", + "starknet-core", + "starknet-providers", + "tempfile", + "tokio", +] + [[package]] name = "dc-eth" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 785bac701..f5286e66a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/primitives/receipt", "crates/primitives/state_update", "crates/primitives/utils", + "crates/tests", ] resolver = "2" # All previous except for `starknet-rpc-test` and `starknet-e2e-test` @@ -36,6 +37,7 @@ default-members = [ "crates/primitives/receipt", "crates/primitives/state_update", "crates/primitives/utils", + "crates/tests", ] [profile.dev] @@ -107,11 +109,8 @@ cairo-lang-utils = "=2.7.0-rc.2" alloy = { version = "0.2.0", features = [ "node-bindings", - "providers", - "transport-http", - "sol-types", - "json", "rpc-types", + "provider-http", "contract", ] } diff --git a/crates/client/eth/Cargo.toml b/crates/client/eth/Cargo.toml index a5c6fc49b..0e7acffa3 100644 --- a/crates/client/eth/Cargo.toml +++ b/crates/client/eth/Cargo.toml @@ -30,7 +30,7 @@ starknet_api = { workspace = true } # Other -alloy = { workspace = true, features = ["node-bindings"] } +alloy = { workspace = true } anyhow = "1.0.75" async-trait = "0.1.80" bitvec = { workspace = true } diff --git a/crates/client/sync/src/lib.rs b/crates/client/sync/src/lib.rs index 96561ac2a..fb40c087a 100644 --- a/crates/client/sync/src/lib.rs +++ b/crates/client/sync/src/lib.rs @@ -19,7 +19,6 @@ pub mod starknet_sync_worker { use dc_telemetry::TelemetryHandle; use dp_convert::ToFelt; use fetch::fetchers::FetchConfig; - use starknet_providers::SequencerGatewayProvider; use std::{sync::Arc, time::Duration}; diff --git a/crates/node/src/cli/block_production.rs b/crates/node/src/cli/block_production.rs index 3f67e84ff..f1db94017 100644 --- a/crates/node/src/cli/block_production.rs +++ b/crates/node/src/cli/block_production.rs @@ -1,8 +1,8 @@ -/// Parameters used to config telemetry. +/// Parameters used to config block production. #[derive(Clone, Debug, clap::Parser)] pub struct BlockProductionParams { /// Disable the block production service. /// The block production service is only enabled with the authority (sequencer) mode. - #[arg(long, alias = "no-disabled")] + #[arg(long, alias = "no-block-production")] pub block_production_disabled: bool, } diff --git a/crates/node/src/cli/mod.rs b/crates/node/src/cli/mod.rs index 1f26b7a86..ca5a8f74c 100644 --- a/crates/node/src/cli/mod.rs +++ b/crates/node/src/cli/mod.rs @@ -19,6 +19,7 @@ use std::sync::Arc; use url::Url; #[derive(Clone, Debug, clap::Parser)] +/// Madara: High performance Starknet sequencer/full-node. pub struct RunCmd { /// The human-readable name for this node. /// It is used as the network node name. diff --git a/crates/node/src/main.rs b/crates/node/src/main.rs index 59fefd823..249b93702 100644 --- a/crates/node/src/main.rs +++ b/crates/node/src/main.rs @@ -84,6 +84,7 @@ async fn main() -> anyhow::Result<()> { l1_gas_setter, chain_config.chain_id.clone(), chain_config.eth_core_contract_address, + run_cmd.authority, ) .await .context("Initializing the l1 sync service")?; diff --git a/crates/node/src/service/l1.rs b/crates/node/src/service/l1.rs index 728b46d00..322c81850 100644 --- a/crates/node/src/service/l1.rs +++ b/crates/node/src/service/l1.rs @@ -31,6 +31,7 @@ impl L1SyncService { l1_gas_provider: GasPriceProvider, chain_id: ChainId, l1_core_address: H160, + authority: bool, ) -> anyhow::Result { let eth_client = if !config.sync_l1_disabled { if let Some(l1_rpc_url) = &config.l1_endpoint { @@ -50,13 +51,13 @@ impl L1SyncService { None }; - let gas_price_sync_disabled = config.gas_price_sync_disabled; + let gas_price_sync_enabled = authority && !config.gas_price_sync_disabled; let gas_price_poll_ms = Duration::from_secs(config.gas_price_poll_ms); - if !gas_price_sync_disabled { - let eth_client = eth_client.clone().ok_or_else(|| { - anyhow::anyhow!("EthereumClient is required to start the l1 sync service but not provided.") - })?; + if gas_price_sync_enabled { + let eth_client = eth_client + .clone() + .context("EthereumClient is required to start the l1 sync service but not provided.")?; // running at-least once before the block production service dc_eth::l1_gas_price::gas_price_worker(ð_client, l1_gas_provider.clone(), gas_price_poll_ms).await?; } @@ -66,7 +67,7 @@ impl L1SyncService { eth_client, l1_gas_provider, chain_id, - gas_price_sync_disabled, + gas_price_sync_disabled: !gas_price_sync_enabled, gas_price_poll_ms, }) } @@ -75,26 +76,24 @@ impl L1SyncService { #[async_trait::async_trait] impl Service for L1SyncService { async fn start(&mut self, join_set: &mut JoinSet>) -> anyhow::Result<()> { - let L1SyncService { eth_client, l1_gas_provider, chain_id, gas_price_sync_disabled, gas_price_poll_ms, .. } = - self.clone(); + let L1SyncService { l1_gas_provider, chain_id, gas_price_sync_disabled, gas_price_poll_ms, .. } = self.clone(); - let db_backend = Arc::clone(&self.db_backend); + if let Some(eth_client) = self.eth_client.take() { + // enabled - let eth_client = eth_client.ok_or_else(|| { - anyhow::anyhow!("EthereumClient is required to start the l1 sync service but not provided.") - })?; - - join_set.spawn(async move { - dc_eth::sync::l1_sync_worker( - &db_backend, - ð_client, - chain_id.to_felt(), - l1_gas_provider, - gas_price_sync_disabled, - gas_price_poll_ms, - ) - .await - }); + let db_backend = Arc::clone(&self.db_backend); + join_set.spawn(async move { + dc_eth::sync::l1_sync_worker( + &db_backend, + ð_client, + chain_id.to_felt(), + l1_gas_provider, + gas_price_sync_disabled, + gas_price_poll_ms, + ) + .await + }); + } Ok(()) } diff --git a/crates/tests/Cargo.toml b/crates/tests/Cargo.toml new file mode 100644 index 000000000..ce63579ef --- /dev/null +++ b/crates/tests/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "dc-e2e-tests" +authors.workspace = true +homepage.workspace = true +edition.workspace = true +repository.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] + +anyhow.workspace = true +env_logger.workspace = true +lazy_static.workspace = true +log.workspace = true +reqwest.workspace = true +rstest.workspace = true +starknet-core.workspace = true +starknet-providers.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs new file mode 100644 index 000000000..813ace3be --- /dev/null +++ b/crates/tests/src/lib.rs @@ -0,0 +1,232 @@ +//! End to end tests for madara. + +use anyhow::bail; +use rstest::rstest; +use starknet_providers::Provider; +use starknet_providers::{jsonrpc::HttpTransport, JsonRpcClient, Url}; +use std::ops::Range; +use std::sync::Mutex; +use std::{ + collections::HashMap, + future::Future, + path::{Path, PathBuf}, + process::{Child, Command, Output, Stdio}, + str::FromStr, + time::Duration, +}; +use tempfile::TempDir; + +async fn wait_for_cond>>(mut cond: impl FnMut() -> F, duration: Duration) { + let mut attempt = 0; + loop { + let Err(err) = cond().await else { + break; + }; + + attempt += 1; + if attempt >= 10 { + panic!("No answer from the node after {attempt} attempts: {:#}", err) + } + + tokio::time::sleep(duration).await; + } +} + +pub struct MadaraCmd { + process: Option, + ready: bool, + json_rpc: Option>, + rpc_url: Url, + tempdir: TempDir, + _port: MadaraPortNum, +} + +impl MadaraCmd { + pub fn wait_with_output(mut self) -> Output { + self.process.take().unwrap().wait_with_output().unwrap() + } + + pub fn json_rpc(&mut self) -> &JsonRpcClient { + self.json_rpc.get_or_insert_with(|| JsonRpcClient::new(HttpTransport::new(self.rpc_url.clone()))) + } + + pub fn db_dir(&self) -> &Path { + self.tempdir.path() + } + + pub async fn wait_for_ready(&mut self) -> &mut Self { + let endpoint = self.rpc_url.join("/health").unwrap(); + wait_for_cond( + || async { + let res = reqwest::get(endpoint.clone()).await?; + res.error_for_status()?; + anyhow::Ok(()) + }, + Duration::from_millis(1000), + ) + .await; + self.ready = true; + self + } + + pub async fn wait_for_sync_to(&mut self, block_n: u64) -> &mut Self { + let rpc = self.json_rpc(); + wait_for_cond( + || async { + match rpc.block_hash_and_number().await { + Ok(got) => { + if got.block_number < block_n { + bail!("got block_n {}, expected {block_n}", got.block_number); + } + anyhow::Ok(()) + } + Err(err) => bail!(err), + } + }, + Duration::from_millis(5000), + ) + .await; + self + } +} + +impl Drop for MadaraCmd { + fn drop(&mut self) { + let Some(mut child) = self.process.take() else { return }; + + let kill = || { + let mut kill = Command::new("kill").args(["-s", "TERM", &child.id().to_string()]).spawn()?; + kill.wait()?; + anyhow::Ok(()) + }; + if let Err(_err) = kill() { + child.kill().unwrap() + } + child.wait().unwrap(); + } +} + +// this really should use unix sockets, sad + +const PORT_RANGE: Range = 19944..20000; + +struct AvailablePorts> { + to_reuse: Vec, + next: I, +} + +lazy_static::lazy_static! { + static ref AVAILABLE_PORTS: Mutex>> = Mutex::new(AvailablePorts { to_reuse: vec![], next: PORT_RANGE }); +} + +pub struct MadaraPortNum(pub u16); +impl Drop for MadaraPortNum { + fn drop(&mut self) { + let mut guard = AVAILABLE_PORTS.lock().expect("poisoned lock"); + guard.to_reuse.push(self.0); + } +} + +pub fn get_port() -> MadaraPortNum { + let mut guard = AVAILABLE_PORTS.lock().expect("poisoned lock"); + if let Some(el) = guard.to_reuse.pop() { + return MadaraPortNum(el); + } + MadaraPortNum(guard.next.next().expect("no more port to use")) +} + +pub struct MadaraCmdBuilder { + args: Vec, + env: HashMap, + tempdir: TempDir, + port: MadaraPortNum, +} + +impl Default for MadaraCmdBuilder { + fn default() -> Self { + Self::new() + } +} + +impl MadaraCmdBuilder { + pub fn new() -> Self { + Self { + args: Default::default(), + env: Default::default(), + tempdir: TempDir::with_prefix("madara-test").unwrap(), + port: get_port(), + } + } + + pub fn args(mut self, args: impl IntoIterator>) -> Self { + self.args = args.into_iter().map(Into::into).collect(); + self + } + + pub fn env(mut self, env: impl IntoIterator, impl Into)>) -> Self { + self.env = env.into_iter().map(|(k, v)| (k.into(), v.into())).collect(); + self + } + + pub fn run(self) -> MadaraCmd { + let target_bin = option_env!("COVERAGE_BIN").unwrap_or("../../target/debug/deoxys"); + let target_bin = PathBuf::from_str(target_bin).expect("target bin is not a path"); + if !target_bin.exists() { + panic!("No binary to run: {:?}", target_bin) + } + + let process = Command::new(target_bin) + .envs(self.env) + .args(self.args.into_iter().chain([ + "--telemetry-disabled".into(), // important: disable telemetry!! + "--no-prometheus".into(), + "--base-path".into(), + format!("{}", self.tempdir.as_ref().display()), + "--rpc-port".into(), + format!("{}", self.port.0), + ])) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + + MadaraCmd { + process: Some(process), + ready: false, + json_rpc: None, + rpc_url: Url::parse(&format!("http://127.0.0.1:{}/", self.port.0)).unwrap(), + tempdir: self.tempdir, + _port: self.port, + } + } +} + +#[rstest] +fn madara_help_shows() { + let _ = env_logger::builder().is_test(true).try_init(); + let output = MadaraCmdBuilder::new().args(["--help"]).run().wait_with_output(); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("Madara: High performance Starknet sequencer/full-node"), "stdout: {stdout}"); +} + +#[rstest] +#[tokio::test] +async fn madara_can_sync_a_few_blocks() { + use starknet_core::types::{BlockHashAndNumber, Felt}; + + let _ = env_logger::builder().is_test(true).try_init(); + let mut node = MadaraCmdBuilder::new() + .args(["--network", "sepolia", "--no-sync-polling", "--n-blocks-to-sync", "20", "--no-l1-sync"]) + .run(); + node.wait_for_ready().await; + node.wait_for_sync_to(19).await; + + assert_eq!( + node.json_rpc().block_hash_and_number().await.unwrap(), + BlockHashAndNumber { + // https://sepolia.voyager.online/block/19 + block_hash: Felt::from_hex_unchecked("0x4177d1ba942a4ab94f86a476c06f0f9e02363ad410cdf177c54064788c9bcb5"), + block_number: 19 + } + ); +} diff --git a/scripts/e2e-coverage.sh b/scripts/e2e-coverage.sh new file mode 100755 index 000000000..73826e28f --- /dev/null +++ b/scripts/e2e-coverage.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +rm -f target/madara-* lcov.info + +source <(cargo llvm-cov show-env --export-prefix) + +cargo build --bin deoxys --profile dev + +export COVERAGE_BIN=$(realpath target/debug/deoxys) +cargo test --profile dev + +cargo llvm-cov report --lcov --output-path lcov.info +# cargo llvm-cov report