diff --git a/.github/ISSUE_TEMPLATE/0_bug.md b/.github/ISSUE_TEMPLATE/0_bug.md new file mode 100644 index 0000000..637ceaf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/0_bug.md @@ -0,0 +1,46 @@ +--- +name: 🐛 Bug Report +about: Submit a bug report if something isn't working +title: "[Bug]" +labels: bug +--- + +## 🐛 Bug Report + + + +(Write your description here) + +## Steps to Reproduce + + + +#### Command or code snippet + +``` +# Add steps here +``` + +#### Error output + +``` +// Paste the output here +``` + +## Expected Behavior + +(Write what you expected to happen here) + +## Your Environment + +- +- +- diff --git a/.github/ISSUE_TEMPLATE/1_feature.md b/.github/ISSUE_TEMPLATE/1_feature.md new file mode 100644 index 0000000..869a534 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1_feature.md @@ -0,0 +1,33 @@ +--- +name: 🚀 Feature Request +about: Submit a new feature request +title: "[Feature]" +labels: feature +--- + +## 🚀 Feature Request + + + +(Write your description here) + +## Motivation + + + +(Write your motivation here) + +## Implementation + + + +(Write your implementation ideas here) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..754a3cc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: ❓ Discord Support + url: https://discord.gg/aleo + about: For quick questions or technical troubleshooting, ask in our Discord. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..cb2a85b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Motivation + +(Describe why this change is needed) + +## Test Plan + +(Describe how you verified your changes) + +## Related PRs + +(Link any related PRs here) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8dc9456 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: "/" + schedule: + interval: weekly + time: "10:00" + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e9711a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + RUST_BACKTRACE: 1 + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install libclang + run: sudo apt-get update -qq && sudo apt-get install -y libclang-dev + - name: Verify Cargo.lock exists + run: test -f Cargo.lock || (echo "::error::Cargo.lock is missing" && exit 1) + - name: Run tests + run: cargo test --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ad1a471 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,159 @@ +name: Release + +on: + push: + tags: ['v[0-9]*'] + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v0.2.0). Creates the git tag if it does not already exist." + required: true + +env: + RUST_BACKTRACE: 0 + +jobs: + prepare: + name: Prepare Release + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + tag: ${{ steps.resolve.outputs.tag }} + version: ${{ steps.resolve.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve Tag and Version + id: resolve + run: | + TAG="${{ github.event.inputs.tag || github.ref_name }}" + VERSION="${TAG#v}" + TOML_VERSION="$(grep '^version' Cargo.toml | head -1 | sed 's/.*= *"\(.*\)"/\1/')" + if [ "$VERSION" != "$TOML_VERSION" ]; then + echo "::error::Tag version ($VERSION) does not match Cargo.toml version ($TOML_VERSION)" + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Ensure Tag Exists + run: | + TAG="${{ steps.resolve.outputs.tag }}" + if git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists" + else + git tag "$TAG" + git push origin "$TAG" + fi + + build: + name: Build (${{ matrix.target }}) + needs: prepare + strategy: + fail-fast: true + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: x86_64-apple-darwin + os: macos-14-large + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Add aarch64 target + if: matrix.target == 'aarch64-apple-darwin' + run: rustup target add aarch64-apple-darwin + + - uses: Swatinem/rust-cache@v2 + + - name: Install libclang (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update -qq && sudo apt-get install -y libclang-dev + + - name: Install LLVM (Windows) + if: runner.os == 'Windows' + uses: KyleMayes/install-llvm-action@v1 + with: + version: "11" + directory: ${{ runner.temp }}/llvm + + - name: Set LIBCLANG_PATH (Windows) + if: runner.os == 'Windows' + run: echo "LIBCLANG_PATH=${{ runner.temp }}/llvm/lib" >> $GITHUB_ENV + + - name: Verify Cargo.lock exists + shell: bash + run: test -f Cargo.lock || (echo "::error::Cargo.lock is missing" && exit 1) + + - name: Build + shell: bash + run: | + TARGET_FLAG="" + if [ "${{ matrix.target }}" = "aarch64-apple-darwin" ]; then + TARGET_FLAG="--target aarch64-apple-darwin" + fi + cargo build --release --locked $TARGET_FLAG + env: + CARGO_NET_GIT_FETCH_WITH_CLI: true + + - name: Package (Unix) + if: runner.os != 'Windows' + run: | + TAG="${{ needs.prepare.outputs.tag }}" + if [ "${{ matrix.target }}" = "aarch64-apple-darwin" ]; then + BIN="target/aarch64-apple-darwin/release/aleo-devnode" + else + BIN="target/release/aleo-devnode" + fi + strip "$BIN" + ARCHIVE="aleo-devnode-${TAG}-${{ matrix.target }}.zip" + zip -j "$ARCHIVE" "$BIN" + echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV" + + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $tag = "${{ needs.prepare.outputs.tag }}" + $archive = "aleo-devnode-${tag}-${{ matrix.target }}.zip" + Compress-Archive -Path target/release/aleo-devnode.exe -DestinationPath $archive + echo "ARCHIVE=$archive" >> $env:GITHUB_ENV + + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.target }} + path: ${{ env.ARCHIVE }} + + release: + name: Publish Release + needs: [prepare, build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: release-* + merge-multiple: true + + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.prepare.outputs.tag }} + name: "v${{ needs.prepare.outputs.version }}" + files: "*.zip" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 735bf17..a408f79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,7 @@ dependencies = [ "serde", "serde_json", "snarkvm", + "tempfile", "tokio", "tower-http", "tower_governor", @@ -1891,15 +1892,14 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.78" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -1923,9 +1923,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.114" +version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", diff --git a/Cargo.toml b/Cargo.toml index 7e2160e..4b37a00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,3 +26,6 @@ tower-http = { version = "0.6.0", features = ["cors", "trace"] } tower_governor = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +[dev-dependencies] +tempfile = "3" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..9d717fc --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,16 @@ +# https://github.com/rust-lang/rustfmt/blob/master/Configurations.md + +# Stable configurations +edition = "2024" +max_width = 120 +merge_derives = true +use_field_init_shorthand = true +use_small_heuristics = "Max" +use_try_shorthand = true +imports_layout = "HorizontalVertical" +imports_granularity = "Crate" +overflow_delimited_expr = true + +# Nightly configurations +reorder_impl_items = true +style_edition = "2024" diff --git a/src/rest/mod.rs b/src/rest/mod.rs index f19d922..f3dac26 100644 --- a/src/rest/mod.rs +++ b/src/rest/mod.rs @@ -35,8 +35,8 @@ use axum::{ response::Response, routing::{get, post}, }; -use std::path::PathBuf; use axum_extra::response::ErasedJson; +use std::path::PathBuf; use parking_lot::Mutex; use std::{ diff --git a/src/restore.rs b/src/restore.rs index eee4bba..e4442d3 100644 --- a/src/restore.rs +++ b/src/restore.rs @@ -43,11 +43,7 @@ impl Restore { let snapshot_path = snapshots_dir.join(&self.snapshot); if !snapshot_path.exists() { - return Err(anyhow!( - "Snapshot '{}' not found at '{}'", - self.snapshot, - snapshot_path.display() - )); + return Err(anyhow!("Snapshot '{}' not found at '{}'", self.snapshot, snapshot_path.display())); } // Clear the current storage directory, preserving the directory itself. @@ -55,8 +51,7 @@ impl Restore { println!("Clearing storage directory: {}", self.storage.display()); clear_dir(&self.storage)?; } else { - std::fs::create_dir_all(&self.storage) - .map_err(|e| anyhow!("Failed to create storage directory: {e}"))?; + std::fs::create_dir_all(&self.storage).map_err(|e| anyhow!("Failed to create storage directory: {e}"))?; } // Copy the snapshot into the storage directory. @@ -65,12 +60,15 @@ impl Restore { println!("Restore complete."); if self.restart { - relaunch_as_start(&self.storage, self.private_key.as_deref(), &self.socket_addr, self.verbosity, self.manual_block_creation)?; + relaunch_as_start( + &self.storage, + self.private_key.as_deref(), + &self.socket_addr, + self.verbosity, + self.manual_block_creation, + )?; } else { - println!( - "Restart the devnode with:\n aleo-devnode start --storage {}", - self.storage.display() - ); + println!("Restart the devnode with:\n aleo-devnode start --storage {}", self.storage.display()); } Ok(()) @@ -130,10 +128,7 @@ fn relaunch_as_start( /// Returns the snapshots directory that sits alongside the given storage directory. /// e.g. `devnode` → `devnode-snapshots` pub(crate) fn snapshots_sibling_dir(storage: &Path) -> PathBuf { - let dir_name = format!( - "{}-snapshots", - storage.file_name().unwrap_or_default().to_string_lossy() - ); + let dir_name = format!("{}-snapshots", storage.file_name().unwrap_or_default().to_string_lossy()); let mut p = storage.to_path_buf(); p.pop(); p.join(dir_name) @@ -144,11 +139,9 @@ fn clear_dir(dir: &Path) -> Result<()> { let entry = entry.map_err(|e| anyhow!("Failed to read entry: {e}"))?; let path = entry.path(); if path.is_dir() { - std::fs::remove_dir_all(&path) - .map_err(|e| anyhow!("Failed to remove '{}': {e}", path.display()))?; + std::fs::remove_dir_all(&path).map_err(|e| anyhow!("Failed to remove '{}': {e}", path.display()))?; } else { - std::fs::remove_file(&path) - .map_err(|e| anyhow!("Failed to remove '{}': {e}", path.display()))?; + std::fs::remove_file(&path).map_err(|e| anyhow!("Failed to remove '{}': {e}", path.display()))?; } } Ok(()) @@ -163,8 +156,7 @@ fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> { if src_path.is_dir() { copy_dir_all(&src_path, &dst_path)?; } else { - std::fs::copy(&src_path, &dst_path) - .map_err(|e| anyhow!("Failed to copy '{}': {e}", src_path.display()))?; + std::fs::copy(&src_path, &dst_path).map_err(|e| anyhow!("Failed to copy '{}': {e}", src_path.display()))?; } } Ok(()) diff --git a/src/start.rs b/src/start.rs index 60db5c8..ca2a532 100644 --- a/src/start.rs +++ b/src/start.rs @@ -14,8 +14,7 @@ // You should have received a copy of the GNU General Public License // along with the Leo library. If not, see . -use crate::logger::initialize_terminal_logger; -use crate::rest::Rest; +use crate::{logger::initialize_terminal_logger, rest::Rest}; use anyhow::Result; use clap::Parser; @@ -82,9 +81,10 @@ async fn start_devnode(command: Start, private_key: Option) -> Result<() .map_err(|e| anyhow::anyhow!("Failed to parse listener address '{}': {}", command.socket_addr, e))?; // Load the genesis block. let genesis_block: Block = if command.genesis_path != "blank" { - Block::from_bytes_le(&std::fs::read(&command.genesis_path).map_err(|e| { - anyhow::anyhow!("Failed to read genesis block file '{}': {}", command.genesis_path, e) - })?)? + Block::from_bytes_le( + &std::fs::read(&command.genesis_path) + .map_err(|e| anyhow::anyhow!("Failed to read genesis block file '{}': {}", command.genesis_path, e))?, + )? } else { // This genesis block is stored in $TMPDIR when running snarkos start --dev 0 --dev-num-validators N Block::from_bytes_le(include_bytes!(concat!( @@ -95,19 +95,17 @@ async fn start_devnode(command: Start, private_key: Option) -> Result<() match command.storage { Some(path) => { if command.clear_storage && path.exists() { - for entry in std::fs::read_dir(&path) - .map_err(|e| anyhow::anyhow!("Failed to read ledger directory: {e}"))? + for entry in + std::fs::read_dir(&path).map_err(|e| anyhow::anyhow!("Failed to read ledger directory: {e}"))? { let entry = entry.map_err(|e| anyhow::anyhow!("Failed to read entry: {e}"))?; let entry_path = entry.path(); if entry_path.is_dir() { - std::fs::remove_dir_all(&entry_path).map_err(|e| { - anyhow::anyhow!("Failed to remove '{}': {e}", entry_path.display()) - })?; + std::fs::remove_dir_all(&entry_path) + .map_err(|e| anyhow::anyhow!("Failed to remove '{}': {e}", entry_path.display()))?; } else { - std::fs::remove_file(&entry_path).map_err(|e| { - anyhow::anyhow!("Failed to remove '{}': {e}", entry_path.display()) - })?; + std::fs::remove_file(&entry_path) + .map_err(|e| anyhow::anyhow!("Failed to remove '{}': {e}", entry_path.display()))?; } } println!("Cleaned ledger directory: {}", path.display()); @@ -173,10 +171,7 @@ async fn run_devnode>( fn resolve_private_key(private_key: &Option) -> Result> { match private_key { - Some(pk) => { - Ok(PrivateKey::::from_str(pk) - .map_err(|e| anyhow::anyhow!("Invalid private key: {e}"))?) - } + Some(pk) => Ok(PrivateKey::::from_str(pk).map_err(|e| anyhow::anyhow!("Invalid private key: {e}"))?), None => { let pk = std::env::var("PRIVATE_KEY").map_err(|e| { anyhow::anyhow!( @@ -187,8 +182,7 @@ Please either: 2. Set the PRIVATE_KEY environment variable" ) })?; - Ok(PrivateKey::::from_str(&pk) - .map_err(|e| anyhow::anyhow!("Invalid private key: {e}"))?) + Ok(PrivateKey::::from_str(&pk).map_err(|e| anyhow::anyhow!("Invalid private key: {e}"))?) } } } diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..73f0466 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,247 @@ +// Integration tests for aleo-devnode. +// +// Each test spawns the binary directly, mirroring the shell-based devnode tests +// in the leo repo (tests/tests/cli/test_devnode*, leo_devnode_missing_private_key). + +use std::{ + path::Path, + process::{Child, Command, Stdio}, + sync::atomic::{AtomicU16, Ordering}, + time::{Duration, Instant}, +}; + +const PRIVATE_KEY: &str = "APrivateKey1zkp8CZNn3yeCseEtxuVPbDCwSyhGW6yZKUYKfgXmcpoGPWH"; +const DEVNODE_BIN: &str = env!("CARGO_BIN_EXE_aleo-devnode"); + +static NEXT_PORT: AtomicU16 = AtomicU16::new(14200); + +fn alloc_port() -> u16 { + NEXT_PORT.fetch_add(1, Ordering::Relaxed) +} + +struct DevnodeGuard { + child: Child, + client: reqwest::blocking::Client, + base_url: String, +} + +impl DevnodeGuard { + /// Spawns the devnode on the given port. + /// + /// When `manual_block_creation` is `true`, the `--manual-block-creation` flag is + /// passed and the ledger stays at genesis height until blocks are created explicitly. + /// When `false`, the devnode auto-advances to the latest consensus version height on + /// startup before accepting further requests. + fn start(port: u16, storage: Option<&Path>, manual_block_creation: bool) -> Self { + let addr = format!("127.0.0.1:{port}"); + let mut cmd = Command::new(DEVNODE_BIN); + cmd.args(["start", "--socket-addr", &addr, "--private-key", PRIVATE_KEY]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + if manual_block_creation { + cmd.arg("--manual-block-creation"); + } + if let Some(dir) = storage { + cmd.arg("--storage").arg(dir); + } + + let child = cmd.spawn().expect("failed to spawn devnode"); + let client = reqwest::blocking::Client::new(); + let base_url = format!("http://127.0.0.1:{port}/testnet"); + let guard = Self { child, client, base_url }; + guard.wait_for_height(0); + guard + } + + /// Polls until the ledger height is at least `min_height`, or panics after 120s. + /// + /// Passing `0` simply waits until the REST API is reachable. + fn wait_for_height(&self, min_height: u32) { + let deadline = Instant::now() + Duration::from_secs(120); + while Instant::now() < deadline { + if let Ok(resp) = self.client.get(format!("{}/block/height/latest", self.base_url)).send() { + if resp.status().is_success() { + if let Ok(h) = resp.json::() { + if h >= min_height { + return; + } + } + } + } + std::thread::sleep(Duration::from_millis(500)); + } + panic!("devnode did not reach height {min_height} within 120s"); + } + + fn height(&self) -> u32 { + self.client + .get(format!("{}/block/height/latest", self.base_url)) + .send() + .expect("height request failed") + .json::() + .expect("invalid height response") + } + + fn advance(&self, n: u32) { + self.client + .post(format!("{}/block/create", self.base_url)) + .json(&serde_json::json!({ "num_blocks": n })) + .send() + .expect("advance request failed") + .error_for_status() + .expect("advance returned error status"); + } + + fn create_snapshot(&self, name: &str) -> serde_json::Value { + self.client + .post(format!("{}/snapshot", self.base_url)) + .json(&serde_json::json!({ "name": name })) + .send() + .expect("snapshot request failed") + .json() + .expect("invalid snapshot response") + } + + fn list_snapshots(&self) -> Vec { + self.client + .get(format!("{}/snapshots", self.base_url)) + .send() + .expect("list snapshots request failed") + .json() + .expect("invalid snapshots response") + } + + fn shutdown(mut self) { + let _ = self.client.post(format!("{}/shutdown", self.base_url)).send(); + let _ = self.child.wait(); + // Drop runs after this and attempts kill+wait; that's harmless. + } +} + +impl Drop for DevnodeGuard { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +// Mirrors test_devnode: start the node, verify it's reachable, advance 5 blocks, +// verify the height increased by exactly 5. +#[test] +fn test_start_and_advance() { + let port = alloc_port(); + let devnode = DevnodeGuard::start(port, None, true); + + let initial_height = devnode.height(); + devnode.advance(5); + let new_height = devnode.height(); + + assert_eq!(new_height, initial_height + 5, "height should increase by exactly 5 after advance"); +} + +// Verifies that without --manual-block-creation the devnode automatically advances +// the ledger to the latest consensus version height before becoming fully ready. +#[test] +fn test_auto_advance_to_consensus_version() { + let expected_height = snarkvm::prelude::TEST_CONSENSUS_VERSION_HEIGHTS.last().unwrap().1; + + let port = alloc_port(); + let devnode = DevnodeGuard::start(port, None, false); + devnode.wait_for_height(expected_height); + + assert_eq!(devnode.height(), expected_height, "devnode should auto-advance to the latest consensus version height"); +} + +// Mirrors leo_devnode_missing_private_key: starting without a private key should +// exit non-zero immediately. +#[test] +fn test_missing_private_key() { + let port = alloc_port(); + let status = Command::new(DEVNODE_BIN) + .args(["start", "--socket-addr", &format!("127.0.0.1:{port}")]) + .env_remove("PRIVATE_KEY") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("failed to spawn devnode"); + + assert!(!status.success(), "devnode should exit with failure when no private key is provided"); +} + +// Mirrors test_devnode_persistent_storage: ledger state (block height) must +// survive a stop/restart cycle when --storage is used. +#[test] +fn test_persistent_storage() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let storage = dir.path().join("ledger"); + + let port1 = alloc_port(); + let devnode = DevnodeGuard::start(port1, Some(&storage), true); + devnode.advance(10); + let height_before = devnode.height(); + devnode.shutdown(); + + let port2 = alloc_port(); + let devnode2 = DevnodeGuard::start(port2, Some(&storage), true); + let height_after = devnode2.height(); + + assert_eq!(height_before, height_after, "ledger height should persist across restarts"); +} + +// Verifies that POST /testnet/snapshot creates a snapshot and GET /testnet/snapshots +// lists it. +#[test] +fn test_snapshot_create_and_list() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let storage = dir.path().join("ledger"); + + let port = alloc_port(); + let devnode = DevnodeGuard::start(port, Some(&storage), true); + devnode.advance(5); + + let resp = devnode.create_snapshot("my-snap"); + assert_eq!(resp["name"], "my-snap", "snapshot response should echo the requested name"); + assert!(resp["height"].as_u64().unwrap() > 0, "snapshot response should include a non-zero height"); + + let snapshots = devnode.list_snapshots(); + assert!(snapshots.contains(&"my-snap".to_string()), "snapshot should appear in the listing"); +} + +// Full snapshot round-trip: create snapshot at height H, advance past it, restore, +// restart, verify the ledger rolled back to H. +#[test] +fn test_snapshot_restore() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let storage = dir.path().join("ledger"); + + let port1 = alloc_port(); + let devnode = DevnodeGuard::start(port1, Some(&storage), true); + + devnode.advance(5); + let resp = devnode.create_snapshot("restore-point"); + let snapshot_height = resp["height"].as_u64().expect("snapshot height missing") as u32; + + // Advance past the snapshot to confirm it actually rolls back. + devnode.advance(5); + assert!(devnode.height() > snapshot_height, "height should exceed snapshot before restore"); + + devnode.shutdown(); + + // Restore (devnode must not be running). + let status = Command::new(DEVNODE_BIN) + .args(["restore", "--snapshot", "restore-point", "--storage"]) + .arg(&storage) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("restore command failed to spawn"); + assert!(status.success(), "restore command should exit successfully"); + + // Restart and verify the ledger rolled back. + let port2 = alloc_port(); + let devnode2 = DevnodeGuard::start(port2, Some(&storage), true); + let height_after = devnode2.height(); + + assert_eq!(height_after, snapshot_height, "height should roll back to the snapshot height after restore"); +}