iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 629ddcef24f91d1f2093e0f9237b0ee4489f24df
parent ff7c727514f561265b8a3bf98288809ae2b26b42
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 11 Aug 2026 09:32:54 +0200

Add local chain reset action

Diffstat:
Msrc/adapters/chain_store.rs | 17+++++++++++++++++
Msrc/adapters/chain_store/tests.rs | 25+++++++++++++++++++++++++
Msrc/adapters/http.rs | 15++++++++-------
Msrc/adapters/http/actions.rs | 46+++++++++++++++++++++++++++++++++++++++++++---
Msrc/adapters/http/index_html.rs | 41++++++++++++++++++++++++++++++++++++++++-
Msrc/adapters/http/tests.rs | 55++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/adapters/http/types.rs | 5+++++
Msrc/app/node_lifecycle.rs | 13+++++++++++++
Mwww/assets/iuna-ui.js | 61++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
9 files changed, 265 insertions(+), 13 deletions(-)

diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -269,6 +269,23 @@ ON CONFLICT(id) DO UPDATE SET }) } + pub fn clear_chain(&self) -> Result<()> { + self.with_connection_mut(|connection| { + let transaction = connection + .transaction() + .context("failed to start chain reset transaction")?; + transaction + .execute("DELETE FROM chain_snapshots", []) + .context("failed to delete chain snapshot")?; + clear_metrics_in_transaction(&transaction)?; + clear_ui_chain_index_in_transaction(&transaction)?; + transaction + .commit() + .context("failed to commit chain reset transaction")?; + Ok(()) + }) + } + pub fn load_metrics(&self) -> Result<Vec<BlockMetricRow>> { self.with_connection(|connection| { let mut statement = connection diff --git a/src/adapters/chain_store/tests.rs b/src/adapters/chain_store/tests.rs @@ -67,6 +67,31 @@ fn sqlite_chain_store_persists_ui_chain_index_for_latest_tip() { } #[test] +fn sqlite_chain_store_clear_chain_removes_snapshot_metrics_and_ui_indexes() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("clear-chain-alice"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 10); + let ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) + .unwrap(); + let expected = build_ui_chain_index(&ledger.snapshot()); + let tip_hash = expected.tip_hash.as_deref().unwrap(); + + store.save_with_metrics(&ledger.snapshot(), true).unwrap(); + assert!(store.load().unwrap().is_some()); + assert!(!store.load_metrics().unwrap().is_empty()); + assert!(store.load_ui_chain_index(tip_hash).unwrap().is_some()); + + store.clear_chain().unwrap(); + + assert!(store.load().unwrap().is_none()); + assert!(store.load_metrics().unwrap().is_empty()); + assert!(store.load_ui_chain_index(tip_hash).unwrap().is_none()); +} + +#[test] fn compact_snapshot_roundtrips_and_is_smaller_than_json() { let alice = Wallet::from_seed("compact-alice"); let bob = Wallet::from_seed("compact-bob"); diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -35,16 +35,16 @@ mod wallet_persistence; #[cfg(test)] use actions::{ add_peer, persist_burn_settings_config, persist_pow_mining_config, remove_address_book_entry, - remove_peer, set_burn_settings, set_keep_track_of_metrics, set_p2p_accept_inbound, - set_p2p_announce_addr, upsert_address_book_entry, + remove_peer, reset_local_chain, set_burn_settings, set_keep_track_of_metrics, + set_p2p_accept_inbound, set_p2p_announce_addr, upsert_address_book_entry, }; use actions::{ api_address_book_delete_form, api_address_book_form, api_burn_fee_estimate_form, - api_burn_per_block_form, api_metrics_settings_form, api_mine_fee_estimate_form, - api_p2p_announce_form, api_p2p_inbound_form, api_peer_delete_form, api_peer_form, - api_pow_mining_form, api_recovery_vdf_settings_form, api_transfer_fee_estimate_form, - api_transfer_form, api_wallet_generate_form, api_wallet_import_form, apply_config_form, - burn_per_block_form, peer_form, transfer_form, + api_burn_per_block_form, api_chain_reset_form, api_metrics_settings_form, + api_mine_fee_estimate_form, api_p2p_announce_form, api_p2p_inbound_form, api_peer_delete_form, + api_peer_form, api_pow_mining_form, api_recovery_vdf_settings_form, + api_transfer_fee_estimate_form, api_transfer_form, api_wallet_generate_form, + api_wallet_import_form, apply_config_form, burn_per_block_form, peer_form, transfer_form, }; use api::{ api_blocks, api_config, api_mempool, api_metrics, api_network_health, api_p2p_metrics, @@ -183,6 +183,7 @@ pub async fn serve( "/api/settings/recovery-vdf", post(api_recovery_vdf_settings_form), ) + .route("/api/settings/chain-reset", post(api_chain_reset_form)) .route("/api/settings/p2p-inbound", post(api_p2p_inbound_form)) .route("/api/settings/p2p-announce", post(api_p2p_announce_form)) .route("/api/transfer", post(api_transfer_form)) diff --git a/src/adapters/http/actions.rs b/src/adapters/http/actions.rs @@ -10,9 +10,10 @@ use axum::{ use tokio::sync::Mutex; use super::types::{ - ActionResponse, AddressBookDeleteForm, AddressBookForm, BurnSettingsForm, ConfigForm, - FeeEstimateResponse, MetricsSettingsForm, P2pAnnounceForm, P2pInboundForm, PeerForm, - PowMiningForm, RecoveryVdfSettingsForm, SeedPhraseForm, TransferForm, WalletSetupResponse, + ActionResponse, AddressBookDeleteForm, AddressBookForm, BurnSettingsForm, ChainResetForm, + ConfigForm, FeeEstimateResponse, MetricsSettingsForm, P2pAnnounceForm, P2pInboundForm, + PeerForm, PowMiningForm, RecoveryVdfSettingsForm, SeedPhraseForm, TransferForm, + WalletSetupResponse, }; use super::{ HttpState, action_json, api_error, config_store, estimate_burn_fee, estimate_mine_fee, @@ -21,9 +22,12 @@ use super::{ }; use crate::{ adapters::{chain_store::SqliteChainStore, config_store::UiConfig}, + app::GossipEnvelope, domain::Amount, }; +const CHAIN_RESET_CONFIRMATION: &str = "RESET"; + pub(super) async fn apply_config_form(state: &HttpState, form: ConfigForm) -> Result<()> { let peer = form.peer.trim(); if !peer.is_empty() { @@ -109,6 +113,13 @@ pub(super) async fn api_recovery_vdf_settings_form( action_json(set_recovery_vdf_top_rank_percent(&state, form.top_rank_percent).await) } +pub(super) async fn api_chain_reset_form( + State(state): State<HttpState>, + Form(form): Form<ChainResetForm>, +) -> Json<ActionResponse> { + action_json(reset_local_chain(&state, &form.confirm).await) +} + pub(super) async fn api_p2p_announce_form( State(state): State<HttpState>, Form(form): Form<P2pAnnounceForm>, @@ -305,6 +316,27 @@ pub(super) async fn set_keep_track_of_metrics(state: &HttpState, enabled: bool) config_store::save(&state.config_path, &config) } +pub(super) async fn reset_local_chain(state: &HttpState, confirmation: &str) -> Result<()> { + if confirmation.trim() != CHAIN_RESET_CONFIRMATION { + bail!("type RESET to confirm deleting the local chain"); + } + + { + let mut node = state.node.lock().await; + node.reset_chain_to_setup_placeholder(); + } + { + let mut cache = state.ui_cache.lock().await; + *cache = super::UiChainCache::default(); + } + clear_chain(&state.chain_store).await?; + state + .gossip + .broadcast(vec![GossipEnvelope::ChainSnapshotRequest]) + .await?; + Ok(()) +} + pub(super) async fn set_p2p_announce_addr(state: &HttpState, addr: String) -> Result<()> { let trimmed = addr.trim(); let parsed = if trimmed.is_empty() { @@ -378,6 +410,14 @@ async fn clear_metrics(store: &SqliteChainStore) -> Result<()> { Ok(()) } +async fn clear_chain(store: &SqliteChainStore) -> Result<()> { + let store = store.clone(); + tokio::task::spawn_blocking(move || store.clear_chain()) + .await + .context("chain reset worker failed")??; + Ok(()) +} + pub(super) async fn add_peer(state: &HttpState, peer: String) -> Result<()> { let peer = validate_peer_address(peer)?; let addresses = { diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -70,6 +70,8 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> button.primary { background: #d5f55f; border-color: #d5f55f; color: #15171a; } button.primary:hover { background: #e4ff83; color: #15171a; } button.subtle { background: transparent; } + button.danger { border-color: #8f3730; background: #341918; color: #ffb1a8; } + button.danger:hover { border-color: #ff7668; background: #451c1a; color: #ffd8d3; } button:disabled { cursor: default; opacity: .5; } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; } .metric, .panel { background: #181b1f; border: 1px solid #2a3035; border-radius: 8px; padding: 13px; } @@ -139,6 +141,10 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .settings-form { display: grid; gap: 10px; align-items: stretch; } .public-p2p-form { margin-top: 14px; } .settings-form label, .settings-form input { width: 100%; } + .danger-panel { border-color: #6a332c; background: #201313; } + .danger-panel h3, .danger-title { color: #ffb1a8; } + .danger-copy { color: #d69a92; line-height: 1.45; } + .danger-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 12px; } .metrics-shell { display: grid; gap: 12px; } .metrics-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; } .metrics-head h2 { margin: 0; } @@ -387,6 +393,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .pill.mine { background: #172a34; color: #8bdcff; } .pill.blinded { background: #272433; color: #c8b8ff; } .pill.reveal, .pill.revealed { background: #2b2f20; color: #d5f55f; } + .pill.error { background: #341918; color: #ffb1a8; } .mempool-panel { min-width: 0; overflow: hidden; } .mempool-strip { width: 100%; min-width: 0; display: flex; gap: 8px; overflow-x: auto; overscroll-behavior-x: contain; padding: 1px 0 10px; scroll-snap-type: x proximity; } .mempool-item { flex: 0 0 220px; scroll-snap-align: start; align-self: stretch; } @@ -435,7 +442,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/iuna-ui.js?v=97"></script> + <script defer src="/assets/iuna-ui.js?v=98"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -1166,10 +1173,42 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div class="setup-actions"><button class="primary" type="submit">Change password</button></div> </form> </div> + <div class="panel danger-panel"> + <h3>Danger Zone</h3> + <div class="settings-mode-row"> + <div class="settings-mode-copy"> + <div class="settings-mode-title danger-title">Delete local chain</div> + <div class="danger-copy">Remove the local blockchain database and request a fresh sync from connected peers. Wallet and settings stay on this device.</div> + </div> + <button class="danger" type="button" @click="openChainResetModal">Delete chain</button> + </div> + </div> </div> </section> </main> </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"> + <section class="tx-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <span class="pill error">Danger</span> + <h2 id="chain-reset-title">Delete local chain</h2> + </div> + <button type="button" @click="closeChainResetModal" :disabled="chainResetBusy">Close</button> + </div> + <div class="info-copy"> + <p>This deletes the local chain database and clears local chain views. Your wallet and settings stay intact.</p> + <p>Type <strong>RESET</strong> to confirm.</p> + </div> + <form class="settings-form" @submit.prevent="resetLocalChain"> + <label>Confirmation<input x-model="chainResetConfirm" autocomplete="off" spellcheck="false" placeholder="RESET"></label> + <div class="danger-actions"> + <button class="subtle" type="button" @click="closeChainResetModal" :disabled="chainResetBusy">Cancel</button> + <button class="danger" type="submit" :disabled="chainResetConfirm.trim() !== 'RESET' || chainResetBusy" x-text="chainResetBusy ? 'Deleting...' : 'Delete and resync'"></button> + </div> + </form> + </section> + </div> <div class="setup-overlay" x-show="showingAuth()" x-transition.opacity role="dialog" aria-modal="true" aria-labelledby="auth-title"> <section class="setup-modal auth-form"> <div class="setup-modal-head"> diff --git a/src/adapters/http/tests.rs b/src/adapters/http/tests.rs @@ -1465,7 +1465,7 @@ fn metrics_response_skips_bootstrap_points_for_block_time_and_vdf_rounds() { #[test] fn metrics_screen_includes_block_range_filter() { - assert!(super::INDEX_HTML.contains("iuna-ui.js?v=97")); + assert!(super::INDEX_HTML.contains("iuna-ui.js?v=98")); assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\"")); assert!(super::INDEX_HTML.contains("setMetricsRange(100)")); assert!(super::INDEX_HTML.contains("setMetricsRange(1000)")); @@ -1583,6 +1583,59 @@ fn p2p_bind_port_changes_show_global_restart_notice() { } #[test] +fn settings_includes_dangerous_chain_reset_flow() { + let app_js = include_str!("../../../www/assets/iuna-ui.js"); + assert!(super::INDEX_HTML.contains("Danger Zone")); + assert!(super::INDEX_HTML.contains("Delete local chain")); + assert!(super::INDEX_HTML.contains("Type <strong>RESET</strong> to confirm.")); + assert!(super::INDEX_HTML.contains("class=\"danger\"")); + assert!(super::INDEX_HTML.contains("chainResetModalOpen")); + assert!(app_js.contains("chainResetModalOpen: false")); + assert!(app_js.contains("async resetLocalChain()")); + assert!(app_js.contains("\"/api/settings/chain-reset\"")); + assert!(app_js.contains("confirm: this.chainResetConfirm")); +} + +#[tokio::test] +async fn chain_reset_deletes_local_chain_and_returns_to_placeholder() { + let dir = tempfile::tempdir().unwrap(); + let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; + let wallet = Wallet::from_seed("http-chain-reset-alice"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 10); + let ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) + .unwrap(); + state + .chain_store + .save_with_metrics(&ledger.snapshot(), true) + .unwrap(); + { + let mut node = state.node.lock().await; + *node = NodeCore::from_ledger(wallet.clone(), ledger, 1); + } + { + let mut cache = state.ui_cache.lock().await; + cache.tip_hash = Some("stale-tip".to_string()); + } + + let error = super::reset_local_chain(&state, "nope").await.unwrap_err(); + assert!(error.to_string().contains("type RESET")); + assert!(state.chain_store.load().unwrap().is_some()); + assert!(state.node.lock().await.has_real_chain()); + + super::reset_local_chain(&state, "RESET").await.unwrap(); + + let node = state.node.lock().await; + assert!(node.ledger().is_setup_placeholder()); + assert_eq!(node.status().wallet_address, wallet.address()); + drop(node); + assert!(state.chain_store.load().unwrap().is_none()); + assert!(state.chain_store.load_metrics().unwrap().is_empty()); + assert!(state.ui_cache.lock().await.tip_hash.is_none()); +} + +#[test] fn polling_refreshes_paged_datasets_without_visible_loaders() { let app_js = include_str!("../../../www/assets/iuna-ui.js"); assert!(app_js.contains("setInterval(() => this.refresh({ silent: true }), 5000)")); diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -90,6 +90,11 @@ pub(super) struct MetricsSettingsForm { } #[derive(Debug, Deserialize)] +pub(super) struct ChainResetForm { + pub(super) confirm: String, +} + +#[derive(Debug, Deserialize)] pub(super) struct P2pAnnounceForm { pub(super) addr: String, } diff --git a/src/app/node_lifecycle.rs b/src/app/node_lifecycle.rs @@ -132,6 +132,19 @@ impl NodeCore { self.local_block_anchor_burn = None; } + pub fn reset_chain_to_setup_placeholder(&mut self) { + self.ledger = Ledger::new(BTreeMap::new(), 1); + self.reset_automatic_mining_progress(); + self.owned_blinded_transactions.clear(); + self.owned_blinded_reveals.clear(); + self.owned_blinded_payloads.clear(); + self.bump_owned_blinded_outbox_version(); + self.reveal_bundles.clear(); + self.equivocated_reveal_bundle_slots.clear(); + self.local_block_anchor_burn = None; + self.outbox.clear(); + } + pub(super) fn reset_automatic_mining_progress(&mut self) { self.last_auto_burn_height = None; self.last_auto_anchor_burn_height = None; diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -100,6 +100,9 @@ window.iunaApp = function iunaApp() { peerAddress: "", flash: null, flashTimer: null, + chainResetModalOpen: false, + chainResetConfirm: "", + chainResetBusy: false, showWalletUtxos: false, showPowDifficultyInfo: false, lastUpdated: null, @@ -747,6 +750,13 @@ window.iunaApp = function iunaApp() { const config = this.datasetConfig(kind); if (!config) return; this[config.items] = []; + this.resetPageState(kind); + await this.refreshPagedDataset(kind); + }, + + resetPageState(kind) { + const config = this.datasetConfig(kind); + if (!config) return; Object.assign(this[config.page], { offset: 0, total: 0, @@ -754,7 +764,6 @@ window.iunaApp = function iunaApp() { loading: false, backgroundLoading: false, }); - await this.refreshPagedDataset(kind); }, async refreshPagedDataset(kind, options = {}) { @@ -1100,11 +1109,61 @@ window.iunaApp = function iunaApp() { this.showPowDifficultyInfo = false; }, + openChainResetModal() { + this.settingsFeedback = null; + this.chainResetConfirm = ""; + this.chainResetModalOpen = true; + }, + + closeChainResetModal() { + if (this.chainResetBusy) return; + this.chainResetModalOpen = false; + this.chainResetConfirm = ""; + }, + + async resetLocalChain() { + if (this.chainResetConfirm.trim() !== "RESET") { + this.showSettingsFeedback("Type RESET to confirm deleting the local chain", "error"); + return; + } + this.chainResetBusy = true; + try { + await this.submitForm("/api/settings/chain-reset", { + confirm: this.chainResetConfirm, + }); + this.blocks = []; + this.selectedBlock = null; + this.selectedByteBlock = null; + this.selectedBurnLeaderBlock = null; + this.selectedTransaction = null; + this.mempool = []; + this.walletTxs = []; + this.walletUtxos = []; + this.mempoolFirstSeenHeights = {}; + this.mempoolFirstSeenAt = {}; + this.mempoolSeenInitialized = false; + this.lastBlockMempoolHeight = null; + this.resetPageState("walletTx"); + this.resetPageState("walletUtxo"); + this.resetPageState("mempool"); + this.chainResetModalOpen = false; + this.chainResetConfirm = ""; + await this.refresh({ force: true }); + this.showSettingsFeedback("Local chain deleted. Sync requested from peers.", "success"); + this.showFlash("Local chain deleted. Syncing from peers.", "success"); + } catch (error) { + this.showSettingsFeedback(error.message, "error"); + } finally { + this.chainResetBusy = false; + } + }, + closeModals() { this.closeTransactionModal(); this.closeWalletUtxosModal(); this.closePowDifficultyInfo(); this.closeBurnLeaderRanksModal(); + this.closeChainResetModal(); }, async loadOlderBlocks() {