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");
+}