commit 5cb23d0d5bde68864e41bc6836e4f3631d68452a
parent 1d21300512bdd7ad9dffa9f3572cb302efc1ac79
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 19 Aug 2026 20:59:43 +0200
Add candidate promotion rehearsal
Diffstat:
3 files changed, 145 insertions(+), 5 deletions(-)
diff --git a/PLAN.md b/PLAN.md
@@ -148,6 +148,10 @@ Acceptance:
## 7. Candidate Promotion Rehearsal
+Status: started with explicit candidate/mainnet peer-network IDs and a restart
+rehearsal that preserves the candidate genesis, tip, launch profile, UTXOs,
+ticket ranks, and transfer history before mining the next promoted block.
+
Goal: prove candidate-to-mainnet promotion preserves coins.
Scenario:
diff --git a/src/app.rs b/src/app.rs
@@ -39,7 +39,9 @@ pub type SharedPeerBook = Arc<Mutex<PeerBook>>;
pub const DEFAULT_BURN_PER_BLOCK: Amount = 0;
pub const DEFAULT_VDF_ROUNDS: u32 = 67_000_000;
pub const PROTOCOL_VERSION: u32 = 1;
-pub const NETWORK_ID: &str = "iuna-mainnet-candidate-v1";
+pub const MAINNET_CANDIDATE_NETWORK_ID: &str = "iuna-mainnet-candidate-v1";
+pub const MAINNET_NETWORK_ID: &str = "iuna-mainnet-v1";
+pub const NETWORK_ID: &str = MAINNET_CANDIDATE_NETWORK_ID;
pub const BLOCK_REQUEST_LIMIT: usize = 128;
pub const TRANSACTION_BATCH_LIMIT: usize = 128;
const IMPORT_REBROADCAST_LIMIT: usize = 128;
@@ -57,15 +59,18 @@ static DEBUG_LOGGING: AtomicBool = AtomicBool::new(false);
#[cfg(test)]
mod tests {
use super::{
- BLOCK_REQUEST_LIMIT, DEFAULT_VDF_ROUNDS, NETWORK_ID, PROTOCOL_VERSION,
- TRANSACTION_BATCH_LIMIT,
+ BLOCK_REQUEST_LIMIT, DEFAULT_VDF_ROUNDS, MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID,
+ NETWORK_ID, PROTOCOL_VERSION, TRANSACTION_BATCH_LIMIT,
};
#[test]
fn mainnet_candidate_network_parameters_are_frozen() {
assert_eq!(DEFAULT_VDF_ROUNDS, 67_000_000);
assert_eq!(PROTOCOL_VERSION, 1);
- assert_eq!(NETWORK_ID, "iuna-mainnet-candidate-v1");
+ assert_eq!(MAINNET_CANDIDATE_NETWORK_ID, "iuna-mainnet-candidate-v1");
+ assert_eq!(MAINNET_NETWORK_ID, "iuna-mainnet-v1");
+ assert_ne!(MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID);
+ assert_eq!(NETWORK_ID, MAINNET_CANDIDATE_NETWORK_ID);
assert_eq!(BLOCK_REQUEST_LIMIT, 128);
assert_eq!(TRANSACTION_BATCH_LIMIT, 128);
}
diff --git a/src/main_tests.rs b/src/main_tests.rs
@@ -5,7 +5,7 @@ use iuna::{
chain_store::SqliteChainStore, config_store::UiConfig, ui_data_store::SqliteUiDataStore,
wallet_store,
},
- app::{DEFAULT_BURN_PER_BLOCK, NodeCore},
+ app::{DEFAULT_BURN_PER_BLOCK, MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID, NodeCore},
domain::{BLOCK_REWARD, GenesisBurn, Ledger, MICRO_IUNA, VDF_TARGET_BLOCK_MS, Wallet},
};
use rusqlite::Connection;
@@ -48,6 +48,67 @@ fn ledger_with_one_mined_block(wallet: &Wallet) -> Ledger {
ledger
}
+fn promoted_candidate_ledger() -> (Ledger, Vec<Wallet>, String) {
+ let wallets = [
+ Wallet::from_seed("promotion-candidate-alice"),
+ Wallet::from_seed("promotion-candidate-bob"),
+ Wallet::from_seed("promotion-candidate-carol"),
+ ];
+ let mut allocations = BTreeMap::new();
+ for wallet in &wallets {
+ allocations.insert(wallet.address().to_string(), 20 * MICRO_IUNA);
+ }
+ let mut ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ wallets
+ .iter()
+ .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA))
+ .collect(),
+ 1,
+ )
+ .unwrap();
+ let finalizer_owner = ledger
+ .burn_leader_ranks_for_block(ledger.height() + 1)
+ .unwrap()
+ .first()
+ .unwrap()
+ .owner
+ .clone();
+ let finalizer = wallets
+ .iter()
+ .find(|wallet| wallet.address() == finalizer_owner)
+ .unwrap();
+ let sender = wallets
+ .iter()
+ .find(|wallet| wallet.address() != finalizer.address())
+ .unwrap();
+ let recipient = wallets
+ .iter()
+ .find(|wallet| {
+ wallet.address() != finalizer.address() && wallet.address() != sender.address()
+ })
+ .unwrap();
+ let transfer = ledger
+ .build_transfer(sender, recipient.address(), MICRO_IUNA, MICRO_IUNA)
+ .unwrap();
+ let transfer_signature = transfer.signature().to_string();
+ ledger.submit_transaction(transfer).unwrap();
+ let anchor = ledger
+ .build_burn(finalizer, MICRO_IUNA, MICRO_IUNA)
+ .unwrap();
+ ledger.submit_transaction(anchor).unwrap();
+ let block = ledger
+ .mine_next_block(finalizer, VDF_TARGET_BLOCK_MS)
+ .unwrap();
+ ledger.apply_locally_mined_block(block).unwrap();
+ assert!(
+ ledger.has_transaction(&transfer_signature),
+ "candidate rehearsal chain should contain a transfer before promotion"
+ );
+
+ (ledger, wallets.to_vec(), transfer_signature)
+}
+
#[test]
fn encrypted_startup_wallet_loads_as_locked_metadata() {
let dir = tempdir().unwrap();
@@ -545,6 +606,76 @@ async fn startup_resumes_persisted_chain_without_genesis_flag() {
}
#[tokio::test]
+async fn candidate_promotion_reuses_chain_data_and_can_continue_mining() {
+ assert_eq!(MAINNET_CANDIDATE_NETWORK_ID, "iuna-mainnet-candidate-v1");
+ assert_eq!(MAINNET_NETWORK_ID, "iuna-mainnet-v1");
+ assert_ne!(MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID);
+ let dir = tempdir().unwrap();
+ let chain_path = dir.path().join("chain.sqlite3");
+ let store = SqliteChainStore::open(&chain_path).unwrap();
+ let (candidate, wallets, transfer_signature) = promoted_candidate_ledger();
+ let candidate_genesis = candidate.genesis_hash().to_string();
+ let candidate_tip = candidate.tip_hash().to_string();
+ let candidate_profile = candidate.launch_profile().clone();
+ let candidate_utxos = candidate.all_utxos();
+ let candidate_next_ranks = candidate
+ .burn_leader_ranks_for_block(candidate.height() + 1)
+ .unwrap();
+ store.save(&candidate.snapshot()).unwrap();
+ let opts = parse(&["--chain-db", chain_path.to_str().unwrap()])
+ .unwrap()
+ .unwrap();
+
+ let mut promoted = initialize_ledger(&opts, wallets[0].address(), &store, opts.p2p_addr)
+ .await
+ .unwrap();
+
+ assert_eq!(promoted.genesis_hash(), candidate_genesis);
+ assert_eq!(promoted.tip_hash(), candidate_tip);
+ assert_eq!(promoted.launch_profile(), &candidate_profile);
+ assert_eq!(promoted.all_utxos(), candidate_utxos);
+ assert_eq!(
+ promoted
+ .burn_leader_ranks_for_block(promoted.height() + 1)
+ .unwrap(),
+ candidate_next_ranks
+ );
+ assert!(promoted.has_transaction(&transfer_signature));
+
+ let next_owner = promoted
+ .burn_leader_ranks_for_block(promoted.height() + 1)
+ .unwrap()
+ .first()
+ .unwrap()
+ .owner
+ .clone();
+ let next_finalizer = wallets
+ .iter()
+ .find(|wallet| wallet.address() == next_owner)
+ .unwrap();
+ let anchor = promoted
+ .build_burn(next_finalizer, MICRO_IUNA, MICRO_IUNA)
+ .unwrap();
+ promoted.submit_transaction(anchor).unwrap();
+ let next_block = promoted
+ .mine_next_block(
+ next_finalizer,
+ promoted
+ .chain()
+ .last()
+ .unwrap()
+ .timestamp_ms
+ .saturating_add(VDF_TARGET_BLOCK_MS),
+ )
+ .unwrap();
+ promoted.apply_locally_mined_block(next_block).unwrap();
+
+ assert_eq!(promoted.genesis_hash(), candidate_genesis);
+ assert_eq!(promoted.height(), candidate.height() + 1);
+ assert_ne!(promoted.tip_hash(), candidate_tip);
+}
+
+#[tokio::test]
async fn startup_resumes_persisted_chain_with_network_accepted_future_tip() {
let dir = tempdir().unwrap();
let chain_path = dir.path().join("chain.sqlite3");