iuna

iuna

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

commit 91c7d10b41226f9f6e1063ce14f90c048559f41d
parent 629575e02c5cc98ee64b4e7dbd15b52fa61fb64f
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Mon, 24 Aug 2026 22:05:28 +0200

Add network reset migration flow

Diffstat:
MROADMAP.md | 2+-
Mdocs/genesis.md | 2+-
Mdocs/protocol.md | 6+++---
Mfuzz/corpus/p2p_envelope/hello.json | 2+-
Msrc/adapters/http/actions.rs | 6+++++-
Msrc/adapters/http/index_html.rs | 20++++++++++++++++++++
Msrc/adapters/http/wallet.rs | 6++++--
Msrc/app.rs | 9+++++----
Msrc/app/node_lifecycle.rs | 13+++++++++++++
Msrc/app/receive.rs | 6++++++
Msrc/app/status.rs | 25++++++++++++++++++++++++-
Msrc/app/types.rs | 8++++++++
Msrc/domain/profile.rs | 11+++++------
Msrc/main.rs | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/main_tests.rs | 46+++++++++++++++++++++++++++++++++++++++++-----
Mwww/assets/iuna-ui.js | 38++++++++++++++++++++++++++++++++++++--
16 files changed, 227 insertions(+), 38 deletions(-)

diff --git a/ROADMAP.md b/ROADMAP.md @@ -98,7 +98,7 @@ Focus: promote the stable candidate ledger. Mainnet launch should not create a s - [ ] Tag the mainnet release from the promoted candidate code line. - [ ] Publish release artifacts and checksums. - [ ] Upgrade or restart bootnodes on the mainnet release while preserving chain data. -- [ ] If the P2P network ID changes from `iuna-mainnet-candidate-v1` to `iuna-mainnet-v1`, coordinate the cutover without changing genesis or launch profile rules. +- [ ] If the P2P network ID changes from `iuna-mainnet-candidate` to `iuna-mainnet-v1`, coordinate the cutover without changing genesis or launch profile rules. - [ ] Monitor first blocks and first recovery/fallback events. - [ ] Keep feature changes frozen during the launch window. - [ ] Document any required hard-fork or emergency procedure before launch. diff --git a/docs/genesis.md b/docs/genesis.md @@ -230,7 +230,7 @@ If the candidate passes the stability window, publish a promotion notice instead - promoted tip height and tip hash; - final candidate release tag and mainnet release tag; - bootnodes that will remain online through the cutover; -- whether the P2P network ID changes from `iuna-mainnet-candidate-v1` to `iuna-mainnet-v1`; +- whether the P2P network ID changes from `iuna-mainnet-candidate` to `iuna-mainnet-v1`; - the exact upgrade window for operators. Do not delete chain data when promoting. Nodes should keep `chain.sqlite3`, wallet files, and UI data, then upgrade or restart with the promoted release. A P2P network ID change fences upgraded mainnet nodes away from old candidate binaries, but it must not change genesis, launch profile rules, or any existing ledger state. diff --git a/docs/protocol.md b/docs/protocol.md @@ -16,10 +16,10 @@ This is still experimental. The rules below describe the current devnet and main The current mainnet-candidate parameter set is intentionally close to Bitcoin where that is useful for operator expectations: -- P2P network ID: `iuna-mainnet-candidate-v1`; +- P2P network ID: `iuna-mainnet-candidate`; - protocol version: `2`; -- launch profile ID: `iuna-mainnet-candidate-v1`; -- launch profile hash: `aef51531eaa3a5c5d3ea8a2524ffba029dcb106e4b0a432b57d5ac1f4f8963de`; +- launch profile ID: `iuna-mainnet-candidate`; +- launch profile hash: `eb2f67e9d735474859ceb1fe124fe270977214f6e2f4cd855a4d8c3b5ecac558`; - target block time: `10 minutes`; - maximum serialized block size: `1,000,000` bytes; - maximum transaction items per block: `1,000`; diff --git a/fuzz/corpus/p2p_envelope/hello.json b/fuzz/corpus/p2p_envelope/hello.json @@ -1 +1 @@ -{"type":"hello","protocol_version":1,"network_id":"iuna-mainnet-candidate-v1","genesis_hash":"0000000000000000000000000000000000000000000000000000000000000000","listen_addr":null,"node_id":null,"height":0,"tip_hash":"0000000000000000000000000000000000000000000000000000000000000000","time_ms":0} +{"type":"hello","protocol_version":1,"network_id":"iuna-mainnet-candidate","genesis_hash":"0000000000000000000000000000000000000000000000000000000000000000","listen_addr":null,"node_id":null,"height":0,"tip_hash":"0000000000000000000000000000000000000000000000000000000000000000","time_ms":0} diff --git a/src/adapters/http/actions.rs b/src/adapters/http/actions.rs @@ -325,6 +325,7 @@ pub(super) async fn reset_local_chain(state: &HttpState, confirmation: &str) -> } clear_chain(&state.chain_store).await?; clear_ui_data(&state.ui_data_store).await?; + state.node.lock().await.complete_network_migration(); state .gossip .broadcast(vec![GossipEnvelope::ChainBootstrapRequest]) @@ -563,7 +564,9 @@ mod tests { chain_store.save(&snapshot).unwrap(); ui_data_store.project_snapshot(&snapshot, true).unwrap(); - let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0))); + let mut node_core = NodeCore::from_ledger(wallet, ledger, 0); + node_core.require_network_migration("iuna-devnet-v5"); + let node = Arc::new(Mutex::new(node_core)); let peers = Arc::new(Mutex::new(PeerBook::default())); let gossip = GossipNetwork::start(node.clone(), peers.clone(), socket(), None, false) .await @@ -590,6 +593,7 @@ mod tests { .unwrap(); assert!(!state.node.lock().await.has_real_chain()); + assert!(state.node.lock().await.network_migration_from().is_none()); assert!(chain_store.load().unwrap().is_none()); assert!(ui_data_store.load_metrics().unwrap().is_empty()); assert_eq!(std::fs::read(&wallet_path).unwrap(), wallet_before); diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -1280,6 +1280,24 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> </section> </main> </div> + <div class="setup-overlay" x-show="showingNetworkMigration()" x-transition.opacity role="dialog" aria-modal="true" aria-labelledby="network-migration-title"> + <section class="setup-modal"> + <div class="setup-modal-head"> + <div class="setup-welcome">A new Iuna network is ready 🎉</div> + <h2 id="network-migration-title">Start from block 0</h2> + <div class="setup-copy">This version connects to <strong x-text="migrationNetworkLabel()"></strong>. To join it, this node must remove its previous local blockchain and synchronize again.</div> + </div> + <div class="panel"> + <h3>Your wallet is separate</h3> + <p class="muted">Keeping your current wallet preserves its address and recovery phrase. Removing the local chain does not remove your wallet or settings.</p> + <div class="setup-actions"> + <button class="primary" type="button" :disabled="migrationBusy" @click="finishNetworkMigration(false)" x-text="migrationBusy ? 'Preparing...' : 'Keep wallet and resync'"></button> + <button class="subtle" type="button" :disabled="migrationBusy" @click="finishNetworkMigration(true)">Use a different wallet</button> + </div> + <p class="danger-copy">Choose a different wallet only if you want a new address or need to import another recovery phrase. Without the old recovery phrase, its funds cannot be recovered on your new address.</p> + </div> + </section> + </div> <div class="sync-overlay" x-show="syncingNode()" role="status" aria-live="polite" aria-label="Blockchain synchronization in progress"> <section class="sync-screen"> <div class="sync-mark" aria-hidden="true"><div class="sync-spinner"></div></div> @@ -1289,6 +1307,8 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div class="sync-progress-fill" :style="`width: ${syncProgressPercent()}%`"></div> </div> <div class="sync-progress-label" x-text="syncProgressLabel()"></div> + <button class="subtle" type="button" @click="openChainResetModal">Sync stuck? Reset local chain</button> + <p class="muted">This recovery option removes only the local blockchain. Your wallet and settings stay on this device.</p> </section> </div> <div class="setup-overlay transaction-overlay" x-show="chainResetModalOpen" x-transition.opacity @click.self="closeChainResetModal()" role="dialog" aria-modal="true" aria-labelledby="chain-reset-title"> diff --git a/src/adapters/http/wallet.rs b/src/adapters/http/wallet.rs @@ -26,7 +26,8 @@ pub(super) async fn wallet_setup_response( ) -> Result<WalletSetupResponse> { let setup_complete = state.ui_config.lock().await.setup_complete; let password = wallet_password_for_request(state, headers).await; - let seed_phrase = if setup_complete { + let migration_required = state.node.lock().await.network_migration_from().is_some(); + let seed_phrase = if setup_complete && !migration_required { None } else { wallet_store::setup_seed_phrase_with_password(&state.wallet_path, password.as_deref())? @@ -96,7 +97,8 @@ pub(super) async fn import_setup_wallet_seed( async fn ensure_wallet_setup_open(state: &HttpState) -> Result<()> { let setup_complete = state.ui_config.lock().await.setup_complete; - if setup_complete { + let migration_required = state.node.lock().await.network_migration_from().is_some(); + if setup_complete && !migration_required { bail!("wallet setup is already complete"); } Ok(()) diff --git a/src/app.rs b/src/app.rs @@ -29,8 +29,8 @@ pub use in_memory_network::InMemoryNetwork; pub use peer_book::{PeerBook, PeerDirection, PeerInfo}; pub use types::{ AutoMineOutcome, AutoMinePlan, BlockInventory, ChainBootstrap, ExternalMineJob, FeeEstimate, - GossipEnvelope, LaunchProfileStatus, MiningStatus, NodeConfig, NodeStatus, ProtocolHello, - StratumStatus, + GossipEnvelope, LaunchProfileStatus, MiningStatus, NetworkMigrationStatus, NodeConfig, + NodeStatus, ProtocolHello, StratumStatus, }; use wallet::NodeWallet; @@ -40,7 +40,7 @@ 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 = 2; -pub const MAINNET_CANDIDATE_NETWORK_ID: &str = "iuna-mainnet-candidate-v1"; +pub const MAINNET_CANDIDATE_NETWORK_ID: &str = "iuna-mainnet-candidate"; 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; @@ -68,7 +68,7 @@ mod tests { fn mainnet_candidate_network_parameters_are_frozen() { assert_eq!(DEFAULT_VDF_ROUNDS, 67_000_000); assert_eq!(PROTOCOL_VERSION, 2); - assert_eq!(MAINNET_CANDIDATE_NETWORK_ID, "iuna-mainnet-candidate-v1"); + assert_eq!(MAINNET_CANDIDATE_NETWORK_ID, "iuna-mainnet-candidate"); 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); @@ -140,6 +140,7 @@ pub struct NodeCore { burn_bundle_collection_started: Option<(u64, u64)>, local_block_anchor_burn: Option<(u64, Transaction)>, outbox: Vec<GossipEnvelope>, + network_migration_from: Option<String>, } pub fn now_ms() -> u64 { diff --git a/src/app/node_lifecycle.rs b/src/app/node_lifecycle.rs @@ -107,9 +107,22 @@ impl NodeCore { burn_bundle_collection_started: None, local_block_anchor_burn: None, outbox: Vec::<GossipEnvelope>::new(), + network_migration_from: None, } } + pub fn require_network_migration(&mut self, from_network: impl Into<String>) { + self.network_migration_from = Some(from_network.into()); + } + + pub fn network_migration_from(&self) -> Option<&str> { + self.network_migration_from.as_deref() + } + + pub fn complete_network_migration(&mut self) { + self.network_migration_from = None; + } + pub fn wallet_address(&self) -> &str { self.wallet.address() } diff --git a/src/app/receive.rs b/src/app/receive.rs @@ -143,6 +143,9 @@ impl NodeCore { } pub fn import_chain_snapshot(&mut self, snapshot: ChainSnapshot) -> Result<()> { + if self.network_migration_from().is_some() { + anyhow::bail!("local chain reset is required before joining the new network"); + } let previous_height = self.ledger.height(); let imported = self.ledger.extend_from_snapshot(snapshot)?; if imported { @@ -156,6 +159,9 @@ impl NodeCore { } pub(crate) fn import_verified_ledger(&mut self, ledger: Ledger) -> Result<bool> { + if self.network_migration_from().is_some() { + anyhow::bail!("local chain reset is required before joining the new network"); + } let replaces_setup_placeholder = self.ledger.is_setup_placeholder() && ledger.genesis_hash() != self.ledger.genesis_hash(); if ledger.genesis_hash() != self.ledger.genesis_hash() && !replaces_setup_placeholder { diff --git a/src/app/status.rs b/src/app/status.rs @@ -6,7 +6,8 @@ use crate::{ }; use super::{ - LaunchProfileStatus, MiningStatus, NodeCore, NodeStatus, StratumStatus, + LaunchProfileStatus, MiningStatus, NETWORK_ID, NetworkMigrationStatus, NodeCore, NodeStatus, + StratumStatus, helpers::{transaction_input_total_from_outputs, transaction_output_total_for_address}, now_ms, }; @@ -60,6 +61,11 @@ impl NodeCore { listen_addr: None, }, chain, + network_migration: NetworkMigrationStatus { + required: self.network_migration_from().is_some(), + from_network: self.network_migration_from().map(str::to_string), + to_network: NETWORK_ID.to_string(), + }, } } @@ -212,4 +218,21 @@ mod tests { Some("waiting for a real chain before PoW mining can start") ); } + + #[test] + fn status_exposes_required_network_migration() { + let wallet = Wallet::from_seed("network-migration-status-wallet"); + let ledger = Ledger::new(BTreeMap::new(), 1); + let mut node = NodeCore::from_ledger(wallet, ledger, 0); + + node.require_network_migration("iuna-devnet-v5"); + let status = node.status(); + + assert!(status.network_migration.required); + assert_eq!( + status.network_migration.from_network.as_deref(), + Some("iuna-devnet-v5") + ); + assert_eq!(status.network_migration.to_network, crate::app::NETWORK_ID); + } } diff --git a/src/app/types.rs b/src/app/types.rs @@ -132,6 +132,14 @@ pub struct NodeStatus { pub mining: MiningStatus, pub stratum: StratumStatus, pub chain: ChainStatus, + pub network_migration: NetworkMigrationStatus, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct NetworkMigrationStatus { + pub required: bool, + pub from_network: Option<String>, + pub to_network: String, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] diff --git a/src/domain/profile.rs b/src/domain/profile.rs @@ -25,7 +25,7 @@ pub struct LaunchProfile { impl Default for LaunchProfile { fn default() -> Self { Self { - profile_id: "iuna-mainnet-candidate-v1".to_string(), + profile_id: "iuna-mainnet-candidate".to_string(), ticket_maturity_delay_heights: DEFAULT_TICKET_MATURITY_DELAY, ticket_expiry_window_heights: DEFAULT_TICKET_EXPIRY_WINDOW, mine_difficulty_bits: MINE_DIFFICULTY_BITS, @@ -64,9 +64,8 @@ impl LaunchProfile { pub fn hash(&self) -> String { let canonical = if self.burn_lineage_maturity_heights == BURN_LINEAGE_MATURITY_HEIGHTS { - // Preserve the existing mainnet-candidate profile hash. The maturity - // used to be a frozen protocol constant, so including its unchanged - // default would needlessly split the existing network. + // Preserve the legacy canonical format when maturity remains at its + // protocol default. The field used to be a frozen constant. format!( "iuna-launch-profile:{}:{}:{}:{}:{}:{}:{}", self.profile_id, @@ -121,7 +120,7 @@ mod tests { fn default_launch_profile_matches_protocol_defaults() { let profile = LaunchProfile::default(); - assert_eq!(profile.profile_id, "iuna-mainnet-candidate-v1"); + assert_eq!(profile.profile_id, "iuna-mainnet-candidate"); assert_eq!( profile.ticket_maturity_delay_heights, DEFAULT_TICKET_MATURITY_DELAY @@ -140,7 +139,7 @@ mod tests { assert_eq!(profile.max_block_bytes, MAX_BLOCK_BYTES); assert_eq!( profile.hash(), - "aef51531eaa3a5c5d3ea8a2524ffba029dcb106e4b0a432b57d5ac1f4f8963de" + "eb2f67e9d735474859ceb1fe124fe270977214f6e2f4cd855a4d8c3b5ecac558" ); } diff --git a/src/main.rs b/src/main.rs @@ -121,7 +121,7 @@ async fn main() -> Result<()> { if ui_config_dirty || auth_config_dirty { config_store::save(&config_path, &ui_config)?; } - let ledger = initialize_ledger( + let initialized_ledger = initialize_ledger( &opts, &wallet_address, &chain_store, @@ -129,7 +129,10 @@ async fn main() -> Result<()> { startup_local_testnet, ) .await?; - let has_chain = opts.has_chain() || persisted_chain_exists; + let migration_from = initialized_ledger.migration_from.clone(); + let migration_required = migration_from.is_some(); + let ledger = initialized_ledger.ledger; + let has_chain = migration_from.is_none() && (opts.has_chain() || persisted_chain_exists); let initial_burn_per_block = initial_burn_per_block(&opts, &ui_config); let initial_burn_fee = initial_burn_fee(&opts, &ui_config); @@ -152,6 +155,13 @@ async fn main() -> Result<()> { node_core.set_pow_mining_workers(ui_config.pow_mining_workers); node_core.set_pow_mining_enabled(ui_config.pow_mining_enabled); node_core.set_recovery_vdf_top_rank_percent(ui_config.recovery_vdf_top_rank_percent); + if let Some(from_network) = migration_from { + println!( + "network upgrade requires local chain reset: {from_network} -> {}", + iuna::app::NETWORK_ID + ); + node_core.require_network_migration(from_network); + } let node: SharedNode = Arc::new(Mutex::new(node_core)); let ui_config = Arc::new(Mutex::new(ui_config)); let mut peers = ui_config.lock().await.peers.clone(); @@ -162,7 +172,7 @@ async fn main() -> Result<()> { let keep_metrics = ui_config.lock().await.keep_track_of_metrics; persist_chain_snapshot(&chain_store, initial_snapshot.clone()).await?; warm_ui_data_store(&ui_data_store, initial_snapshot, keep_metrics).await?; - } else { + } else if !migration_required { clear_ui_data_store(&ui_data_store).await?; } @@ -475,7 +485,7 @@ async fn initialize_ledger( chain_store: &SqliteChainStore, advertised_p2p_addr: SocketAddr, local_testnet: bool, -) -> Result<Ledger> { +) -> Result<InitializedLedger> { if let Some(snapshot) = chain_store.load()? { if opts.chain_mode == ChainMode::Genesis { bail!( @@ -483,10 +493,16 @@ async fn initialize_ledger( chain_store.path().display() ); } - if local_testnet && snapshot.launch_profile != LaunchProfile::local_testnet() { - bail!( - "{LOCAL_TESTNET_ENV}=true requires the iuna-local-testnet-v1 launch profile; reset this local chain before restarting" - ); + let expected_profile = if local_testnet { + LaunchProfile::local_testnet() + } else { + LaunchProfile::default() + }; + if snapshot.launch_profile.profile_id != expected_profile.profile_id { + return Ok(InitializedLedger { + ledger: setup_ledger(), + migration_from: Some(snapshot.launch_profile.profile_id), + }); } let height = snapshot_height(&snapshot); let ledger = Ledger::from_persisted_snapshot(snapshot).with_context(|| { @@ -499,13 +515,40 @@ async fn initialize_ledger( "resumed chain from {} at height {height}", chain_store.path().display() ); - Ok(ledger) + Ok(InitializedLedger { + ledger, + migration_from: None, + }) } else { - match opts.chain_mode { + let ledger = match opts.chain_mode { ChainMode::Setup => Ok(setup_ledger()), ChainMode::Genesis => start_genesis_ledger(wallet_address, local_testnet), ChainMode::Join => join_chain_ledger(&opts.join_peers, advertised_p2p_addr).await, - } + }?; + Ok(InitializedLedger { + ledger, + migration_from: None, + }) + } +} + +#[derive(Debug)] +struct InitializedLedger { + ledger: Ledger, + migration_from: Option<String>, +} + +impl std::ops::Deref for InitializedLedger { + type Target = Ledger; + + fn deref(&self) -> &Self::Target { + &self.ledger + } +} + +impl std::ops::DerefMut for InitializedLedger { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.ledger } } diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -89,6 +89,10 @@ fn management_ui_blocks_interaction_while_the_node_is_syncing() { assert!(javascript.contains("this.networkHealth.sync_validated_height")); assert!(javascript.contains("this.syncingNode() ? 1000 : 5000")); assert!(javascript.contains("Syncing ${this.syncCurrentHeight().toLocaleString()} of")); + assert!(javascript.contains("!this.showingNetworkMigration()")); + assert!(javascript.contains("!this.chainResetModalOpen")); + assert!(html.contains("Sync stuck? Reset local chain")); + assert!(html.contains("@click=\"openChainResetModal\"")); } fn ledger_with_one_spendable_iuna(wallet: &Wallet) -> Ledger { @@ -900,7 +904,7 @@ async fn startup_resumes_persisted_chain_without_genesis_flag() { } #[tokio::test] -async fn local_testnet_refuses_persisted_chain_with_normal_launch_profile() { +async fn local_testnet_requests_reset_for_persisted_normal_launch_profile() { let dir = tempdir().unwrap(); let chain_path = dir.path().join("chain.sqlite3"); let store = SqliteChainStore::open(&chain_path).unwrap(); @@ -911,7 +915,7 @@ async fn local_testnet_refuses_persisted_chain_with_normal_launch_profile() { .unwrap() .unwrap(); - let error = initialize_ledger( + let initialized = initialize_ledger( &opts, persisted_wallet.address(), &store, @@ -919,14 +923,46 @@ async fn local_testnet_refuses_persisted_chain_with_normal_launch_profile() { true, ) .await - .unwrap_err(); + .unwrap(); + + assert_eq!( + initialized.migration_from.as_deref(), + Some("iuna-mainnet-candidate") + ); + assert!(initialized.ledger.is_setup_placeholder()); +} - assert!(error.to_string().contains("reset this local chain")); +#[tokio::test] +async fn startup_requests_reset_for_a_legacy_network_profile() { + let dir = tempdir().unwrap(); + let chain_path = dir.path().join("chain.sqlite3"); + let store = SqliteChainStore::open(&chain_path).unwrap(); + let wallet = Wallet::from_seed("legacy-network-owner"); + let mut snapshot = ledger_with_one_mined_block(&wallet).snapshot(); + snapshot.launch_profile.profile_id = "iuna-devnet-v5".to_string(); + store.save(&snapshot).unwrap(); + let opts = parse(&["--chain-db", chain_path.to_str().unwrap()]) + .unwrap() + .unwrap(); + + let initialized = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr, false) + .await + .unwrap(); + + assert_eq!( + initialized.migration_from.as_deref(), + Some("iuna-devnet-v5") + ); + assert!(initialized.ledger.is_setup_placeholder()); + assert_eq!( + store.load().unwrap().unwrap().launch_profile.profile_id, + "iuna-devnet-v5" + ); } #[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_CANDIDATE_NETWORK_ID, "iuna-mainnet-candidate"); assert_eq!(MAINNET_NETWORK_ID, "iuna-mainnet-v1"); assert_ne!(MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID); let dir = tempdir().unwrap(); diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -82,6 +82,7 @@ window.iunaApp = function iunaApp() { importSeedPhrase: "", walletVerified: false, setupFeedback: null, + migrationBusy: false, burnAmount: 100, burnAmountDraft: "0.0001", burnFee: 1, @@ -281,7 +282,38 @@ window.iunaApp = function iunaApp() { }, showingSetup() { - return this.authLoaded && !this.showingAuth() && !this.config.setup_complete; + return this.authLoaded && !this.showingAuth() && !this.showingNetworkMigration() && !this.config.setup_complete; + }, + + showingNetworkMigration() { + return this.authLoaded && !this.showingAuth() && this.status.network_migration?.required === true; + }, + + migrationNetworkLabel() { + return this.status.network_migration?.to_network || "the new Iuna network"; + }, + + async finishNetworkMigration(setUpDifferentWallet) { + this.migrationBusy = true; + try { + await this.submitForm("/api/settings/chain-reset", { confirm: "RESET" }); + if (setUpDifferentWallet) { + const response = await this.fetchWithTimeout("/api/config", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ setup_complete: "false", peer: "" }), + }); + const payload = await response.json(); + if (!response.ok || !payload.ok) throw new Error(payload.error || "Could not open wallet setup"); + this.config.setup_complete = false; + } + await this.refresh({ force: true }); + this.showFlash(setUpDifferentWallet ? "Local chain reset. Choose or import a wallet." : "Local chain reset. Sync requested from peers.", "success"); + } catch (error) { + this.showFlash(error.message, "error"); + } finally { + this.migrationBusy = false; + } }, showingAuth() { @@ -689,7 +721,7 @@ window.iunaApp = function iunaApp() { if (!this.allowedTabs().includes(this.tab)) { this.setTab("wallet"); } - if (!this.config.setup_complete) { + if (!this.config.setup_complete || status.network_migration?.required === true) { await this.refreshWalletSetup(); } this.syncMempoolBlockMarker(previousChainHeight, status.chain?.height); @@ -3018,6 +3050,8 @@ window.iunaApp = function iunaApp() { syncingNode() { return ( this.canUseProtectedApi() && + !this.showingNetworkMigration() && + !this.chainResetModalOpen && this.config.setup_complete === true && this.networkHealthLoaded && this.networkHealth.state === "syncing"