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:
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() {