iuna

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

commit 5185ae498b0f0138fcf6cbd3b4b71d59210908bb
parent 1681e5d6a6226759117ff4f9905944d4aa898f0f
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Mon, 27 Jul 2026 13:03:10 +0200

Improve peer management UI

Diffstat:
MREADME.md | 4++--
Massets/iuna-ui.js | 59++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mdevlogs/004-wallet-file.md | 2+-
Mdocs/genesis.md | 2+-
Msrc/adapters/http.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/app.rs | 13+++++++++++++
Msrc/main.rs | 22+++++++++++++++++++---
Mtests/iuna.rs | 10++++++++++
Mwww/index.html | 1-
9 files changed, 239 insertions(+), 30 deletions(-)

diff --git a/README.md b/README.md @@ -40,7 +40,7 @@ Open `http://127.0.0.1:18661`, set a local password, and back up the recovery ph Ask for a seed node address and start iuna with `--join`: ```sh -./iuna --data-dir .iuna --http 127.0.0.1:18661 --p2p 0.0.0.0:9445 --join seed.example:9444 +./iuna --http 127.0.0.1:18661 --p2p 0.0.0.0:9445 --join seed.example:9444 ``` After the node syncs, you can receive IUNA, send transactions, burn for block finalization, or enable PoW mine actions from the Mining screen. @@ -60,7 +60,7 @@ Genesis requires a fresh wallet and an empty chain database. It creates the star iuna can expose a Stratum V1 endpoint for SHA-256 ASIC miners such as a Bitaxe: ```sh -./iuna --data-dir .iuna --stratum 0.0.0.0:3333 +./iuna --stratum 0.0.0.0:3333 ``` Use your iuna wallet address as the worker username. Accepted shares become PoW mine actions in the node mempool and are gossiped to peers. diff --git a/assets/iuna-ui.js b/assets/iuna-ui.js @@ -536,7 +536,7 @@ window.iunaApp = function iunaApp() { } }, - async postForm(path, fields, successMessage) { + async postForm(path, fields, successMessage, method = "POST") { const body = new URLSearchParams(); for (const [key, value] of Object.entries(fields)) { if (Array.isArray(value)) { @@ -546,7 +546,7 @@ window.iunaApp = function iunaApp() { } } const response = await fetch(path, { - method: "POST", + method, headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, body, }); @@ -798,7 +798,7 @@ window.iunaApp = function iunaApp() { async addPeer() { try { - const peer = this.peerAddress; + const peer = this.peerAddress.trim(); await this.postForm("/api/peers", { peer }, `Added peer ${peer}`); this.peerAddress = ""; } catch (error) { @@ -806,6 +806,14 @@ window.iunaApp = function iunaApp() { } }, + async removePeer(peer) { + try { + await this.postForm("/api/peers", { peer: peer.address }, `Removed peer ${peer.address}`, "DELETE"); + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + async copyAddress() { try { await navigator.clipboard.writeText(this.setupAddress()); @@ -1051,6 +1059,51 @@ window.iunaApp = function iunaApp() { return Math.min(local, ...peerHeights); }, + outboundPeers() { + return this.peers.filter((peer) => peer.direction !== "inbound"); + }, + + inboundPeers() { + return this.peers.filter((peer) => peer.direction === "inbound"); + }, + + healthyPeers() { + return this.peers.filter((peer) => !peer.last_error && typeof peer.last_known_height === "number"); + }, + + failedPeers() { + return this.peers.filter((peer) => peer.last_error); + }, + + peerStatus(peer) { + if (peer.last_error) return "error"; + if (typeof peer.last_known_height === "number") return "synced"; + if ((peer.messages_sent ?? 0) > 0 || (peer.messages_received ?? 0) > 0) return "active"; + return "pending"; + }, + + peerStatusLabel(peer) { + return { + error: "Error", + synced: "Synced", + active: "Active", + pending: "Pending", + }[this.peerStatus(peer)]; + }, + + peerHeightDelta(peer) { + const local = this.status.chain?.height; + const remote = peer.last_known_height; + if (typeof local !== "number" || typeof remote !== "number") return "-"; + if (remote === local) return "even"; + if (remote > local) return `+${remote - local}`; + return `-${local - remote}`; + }, + + canRemovePeer(peer) { + return peer.direction !== "inbound"; + }, + targetSecondsLabel() { const ms = this.status.mining?.vdf_target_block_ms; if (!ms) return "-"; diff --git a/devlogs/004-wallet-file.md b/devlogs/004-wallet-file.md @@ -2,7 +2,7 @@ The node no longer has a baked-in dev wallet seed. -On first real startup, `--start` or `--join`, iuna creates a wallet file and reuses it next time. The default is `.iuna/wallet.json`, or `<data-dir>/wallet.json` when a node uses its own data directory. +On first real startup, `--start` or `--join`, iuna creates a wallet file and reuses it next time. The default is `~/.iuna/wallet.json`, or `<data-dir>/wallet.json` when a node uses its own data directory. That matters for friend testing. You can restart your node and keep the same address, but friends do not need to pass a seed just to be someone else. They join your chain, get their own fresh local wallet, and start with 0 IUNA until you send them some. diff --git a/docs/genesis.md b/docs/genesis.md @@ -33,7 +33,7 @@ your-host.example:9444 They can join with: ```sh -cargo run -- --data-dir .iuna --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join your-host.example:9444 +cargo run -- --p2p 0.0.0.0:9445 --http 127.0.0.1:18661 --join your-host.example:9444 ``` Keep the management UI bound to `127.0.0.1`. diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -253,7 +253,12 @@ pub async fn serve( .route("/api/fee-estimate/burn", post(api_burn_fee_estimate_form)) .route("/api/fee-estimate/mine", post(api_mine_fee_estimate_form)) .route("/api/mempool", get(api_mempool)) - .route("/api/peers", get(api_peers).post(api_peer_form)) + .route( + "/api/peers", + get(api_peers) + .post(api_peer_form) + .delete(api_peer_delete_form), + ) .route("/api/p2p/metrics", get(api_p2p_metrics)) .route( "/api/settings/burn-per-block", @@ -612,6 +617,14 @@ async fn api_peer_form( action_json(result) } +async fn api_peer_delete_form( + State(state): State<HttpState>, + Form(form): Form<PeerForm>, +) -> Json<ActionResponse> { + let result = remove_peer(&state, form.peer).await; + action_json(result) +} + async fn peer_form(State(state): State<HttpState>, Form(form): Form<PeerForm>) -> Response { match add_peer(&state, form.peer).await { Ok(()) => Redirect::to("/").into_response(), @@ -683,6 +696,7 @@ async fn persist_pow_mining_config( } async fn add_peer(state: &HttpState, peer: String) -> Result<()> { + let peer = validate_peer_address(peer)?; let addresses = { let mut peers = state.peers.lock().await; peers.add_peer(peer); @@ -693,6 +707,28 @@ async fn add_peer(state: &HttpState, peer: String) -> Result<()> { config_store::save(&state.config_path, &config) } +async fn remove_peer(state: &HttpState, peer: String) -> Result<()> { + let peer = validate_peer_address(peer)?; + let addresses = { + let mut peers = state.peers.lock().await; + if !peers.remove_peer(&peer) { + bail!("peer is not configured as an outbound peer"); + } + peers.addresses() + }; + let mut config = state.ui_config.lock().await; + config.peers = addresses; + config_store::save(&state.config_path, &config) +} + +fn validate_peer_address(peer: String) -> Result<String> { + let peer = peer.trim().to_string(); + if peer.is_empty() { + bail!("peer address is required"); + } + Ok(peer) +} + async fn wallet_setup_response( state: &HttpState, headers: &HeaderMap, @@ -1626,6 +1662,20 @@ const INDEX_HTML: &str = r#"<!doctype html> .tx-value.number { color: #c7d0d5; font-variant-numeric: tabular-nums; } .tx-value.text { color: #e8edf0; } .metric-context { display: grid; gap: 5px; margin-top: 12px; } + .peer-toolbar { display: flex; justify-content: space-between; gap: 12px; align-items: start; flex-wrap: wrap; margin-bottom: 12px; } + .peer-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 8px; margin-bottom: 12px; } + .peer-summary-item { min-width: 0; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } + .peer-summary-label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } + .peer-summary-value { margin-top: 5px; color: #dce4e7; font-size: 15px; font-weight: 850; } + .peer-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } + .peer-form label { min-width: min(320px, 100%); } + .peer-form input { width: 100%; } + .peer-status { display: inline-flex; align-items: center; border: 1px solid #3a4248; border-radius: 999px; padding: 3px 8px; color: #a8b2b8; font-size: 11px; font-weight: 850; } + .peer-status.synced, .peer-status.active { border-color: #566d25; color: #d5f55f; background: #1c2516; } + .peer-status.error { border-color: #713434; color: #ffb1a8; background: #2a1717; } + .peer-actions { display: flex; gap: 6px; align-items: center; } + .peer-remove { padding: 4px 7px; border-color: #4f3737; background: #221717; color: #ffb1a8; font-size: 12px; } + .peer-remove:hover { border-color: #ffb1a8; color: #ffd4cf; } .panel .grid + form { margin-top: 12px; } .explorer-shell { width: 100%; display: grid; gap: 12px; } .block-rail-wrap { background: #181b1f; border: 1px solid #2a3035; border-radius: 8px; padding: 12px; overflow: hidden; } @@ -1922,6 +1972,47 @@ const INDEX_HTML: &str = r#"<!doctype html> <section x-show="tab === 'p2p'"> <div class="panel"> + <div class="peer-toolbar"> + <div> + <h2>Peers</h2> + <div class="panel-description">Manage outbound peers and inspect which nodes are healthy, lagging, or failing. Inbound rows are observed sessions and are not persisted in config.</div> + </div> + <form class="peer-form" @submit.prevent="addPeer"> + <label>Peer address<input x-model="peerAddress" placeholder="seed.example:9444"></label> + <button class="primary" type="submit">Add</button> + </form> + </div> + <div class="peer-summary"> + <div class="peer-summary-item"><div class="peer-summary-label">Outbound</div><div class="peer-summary-value" x-text="outboundPeers().length"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Inbound</div><div class="peer-summary-value" x-text="inboundPeers().length"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Healthy</div><div class="peer-summary-value" x-text="healthyPeers().length"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Errors</div><div class="peer-summary-value" x-text="failedPeers().length"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Shared Height</div><div class="peer-summary-value" x-text="sharedHeightLabel()"></div></div> + </div> + <div class="table-wrap"> + <table> + <thead><tr><th>Status</th><th>Address</th><th>Direction</th><th>Height</th><th>Delta</th><th>Tip</th><th>Sent</th><th>Received</th><th>Last Error</th><th>Actions</th></tr></thead> + <tbody> + <template x-for="peer in peers" :key="peer.address"> + <tr> + <td><span class="peer-status" :class="peerStatus(peer)" x-text="peerStatusLabel(peer)"></span></td> + <td><code x-text="peer.address"></code></td> + <td x-text="peer.direction"></td> + <td x-text="peer.last_known_height ?? '-'"></td> + <td x-text="peerHeightDelta(peer)"></td> + <td><code x-text="short(peer.last_known_tip_hash)"></code></td> + <td x-text="peer.messages_sent"></td> + <td x-text="peer.messages_received"></td> + <td x-text="peer.last_error || ''"></td> + <td><div class="peer-actions"><button class="peer-remove" type="button" x-show="canRemovePeer(peer)" @click="removePeer(peer)">Remove</button><span class="muted" x-show="!canRemovePeer(peer)">Observed</span></div></td> + </tr> + </template> + <tr x-show="peers.length === 0"><td colspan="10">No peers</td></tr> + </tbody> + </table> + </div> + </div> + <div class="panel"> <h2>Metrics</h2> <div class="grid"> <div class="metric"><div class="label">Inbound Sessions</div><div class="value" x-text="p2pMetrics.inbound_sessions_started ?? 0"></div></div> @@ -1954,24 +2045,6 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="tx-field"><span class="tx-label">Last Parse</span><span class="tx-value text" x-text="p2pMetrics.last_parse_error || '-'"></span></div> </div> </div> - <div class="panel"> - <h2>Add Peer</h2> - <form @submit.prevent="addPeer"> - <label>Peer address<input x-model="peerAddress" placeholder="127.0.0.1:9445"></label> - <button class="primary" type="submit">Add</button> - </form> - </div> - <div class="panel table-wrap"> - <table> - <thead><tr><th>Address</th><th>Direction</th><th>Height</th><th>Tip</th><th>Sent</th><th>Received</th><th>Last Error</th></tr></thead> - <tbody> - <template x-for="peer in peers" :key="peer.address"> - <tr><td><code x-text="peer.address"></code></td><td x-text="peer.direction"></td><td x-text="peer.last_known_height ?? '-'"></td><td><code x-text="short(peer.last_known_tip_hash)"></code></td><td x-text="peer.messages_sent"></td><td x-text="peer.messages_received"></td><td x-text="peer.last_error || ''"></td></tr> - </template> - <tr x-show="peers.length === 0"><td colspan="7">No peers</td></tr> - </tbody> - </table> - </div> </section> <section x-show="tab === 'chain'"> @@ -2447,6 +2520,51 @@ mod tests { assert_eq!(protected.body, "protected"); } + #[tokio::test] + async fn peer_management_updates_config_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let state = auth_test_state( + config_path.clone(), + UiConfig { + setup_complete: true, + ..UiConfig::default() + }, + ) + .await; + + super::add_peer(&state, " 127.0.0.1:9445 ".to_string()) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert_eq!(config.peers, vec!["127.0.0.1:9445"]); + assert_eq!(state.peers.lock().await.addresses(), vec!["127.0.0.1:9445"]); + + super::remove_peer(&state, "127.0.0.1:9445".to_string()) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(config.peers.is_empty()); + assert!(state.peers.lock().await.addresses().is_empty()); + } + + #[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; + + assert!(super::add_peer(&state, " ".to_string()).await.is_err()); + state + .peers + .lock() + .await + .record_received("127.0.0.1:9555", 1); + + let result = super::remove_peer(&state, "127.0.0.1:9555".to_string()).await; + assert!(result.is_err()); + assert_eq!(state.peers.lock().await.addresses(), Vec::<String>::new()); + } + #[test] fn wallet_transactions_include_old_confirmed_transfers_without_burns_or_explorer_pagination() { let alice = Wallet::from_seed("wallet-history-alice"); diff --git a/src/app.rs b/src/app.rs @@ -1083,6 +1083,19 @@ impl PeerBook { } } + pub fn remove_peer(&mut self, address: &str) -> bool { + if self + .peers + .get(address) + .is_some_and(|peer| peer.direction != PeerDirection::Inbound) + { + self.peers.remove(address); + true + } else { + false + } + } + pub fn addresses(&self) -> Vec<String> { self.peers .values() diff --git a/src/main.rs b/src/main.rs @@ -266,7 +266,7 @@ impl CliOptions { peers: Vec::new(), join_peers: Vec::new(), chain_mode: ChainMode::Setup, - data_dir: PathBuf::from(".iuna"), + data_dir: default_data_dir(), }; let raw_args = args.into_iter().collect::<Vec<_>>(); @@ -287,7 +287,7 @@ impl CliOptions { } "--wallet-seed" => { bail!( - "--wallet-seed was removed; wallets are stored in --wallet <path> or .iuna/wallet.json" + "--wallet-seed was removed; wallets are stored in --wallet <path> or ~/.iuna/wallet.json" ) } "--http" => { @@ -404,11 +404,20 @@ fn help_text() -> &'static str { --p2p <addr:port> P2P TCP listener address (default 127.0.0.1:9444)\n\ --stratum <addr:port> Stratum V1 listener for SHA-256 ASIC miners\n\ --join <addr:port> Fetch chain snapshot from this peer before finalization\n\ - --data-dir <path> Local wallet directory\n\n\ + --data-dir <path> Local wallet directory (default ~/.iuna)\n\n\ Environment:\n\ IUNA_DEV_SKIP_SEED_VERIFY=1 Show a setup button to skip seed verification\n" } +fn default_data_dir() -> PathBuf { + std::env::var_os("HOME") + .filter(|home| !home.is_empty()) + .or_else(|| std::env::var_os("USERPROFILE").filter(|home| !home.is_empty())) + .map(PathBuf::from) + .map(|home| home.join(".iuna")) + .unwrap_or_else(|| PathBuf::from(".iuna")) +} + fn snapshot_height(snapshot: &ChainSnapshot) -> u64 { snapshot .blocks @@ -858,6 +867,13 @@ mod tests { } #[test] + fn data_dir_defaults_under_home() { + let opts = parse(&[]).unwrap().unwrap(); + assert_eq!(opts.data_dir, super::default_data_dir()); + assert!(opts.data_dir.ends_with(".iuna")); + } + + #[test] fn wallet_defaults_under_data_dir() { let opts = parse(&["--genesis", "--data-dir", "tmp-node"]) .unwrap() diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -1781,6 +1781,16 @@ fn peer_book_tracks_multiple_peers_without_networking() { .unwrap(); assert_eq!(inbound_error.direction, PeerDirection::Inbound); assert_eq!(inbound_error.last_error.as_deref(), Some("invalid nonce")); + + assert!(peers.remove_peer("127.0.0.1:9445")); + assert!(!peers.addresses().contains(&"127.0.0.1:9445".to_string())); + assert!(!peers.remove_peer("127.0.0.1:9555")); + assert!( + peers + .list() + .iter() + .any(|peer| peer.address == "127.0.0.1:9555") + ); } #[test] diff --git a/www/index.html b/www/index.html @@ -295,7 +295,6 @@ <div class="step"> <p>Or, if you already have a public seed node address, start iuna with that peer as your join target.</p> <pre><code>./iuna \ - --data-dir .iuna \ --p2p 0.0.0.0:9445 \ --http 127.0.0.1:18661 \ --join seed.example:9444</code></pre>