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:
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)
}