iuna

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

commit beb30a8860b54178cf665532f43a0d51da20274c
parent 491aeed4afe154708f83bcbf68301b0b5e4e53ba
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 29 Jul 2026 11:56:19 +0200

Simplify management UI modes

Diffstat:
Msrc/adapters/http.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mwww/assets/iuna-ui.js | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 124 insertions(+), 13 deletions(-)

diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -182,6 +182,7 @@ struct WalletSetupResponse { address: Option<String>, seed_phrase: Option<String>, dev_verify_bypass: bool, + requires_peer: bool, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -641,6 +642,12 @@ async fn apply_config_form(state: &HttpState, form: ConfigForm) -> Result<()> { if !peer.is_empty() { add_peer(state, peer.to_string()).await?; } + if form.setup_complete && setup_requires_peer(state).await { + let has_peer = !state.peers.lock().await.addresses().is_empty(); + if !has_peer { + bail!("add a bootstrap peer before completing setup"); + } + } let mut config = state.ui_config.lock().await; config.setup_complete = form.setup_complete; config_store::save(&state.config_path, &config) @@ -1014,9 +1021,14 @@ async fn wallet_setup_response( address: Some(address), seed_phrase, dev_verify_bypass: dev_seed_verify_bypass_enabled(), + requires_peer: setup_requires_peer(state).await, }) } +async fn setup_requires_peer(state: &HttpState) -> bool { + !state.node.lock().await.has_real_chain() +} + fn wallet_transaction_rows( wallet: &str, pending: Vec<Transaction>, @@ -1349,6 +1361,7 @@ async fn replace_setup_wallet_with_generated_seed( address: Some(address), seed_phrase: Some(seed_phrase), dev_verify_bypass: dev_seed_verify_bypass_enabled(), + requires_peer: setup_requires_peer(state).await, }) } @@ -1374,6 +1387,7 @@ async fn import_setup_wallet_seed( address: Some(address), seed_phrase: None, dev_verify_bypass: dev_seed_verify_bypass_enabled(), + requires_peer: setup_requires_peer(state).await, }) } @@ -1394,6 +1408,7 @@ fn wallet_setup_json(result: Result<WalletSetupResponse>) -> Json<WalletSetupRes address: None, seed_phrase: None, dev_verify_bypass: dev_seed_verify_bypass_enabled(), + requires_peer: false, }), } } @@ -1900,7 +1915,10 @@ const INDEX_HTML: &str = r#"<!doctype html> .nav-button svg.chain-icon { stroke-width: 1.35; } .nav-button span { font-size: 11px; font-weight: 800; } .nav-button:hover, .nav-button.active { background: #202328; border-color: #3b4448; color: #d5f55f; } - .version-panel { margin-top: auto; width: 64px; display: grid; gap: 4px; justify-items: center; border: 1px solid transparent; border-radius: 8px; padding: 7px 4px; color: #7f888e; background: transparent; font-size: 10px; font-weight: 850; text-align: center; } + .mode-toggle { margin-top: auto; width: 64px; display: grid; justify-items: center; border: 1px solid #2f363c; border-radius: 8px; padding: 7px 4px; color: #9fa8ad; background: #111316; font-size: 10px; font-weight: 850; text-align: center; } + .mode-toggle:hover { border-color: #d5f55f; color: #d5f55f; } + .mode-toggle-label { line-height: 1; text-transform: uppercase; } + .version-panel { width: 64px; display: grid; gap: 4px; justify-items: center; border: 1px solid transparent; border-radius: 8px; padding: 7px 4px; color: #7f888e; background: transparent; font-size: 10px; font-weight: 850; text-align: center; } .version-panel.update { border-color: #566d25; color: #d5f55f; background: #1c2516; cursor: pointer; } .version-panel.checking { color: #a8b2b8; } .version-panel.failed { color: #ffb1a8; } @@ -1914,6 +1932,9 @@ const INDEX_HTML: &str = r#"<!doctype html> main > section { width: 100%; } header { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; padding: 0 0 18px; } .header-actions { display: flex; gap: 10px; align-items: center; } + .basic-status { display: inline-flex; gap: 8px; align-items: center; margin-top: 5px; color: #9eb3bc; font-size: 12px; font-weight: 750; } + .basic-status button { padding: 3px 7px; border-color: #3a4248; background: #202328; color: #9fa8ad; font-size: 11px; } + .basic-status button:hover { border-color: #d5f55f; color: #d5f55f; } .lock-button { padding: 5px 8px; border-color: #3a4248; background: #202328; color: #9fa8ad; font-size: 12px; } .lock-button:hover { border-color: #d5f55f; color: #d5f55f; } h1 { margin: 0 0 4px; font-size: 28px; } @@ -2143,7 +2164,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .side-nav { display: flex; width: auto; gap: 8px; } .nav-button { width: 52px; min-height: 48px; } .nav-button span { font-size: 10px; } - .version-panel { margin-top: 0; width: 48px; padding: 6px 3px; font-size: 9px; } + .mode-toggle, .version-panel { margin-top: 0; width: 48px; padding: 6px 3px; font-size: 9px; } .content { padding: 16px 12px 36px; } header, .split, .setup-grid, .wallet-grid, .mining-grid, .detail-grid, .wallet-tx-row { grid-template-columns: 1fr; } header { display: grid; } @@ -2153,7 +2174,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/iuna-ui.js?v=58"></script> + <script defer src="/assets/iuna-ui.js?v=59"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -2165,11 +2186,11 @@ const INDEX_HTML: &str = r#"<!doctype html> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7h16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H3z"></path><path d="M3 7V5a2 2 0 0 1 2-2h12"></path><path d="M16 13h3"></path></svg> <span>Wallet</span> </button> - <button class="nav-button" :class="{ active: tab === 'mining' }" @click="setTab('mining')" type="button" title="Mining" aria-label="Mining"> + <button class="nav-button" x-show="advancedMode()" :class="{ active: tab === 'mining' }" @click="setTab('mining')" type="button" title="Mining" aria-label="Mining"> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="M7 15l4-4 3 3 5-7"></path></svg> <span>Mining</span> </button> - <button class="nav-button" :class="{ active: tab === 'p2p' }" @click="setTab('p2p')" type="button" title="P2P" aria-label="P2P"> + <button class="nav-button" x-show="advancedMode()" :class="{ active: tab === 'p2p' }" @click="setTab('p2p')" type="button" title="P2P" aria-label="P2P"> <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="6" r="3"></circle><circle cx="18" cy="18" r="3"></circle><path d="M8.5 10.5 15.5 7.5"></path><path d="M8.5 13.5 15.5 16.5"></path></svg> <span>P2P</span> </button> @@ -2178,6 +2199,9 @@ const INDEX_HTML: &str = r#"<!doctype html> <span>Chain</span> </button> </nav> + <button class="mode-toggle" type="button" @click="toggleUiMode" :title="advancedMode() ? 'Switch to basic mode' : 'Switch to full mode'"> + <span class="mode-toggle-label" x-text="advancedMode() ? 'Full' : 'Basic'"></span> + </button> <button class="version-panel" type="button" :class="{ update: updateAvailable(), checking: releaseCheckState === 'checking', failed: releaseCheckState === 'failed' }" :title="versionPanelTitle()" @click="openLatestRelease"> <span class="version-dot" aria-hidden="true"></span> <span class="version-label" x-text="appVersionLabel()"></span> @@ -2189,6 +2213,10 @@ const INDEX_HTML: &str = r#"<!doctype html> <header> <div> <h1 x-text="pageTitle()">iuna</h1> + <div class="basic-status" x-show="basicMode()"> + <span x-text="basicNetworkStatusLabel()"></span> + <button type="button" x-show="basicNetworkNeedsAttention()" @click="setUiMode('advanced'); setTab('p2p')">Details</button> + </div> </div> <div class="header-actions"> <div class="muted" x-text="lastUpdatedLabel()"></div> @@ -2214,7 +2242,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <label>Amount<input x-model="transferAmount" @input="scheduleFeeEstimates" type="number" min="0.000001" step="0.000001" required></label> <label>Fee / byte<input x-model="transferFee" @input="scheduleFeeEstimates" type="number" min="0" step="0.000001" required></label> <div class="fee-preview" x-text="feeEstimateLabel('transfer')"></div> - <button class="advanced-toggle" type="button" @click="toggleSendAdvanced" x-text="showSendAdvanced ? 'Hide advanced' : 'Advanced'"></button> + <button class="advanced-toggle" type="button" @click="toggleSendAdvanced" x-text="showSendAdvanced ? 'Hide UTXOs' : 'UTXOs'"></button> <div class="send-utxo-summary" x-show="showSendAdvanced"> <div>Selected UTXOs: <span x-text="selectedTransferUtxos.length"></span></div> <div>Selected total: IUNA <span x-text="amountLabel(selectedTransferUtxoTotal())"></span></div> @@ -2697,7 +2725,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="setup-modal-head"> <div class="setup-welcome">Welcome to iuna</div> <h2 id="setup-title">Initial Setup</h2> - <div class="setup-copy">Choose how this node finds the network, then set up the local wallet.</div> + <div class="setup-copy">Connect this node to the network, then set up the local wallet.</div> </div> <div class="setup-feedback" :class="setupFeedback?.kind" x-show="setupFeedback" x-transition x-text="setupFeedback?.message"></div> <div class="setup-grid"> @@ -2707,9 +2735,9 @@ const INDEX_HTML: &str = r#"<!doctype html> <a class="setup-network-link" href="https://github.com/iuna-labs/iuna/blob/main/KNOWN_NODES.txt" target="_blank" rel="noreferrer">Known nodes</a> </div> <div class="setup-network-row"> - <label>Bootstrap peer<input x-model="setupPeerAddress" placeholder="iuna.jhx.app:9444"></label> + <label><span x-text="setupRequiresPeer() ? 'Bootstrap peer (required)' : 'Bootstrap peer'"></span><input x-model="setupPeerAddress" placeholder="iuna.jhx.app:9444"></label> </div> - <div class="setup-network-copy">Known nodes are bootstrap peers, not authorities. They help this node find the network; they do not control your wallet or decide valid blocks.</div> + <div class="setup-network-copy" x-text="setupRequiresPeer() ? 'A bootstrap peer is required before this node can join the network. Known nodes help discovery; they do not control your wallet or decide valid blocks.' : 'You can add a bootstrap peer now or later from the P2P screen. Known nodes help discovery; they do not control your wallet or decide valid blocks.'"></div> </div> <div class="setup-section setup-wallet-section seed-panel"> <div class="panel-head"> @@ -2785,7 +2813,7 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> </div> <div class="setup-actions"> - <button class="primary" type="button" :disabled="!walletVerified" @click="completeSetup">Continue</button> + <button class="primary" type="button" :disabled="!setupCanContinue()" @click="completeSetup">Continue</button> </div> </section> </div> @@ -3133,6 +3161,27 @@ mod tests { } #[tokio::test] + async fn setup_config_form_requires_peer_for_placeholder_chain() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let state = auth_test_state(config_path.clone(), UiConfig::default()).await; + + let error = super::apply_config_form( + &state, + super::ConfigForm { + setup_complete: true, + peer: " ".to_string(), + }, + ) + .await + .unwrap_err(); + + assert!(format!("{error:#}").contains("add a bootstrap peer")); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(!config.setup_complete); + } + + #[tokio::test] async fn peer_management_rejects_empty_and_inbound_removal() { let dir = tempfile::tempdir().unwrap(); let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -13,6 +13,13 @@ window.iunaApp = function iunaApp() { peers: [], p2pMetrics: {}, networkHealth: {}, + uiMode: (() => { + try { + return localStorage.getItem("iunaUiMode") === "advanced" ? "advanced" : "basic"; + } catch { + return "basic"; + } + })(), latestRelease: null, releaseCheckState: "idle", releaseCheckError: null, @@ -23,7 +30,7 @@ window.iunaApp = function iunaApp() { authPasswordConfirm: "", loginPassword: "", authFeedback: null, - setupWallet: { address: null, seed_phrase: null, dev_verify_bypass: false }, + setupWallet: { address: null, seed_phrase: null, dev_verify_bypass: false, requires_peer: false }, setupWalletMode: "create", setupSeedStep: "write", generatedSeedPhrase: "", @@ -93,17 +100,43 @@ window.iunaApp = function iunaApp() { tabFromHash() { const hash = window.location.hash.replace(/^#\/?/, ""); - return ["wallet", "mining", "p2p", "chain"].includes(hash) ? hash : "wallet"; + return this.allowedTabs().includes(hash) ? hash : "wallet"; }, setTab(tab) { - if (!["wallet", "mining", "p2p", "chain"].includes(tab)) return; + if (!this.allowedTabs().includes(tab)) return; this.tab = tab; if (window.location.hash !== `#${tab}`) { window.location.hash = tab; } }, + allowedTabs() { + return this.advancedMode() ? ["wallet", "mining", "p2p", "chain"] : ["wallet", "chain"]; + }, + + basicMode() { + return this.uiMode !== "advanced"; + }, + + advancedMode() { + return this.uiMode === "advanced"; + }, + + setUiMode(mode) { + this.uiMode = mode === "advanced" ? "advanced" : "basic"; + try { + localStorage.setItem("iunaUiMode", this.uiMode); + } catch {} + if (!this.allowedTabs().includes(this.tab)) { + this.setTab("wallet"); + } + }, + + toggleUiMode() { + this.setUiMode(this.advancedMode() ? "basic" : "advanced"); + }, + pageTitle() { return { wallet: "iuna", @@ -148,6 +181,18 @@ window.iunaApp = function iunaApp() { return this.authLoaded && (!this.auth.configured || !this.auth.authenticated); }, + setupRequiresPeer() { + return this.setupWallet.requires_peer === true; + }, + + setupHasPeer() { + return this.setupPeerAddress.trim().length > 0 || this.outboundPeers().length > 0; + }, + + setupCanContinue() { + return this.walletVerified && (!this.setupRequiresPeer() || this.setupHasPeer()); + }, + async refreshAuth() { this.auth = await this.fetchJson("/api/auth/status"); this.authLoaded = true; @@ -356,6 +401,9 @@ window.iunaApp = function iunaApp() { if (!this.walletVerified) { throw new Error("Verify or import a recovery phrase first"); } + if (this.setupRequiresPeer() && !this.setupHasPeer()) { + throw new Error("Add a bootstrap peer before continuing"); + } const response = await fetch("/api/config", { method: "POST", headers: { @@ -1134,6 +1182,20 @@ window.iunaApp = function iunaApp() { return `${lag} behind`; }, + basicNetworkStatusLabel() { + const state = this.networkHealth.state; + if (!state) return "Network starting"; + if (state === "healthy" || state === "ahead of peers") return "Connected"; + if (state === "syncing" || state === "mempool syncing") return "Syncing"; + if (state === "isolated") return "Offline"; + return state.charAt(0).toUpperCase() + state.slice(1); + }, + + basicNetworkNeedsAttention() { + if (!this.networkHealth.state) return false; + return !this.networkHealth.ok && this.networkHealth.state !== "syncing"; + }, + networkTimeOffsetLabel() { return this.clockOffsetLabel(this.networkHealth.network_time_offset_ms, true); },