iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 94b6ef22e67029631608daaf45a781d286bf8a95
parent 6affcea995445cb851cdd5555101110158566ad1
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue,  1 Sep 2026 17:29:42 +0200

Make post-1000 release testing the default

Diffstat:
D.github/workflows/security.yml | 70----------------------------------------------------------------------
MREADME.md | 5+++--
MROADMAP.md | 8+++++++-
Mdeployment.sh | 6++++--
Me2e/README.md | 9+++++++++
Me2e/iuna_e2e.py | 11+++++++++--
Msrc/domain/adversarial_tests.rs | 17+++++++++++------
Msrc/domain/ledger_reveal.rs | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtests/properties.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
9 files changed, 190 insertions(+), 110 deletions(-)

diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml @@ -1,70 +0,0 @@ -name: Security and desktop builds - -on: - pull_request: - push: - branches: [main] - -permissions: - contents: read - -jobs: - dependency-policy: - runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Install pinned Rust toolchain - run: rustup toolchain install 1.88.0 --profile minimal - - name: Install pinned cargo-audit - run: cargo +1.88.0 install cargo-audit --version 0.22.1 --locked - - name: Audit dependencies and enforce source/license policy - run: ./scripts/check-dependencies.sh - - name: Test node - run: cargo test --locked - - name: Check fuzz targets - run: cargo check --locked --manifest-path fuzz/Cargo.toml - - desktop-build: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - defaults: - run: - shell: bash - steps: - - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Install Linux desktop dependencies - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libwebkit2gtk-4.1-dev \ - build-essential \ - libxdo-dev \ - libssl-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev - - name: Install pinned Rust toolchain - run: rustup toolchain install 1.88.0 --profile minimal - - name: Build host sidecar - run: | - cargo +1.88.0 build --locked - target_triple="$(rustc +1.88.0 -vV | sed -n 's/^host: //p')" - mkdir -p src-tauri/binaries - if [ "${{ runner.os }}" = "Windows" ]; then - cp target/debug/iuna.exe "src-tauri/binaries/iuna-sidecar-${target_triple}.exe" - else - cp target/debug/iuna "src-tauri/binaries/iuna-sidecar-${target_triple}" - fi - - name: Test desktop crate - run: cargo +1.88.0 test --locked --manifest-path src-tauri/Cargo.toml - - name: Build desktop crate - run: cargo +1.88.0 build --locked --manifest-path src-tauri/Cargo.toml diff --git a/README.md b/README.md @@ -71,8 +71,9 @@ Release and deploy with: By default, deployment audits all three Rust lockfiles, enforces the dependency source/license policy, runs the regular unit tests, verifies that the fuzz targets compile against their locked dependencies, and runs the extended -adversarial, fuzz, and release-property suites. Release hosts therefore need -`cargo-audit` and `jq` in addition to the pinned Rust 1.88 toolchain. To +adversarial, fuzz, post-height-1000 six-node E2E, and release-property suites. +Release hosts therefore need `cargo-audit`, `jq`, and Docker Compose in addition +to the pinned Rust 1.88 toolchain. To explicitly skip the long-running suites: ```sh diff --git a/ROADMAP.md b/ROADMAP.md @@ -128,7 +128,10 @@ A release intended for deployment must pass: - desktop test/build jobs on Linux, macOS, and Windows - fuzz gate runs with `256` iterations each for `p2p_envelope`, `compact_snapshot`, `domain_json`, `stratum_request`, and `wallet_config` -- `cargo test --locked --release --test properties -- --ignored` +- `cargo test --locked --release --features e2e --test properties -- --ignored`, + restoring the first objective checkpoint before exercising P2P, Stratum, and restarts +- `./e2e/iuna_e2e.py test post-activation --build`, which crosses height 1000, + checks objective finality, restarts all six nodes, and advances through 1007 Normal local development may skip ignored long-running property tests and long fuzzing sessions, but deployment must run the release gate smoke checks. @@ -138,3 +141,6 @@ Normal local development may skip ignored long-running property tests and long f - 2026-08-14: Long-running property/soak tests are marked `#[ignore]` for normal local runs and are required in `deployment.sh`. - 2026-08-19: The mainnet-candidate chain is intended to be promotable to mainnet without a second genesis if it satisfies the stability window and release gates. - 2026-08-20: The agreed independent-node long-running testnet stability window completed without requiring an unplanned chain reset. +- 2026-09-01: Post-height-1000 operation is the standard integration baseline; + deployment restores mature checkpoints instead of treating genesis-only runs + as sufficient release coverage. diff --git a/deployment.sh b/deployment.sh @@ -122,9 +122,10 @@ run_release_tests() { ./scripts/check-dependencies.sh cargo test --locked cargo check --locked --manifest-path fuzz/Cargo.toml + ./e2e/iuna_e2e.py test snapshots if [ "$skip_long_tests" = "true" ]; then - echo "WARNING: skipping long-running adversarial, fuzz, and property test suites" + echo "WARNING: skipping long-running adversarial, fuzz, post-activation E2E, and property test suites" return 0 fi @@ -140,7 +141,8 @@ run_release_tests() { cargo run --locked --manifest-path fuzz/Cargo.toml --bin stratum_request -- -runs="$fuzz_runs" fuzz/corpus/stratum_request cargo run --locked --manifest-path fuzz/Cargo.toml --bin wallet_config -- -runs="$fuzz_runs" fuzz/corpus/wallet_config cargo run --locked --manifest-path fuzz/Cargo.toml --bin vdf_proof -- -runs="$vdf_fuzz_runs" fuzz/corpus/vdf_proof - cargo test --locked --release --test properties -- --ignored + cargo test --locked --release --features e2e --test properties -- --ignored + ./e2e/iuna_e2e.py test post-activation --build } update_versions() { diff --git a/e2e/README.md b/e2e/README.md @@ -40,6 +40,15 @@ Run the complete assertion suite (build the image on the first scenario): ./e2e/iuna_e2e.py test --build ``` +Run the standard post-activation gate used by deployment: + +```sh +./e2e/iuna_e2e.py test post-activation --build +``` + +This verifies the committed checkpoints, crosses 999 through 1001, then restores +the first objective checkpoint and advances the restarted network through 1007. + Tests can also be selected individually: ```sh diff --git a/e2e/iuna_e2e.py b/e2e/iuna_e2e.py @@ -80,6 +80,7 @@ SCENARIOS = { leader_burn_minimum_height=1_002, ), } +POST_ACTIVATION_SCENARIOS = ("objective-finality", "checkpoint-restart") class E2EError(RuntimeError): @@ -656,7 +657,13 @@ def run_scenario( def run_tests(name: str, timeout: float, build: bool, keep: bool) -> None: if name in ("snapshots", "all"): test_snapshots() - selected = list(SCENARIOS) if name == "all" else [name] + if name == "all": + selected = list(SCENARIOS) + elif name == "post-activation": + test_snapshots() + selected = list(POST_ACTIVATION_SCENARIOS) + else: + selected = [name] selected = [scenario_name for scenario_name in selected if scenario_name != "snapshots"] for index, scenario_name in enumerate(selected): scenario_keep = keep and index == len(selected) - 1 @@ -715,7 +722,7 @@ def parser() -> argparse.ArgumentParser: "scenario", nargs="?", default="all", - choices=("all", "snapshots", *SCENARIOS), + choices=("all", "post-activation", "snapshots", *SCENARIOS), ) tests.add_argument("--timeout", type=float, default=600) tests.add_argument("--build", action="store_true") diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs @@ -3060,16 +3060,21 @@ fn local_testnet_lineage_with_an_eligible_ticket_is_immediately_required_by_rank let leader = harness.next_rank(0); let finalizer = harness.wallet(&leader.owner).clone(); harness.submit_anchor_burn(&finalizer); - let missing_bundle_block = harness.finish_ticket_block_from_pending(0, Vec::new()); + let timestamp = harness + .ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS); let error = harness .ledger - .apply_block_at( - missing_bundle_block, - NOW_MS.saturating_add(VDF_TARGET_BLOCK_MS), - ) + .prepare_next_block_with_burn_bundles(finalizer.address(), timestamp, Vec::new()) .unwrap_err(); - assert!(error.to_string().contains("too few burn bundle signatures")); + assert!( + error + .to_string() + .contains("not enough burn bundle signatures collected") + ); } #[test] diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs @@ -115,12 +115,25 @@ impl Ledger { .into_iter() .map(|member| (member.slot, member)) .collect::<BTreeMap<_, _>>(); - self.validate_burn_bundles_for_committee( + let validated = self.validate_burn_bundles_for_committee( expected_height, &expected_prev_hash, &committee, bundles, - ) + )?; + let required_signatures = self.required_explicit_burn_signatures( + expected_height, + FinalizerMode::Ticket, + finalizer_rank, + committee.len(), + ); + let explicit_signatures = validated.iter().filter(|bundle| bundle.slot != 0).count(); + if explicit_signatures < required_signatures { + bail!( + "not enough burn bundle signatures collected: got {explicit_signatures}, need {required_signatures}" + ); + } + Ok(validated) } pub(crate) fn precheck_next_block_burn_bundle(&self, bundle: &BurnBundle) -> Result<()> { @@ -485,7 +498,9 @@ mod tests { use std::collections::BTreeMap; use super::*; - use crate::domain::{GenesisBurn, MICRO_IUNA, OutPoint, TxInput, TxOutput, Wallet}; + use crate::domain::{ + GenesisBurn, MICRO_IUNA, OutPoint, TxInput, TxOutput, UtxoLineageRoot, Wallet, + }; fn ledger() -> Ledger { Ledger::new(BTreeMap::new(), 1) @@ -579,6 +594,52 @@ mod tests { } #[test] + fn finalizer_waits_until_the_required_burn_bundle_quorum_is_available() { + let wallets = (0..3) + .map(|index| Wallet::from_seed(&format!("bundle-quorum-wallet-{index}"))) + .collect::<Vec<_>>(); + let mut ledger = funded_ledger(&wallets); + ledger.launch_profile.burn_lineage_maturity_heights = 0; + for (index, wallet) in wallets.iter().enumerate() { + let outpoint = OutPoint { + txid: format!("{:064x}", index + 1), + index: 0, + }; + let root = UtxoLineageRoot { + outpoint: outpoint.clone(), + height: 0, + }; + ledger.lineage_values.insert(root.clone(), 10); + ledger.lineage_owners.insert( + root, + BTreeMap::from([( + wallet.address().to_string(), + BTreeMap::from([(outpoint, 10)]), + )]), + ); + } + let committee_size = ledger.burn_committee_for_next_ticket_block(0).len(); + assert!( + ledger.required_explicit_burn_signatures( + ledger.height() + 1, + FinalizerMode::Ticket, + 0, + committee_size, + ) > 0 + ); + + let error = ledger + .validate_next_block_burn_bundles_for_finalizer_rank(0, Vec::new()) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("not enough burn bundle signatures collected") + ); + } + + #[test] fn only_post_activation_rank_zero_quorum_certifies_its_parent() { let ledger = ledger(); let mut block = ledger.tip().clone(); diff --git a/tests/properties.rs b/tests/properties.rs @@ -1,15 +1,20 @@ use std::{ - collections::BTreeMap, net::{Ipv4Addr, SocketAddr, TcpListener as StdTcpListener}, + path::Path, sync::Arc, time::Duration, }; use anyhow::{Context, Result, bail}; use iuna::{ - adapters::{chain_store::SqliteChainStore, p2p::GossipNetwork, stratum::StratumServer}, + adapters::{ + chain_store::SqliteChainStore, p2p::GossipNetwork, stratum::StratumServer, wallet_store, + }, app::{NodeCore, PeerBook, SharedNode, now_ms}, - domain::{GenesisBurn, Ledger, MICRO_IUNA, Wallet, run_vdf}, + domain::{ + Ledger, OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, Transaction, VDF_TARGET_BLOCK_MS, Wallet, + run_vdf, + }, }; use serde_json::{Value, json}; use tempfile::tempdir; @@ -21,15 +26,14 @@ use tokio::{ }; const SOAK_BLOCKS: u64 = 12; -const BURN_COLLECTION_MS: u64 = 31_000; +const BURN_COLLECTION_MS: u64 = VDF_TARGET_BLOCK_MS / 20 + 1; +const SOAK_START_HEIGHT: u64 = OBJECTIVE_FINALITY_ACTIVATION_HEIGHT + 1; +const FIXTURE_SERVICES: [&str; 6] = ["bootstrap", "node2", "node3", "node4", "node5", "node6"]; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -#[ignore = "long-running release-mode soak; run with cargo test --release --test properties -- --ignored"] -async fn release_soak_auto_finalization_p2p_stratum_and_restarts() -> Result<()> { - let wallets = (0..3) - .map(|index| Wallet::from_seed(&format!("release-soak-wallet-{index}"))) - .collect::<Vec<_>>(); - let genesis = funded_ledger(&wallets); +#[ignore = "long-running post-activation soak; run with cargo test --release --features e2e --test properties -- --ignored"] +async fn release_soak_post_activation_auto_finalization_p2p_stratum_and_restarts() -> Result<()> { + let (wallets, genesis) = post_activation_fixture()?; let p2p_addrs = reserve_loopback_addrs(wallets.len())?; let stratum_addr = reserve_loopback_addrs(1)?.remove(0); let store_dirs = (0..wallets.len()) @@ -85,7 +89,7 @@ async fn release_soak_auto_finalization_p2p_stratum_and_restarts() -> Result<()> assert_stratum_serves_work(stratum_addr, &stratum_worker).await?; sleep(Duration::from_secs(2)).await; - for target_height in 1..=SOAK_BLOCKS { + for target_height in (SOAK_START_HEIGHT + 1)..=(SOAK_START_HEIGHT + SOAK_BLOCKS) { finalize_one_block(&nodes, target_height).await?; wait_for_convergence(&nodes, target_height, Duration::from_secs(8)).await?; @@ -101,7 +105,15 @@ async fn release_soak_auto_finalization_p2p_stratum_and_restarts() -> Result<()> let final_tip = nodes[0].node.lock().await.chain_tip_hash(); for node in &nodes { - assert_eq!(node.node.lock().await.chain_tip_hash(), final_tip); + let core = node.node.lock().await; + assert_eq!(core.chain_tip_hash(), final_tip); + assert!(core.chain_height() > OBJECTIVE_FINALITY_ACTIVATION_HEIGHT); + assert!( + core.status() + .chain + .finalized_height + .is_some_and(|height| height >= OBJECTIVE_FINALITY_ACTIVATION_HEIGHT) + ); } assert!(configured_automatic_burn_was_included(&nodes[0]).await); Ok(()) @@ -115,16 +127,37 @@ struct SoakNode { store: SqliteChainStore, } -fn funded_ledger(wallets: &[Wallet]) -> Ledger { - let allocations = wallets - .iter() - .map(|wallet| (wallet.address().to_string(), 100 * MICRO_IUNA)) - .collect::<BTreeMap<_, _>>(); - let genesis_burns = wallets +fn post_activation_fixture() -> Result<(Vec<Wallet>, Ledger)> { + if !cfg!(feature = "e2e") { + bail!("post-activation soak requires --features e2e"); + } + + let fixture = + Path::new(env!("CARGO_MANIFEST_DIR")).join("e2e/snapshots/first-objective-checkpoint"); + let wallets = FIXTURE_SERVICES .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect::<Vec<_>>(); - Ledger::new_with_genesis_burns(allocations, genesis_burns, 1).unwrap() + .map(|service| { + wallet_store::load_with_password( + &fixture.join(service).join("wallet.json"), + "testtesttest", + ) + }) + .collect::<Result<Vec<_>>>()?; + // SQLite may create journals while opening a database; never open the committed fixture in place. + let chain_copy_dir = tempdir()?; + let chain_copy = chain_copy_dir.path().join("chain.sqlite3"); + std::fs::copy(fixture.join("bootstrap/chain.sqlite3"), &chain_copy)?; + let snapshot = SqliteChainStore::open(chain_copy)? + .load()? + .context("post-activation checkpoint has no chain snapshot")?; + let ledger = Ledger::from_persisted_snapshot(snapshot)?; + if ledger.height() != SOAK_START_HEIGHT { + bail!( + "post-activation checkpoint height is {}, expected {SOAK_START_HEIGHT}", + ledger.height() + ); + } + Ok((wallets, ledger)) } fn reserve_loopback_addrs(count: usize) -> Result<Vec<SocketAddr>> { @@ -181,8 +214,11 @@ async fn finalize_one_block(nodes: &[SoakNode], target_height: u64) -> Result<() let start = now_ms().saturating_sub(BURN_COLLECTION_MS + 1); prepare_and_broadcast(nodes, start).await?; sleep(Duration::from_millis(250)).await; - let timestamp_ms = now_ms(); - prepare_and_broadcast(nodes, timestamp_ms).await?; + // Post-activation finalizers must see the complete committee quorum before preparing VDF work. + for _ in 0..3 { + prepare_and_broadcast(nodes, now_ms()).await?; + sleep(Duration::from_millis(100)).await; + } let deadline = tokio::time::Instant::now() + Duration::from_secs(8); loop { @@ -221,10 +257,32 @@ async fn soak_diagnostics(nodes: &[SoakNode], target_height: u64) -> String { }); let pending = core.pending_transactions(); let pending_burns = pending.iter().filter(|tx| tx.is_burn()).count(); + let pending_burn_details = pending + .iter() + .filter(|tx| tx.is_burn()) + .map(|tx| { + let Transaction::Burn { anchor, .. } = tx else { + unreachable!(); + }; + let eligible_anchor = core.chain()[core.chain().len() - 1].prev_hash.as_str(); + format!( + "sender={}, amount={}, anchor={:?}, eligible={}", + tx.sender(), + tx.amount(), + anchor, + anchor.as_deref() == Some(eligible_anchor) + ) + }) + .collect::<Vec<_>>(); + let wallet_view_pending = core + .wallet_view_ledger() + .map(|ledger| ledger.pending().len()) + .unwrap_or_default(); let metrics = node.network.metrics(); lines.push(format!( "node {index}: height={}, tip={}, wallet_rank={rank:?}, leader={:?}, \ - last_finalization={:?}, pending={} (burns={pending_burns}), \ + last_finalization={:?}, pending={} (burns={pending_burns}, \ + wallet_view={wallet_view_pending}, details={pending_burn_details:?}), \ burn_bundles_received={}, control_received={}, rejected_blocks={}, \ session_failures={}, last_session_failure={:?}, last_chain_error={:?}, \ sync_progress={:?}", @@ -305,6 +363,7 @@ async fn configured_automatic_burn_was_included(node: &SoakNode) -> bool { .await .chain() .iter() + .filter(|block| block.height > SOAK_START_HEIGHT) .flat_map(|block| &block.transactions) .any(|tx| tx.is_burn() && tx.sender() == node.wallet.address() && tx.amount() == 2) }