iuna

iuna

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

commit e80ff61bc3b542030ba771bb04afe494910e9e3b
parent 4b5962c810226f4cdf9442fec62758e3f9fc5b01
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Mon,  7 Sep 2026 12:59:33 +0200

Add guided wallet UTXO optimization

Diffstat:
Msrc/adapters/http.rs | 3+++
Asrc/adapters/http/consolidation.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/adapters/http/index_html.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app.rs | 1+
Asrc/app/consolidation.rs | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain.rs | 2++
Asrc/domain/consolidation_tests.rs | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain/ledger_builders.rs | 54++++++++++++++++++++++++++++++++++--------------------
Msrc/domain/ledger_queries.rs | 7+++++++
Atests/wallet-optimization.test.cjs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mwww/assets/iuna-ui.js | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 658 insertions(+), 20 deletions(-)

diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -27,6 +27,7 @@ mod actions; mod api; mod auth; mod auth_routes; +mod consolidation; mod index_html; mod metrics; mod request_auth; @@ -184,6 +185,8 @@ pub async fn serve( .route("/api/settings/p2p-announce", post(api_p2p_announce_form)) .route("/api/settings/stratum", post(api_stratum_settings_form)) .route("/api/transfer", post(api_transfer_form)) + .route("/api/wallet/optimize/preview", post(consolidation::preview)) + .route("/api/wallet/optimize/submit", post(consolidation::submit)) .route("/settings/burn-per-block", post(burn_per_block_form)) .route("/transfer", post(transfer_form)) .route("/peers", post(peer_form)) diff --git a/src/adapters/http/consolidation.rs b/src/adapters/http/consolidation.rs @@ -0,0 +1,64 @@ +use axum::{Form, Json, extract::State}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::HttpState; +use crate::domain::{Amount, OutPoint}; + +#[derive(Deserialize)] +pub(super) struct PreviewForm { + fee_per_byte: Amount, + #[serde(default)] + merge_roots: bool, +} + +#[derive(Deserialize)] +pub(super) struct SubmitForm { + fee_per_byte: Amount, + max_fee: Amount, + merge_roots: bool, + address: String, + utxos: String, +} + +pub(super) async fn preview( + State(state): State<HttpState>, + Form(form): Form<PreviewForm>, +) -> Json<Value> { + let node = state.node.lock().await; + match node.consolidation_plan(form.fee_per_byte, form.merge_roots) { + Ok(plan) => Json(json!({ "ok": true, "plan": plan })), + Err(error) => Json(json!({ "ok": false, "error": error.to_string() })), + } +} + +pub(super) async fn submit( + State(state): State<HttpState>, + Form(form): Form<SubmitForm>, +) -> Json<Value> { + let points: Vec<OutPoint> = match serde_json::from_str(&form.utxos) { + Ok(points) => points, + Err(_) => return Json(json!({ "ok": false, "error": "Invalid output selection" })), + }; + let (result, outbox) = { + let mut node = state.node.lock().await; + let result = node.consolidate( + &points, + form.fee_per_byte, + form.max_fee, + form.merge_roots, + &form.address, + ); + (result, node.drain_outbox()) + }; + match result { + Ok(transaction) => { + // Submission has already happened. Return the signature even if broadcasting fails; + // the UI must never blindly retry a possibly accepted financial action. + let broadcast = state.gossip.broadcast(outbox).await; + Json(json!({ "ok": true, "signature": transaction.signature(), + "broadcast_error": broadcast.err().map(|error| error.to_string()) })) + } + Err(error) => Json(json!({ "ok": false, "error": error.to_string() })), + } +} diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -226,6 +226,10 @@ pub(super) const INDEX_HTML: &str = concat!( .leaderboard-amount { color: #d5f55f; font-size: 13px; font-weight: 900; font-variant-numeric: tabular-nums; white-space: nowrap; } .wallet-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr); gap: 12px; align-items: start; } .wallet-actions { display: grid; gap: 12px; } + .optimize-form { display: grid; gap: 12px; } + .optimize-form .optimize-choice { display: flex; align-items: center; gap: 8px; } + .optimize-choice input { width: auto; min-width: auto; margin: 0; } + .optimize-form button { justify-self: start; } .amount-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: end; } .amount-field label { min-width: 0; } .amount-field input { width: 100%; } @@ -695,6 +699,13 @@ pub(super) const INDEX_HTML: &str = concat!( <span class="tx-label">Balance</span> <span class="tx-value money">IUNA <span x-text="amountLabel(status.wallet_balance)"></span></span> </button> + <button type="button" @click="openOptimizeWallet">Optimize wallet</button> + </div> + <div class="panel" x-show="showOptimizeSuggestion()"> + <h3>Your wallet could be more efficient</h3> + <p class="panel-description">Your balance consists of <span x-text="walletUtxoPage.total"></span> parts. Combining them can reduce the number of inputs needed for future payments. Review the network fee before deciding.</p> + <button type="button" @click="openOptimizeWallet">Review optimization</button> + <button type="button" @click="dismissOptimizeSuggestion">Later</button> </div> <div class="wallet-grid"> <div class="wallet-actions"> @@ -1591,11 +1602,46 @@ pub(super) const INDEX_HTML: &str = concat!( </form> </section> </div> + <div class="setup-overlay transaction-overlay" x-show="optimizeOpen" x-transition.opacity @click.self="closeOptimizeWallet()" @keydown.escape.stop="closeOptimizeWallet()" role="dialog" aria-modal="true" aria-labelledby="optimize-title"> + <section class="tx-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"><h2 id="optimize-title">Optimize wallet</h2></div> + <button type="button" @click="closeOptimizeWallet" :disabled="optimizeRunning">Close</button> + </div> + <p>Your balance is made up of many small parts. Combining them can make future payments simpler and cheaper.</p> + <p>Your money stays in your wallet. You only pay the network fee shown below. Parts that would be relatively expensive to combine are skipped.</p> + <p class="muted">Only available parts are combined. The largest part is kept separate so you can continue making payments.</p> + <form class="optimize-form" @submit.prevent="previewOptimization"> + <label>Network fee / byte (IUNA)<input type="number" min="0.000001" step="0.000001" x-model="optimizeFee" :disabled="optimizeBusy || optimizeRunning || !!optimizePlan" required></label> + <label class="optimize-choice"><input type="checkbox" x-model="optimizeMergeRoots" :disabled="optimizeBusy || optimizeRunning || !!optimizePlan"> Also combine different mining groups</label> + <p class="panel-description">Mining groups are kept separate by default. Combining different groups changes their lineage and can affect burn-committee selection and maturity.</p> + <button type="submit" :disabled="optimizeBusy || optimizeRunning || !!optimizePlan || status.wallet_locked" x-text="optimizeBusy ? 'Calculating…' : 'Preview costs'"></button> + <span x-show="status.wallet_locked">Unlock your wallet to preview.</span> + </form> + <template x-if="optimizePlan"> + <div> + <div class="tx-modal-summary" style="margin-top:16px"> + <div class="tx-field"><span class="tx-label">Balance parts (expected)</span><span class="tx-value" x-text="`${optimizePlan.before} → ${optimizePlan.after}`"></span></div> + <div class="tx-field"><span class="tx-label">Maximum total fee</span><span class="tx-value" x-text="`${amountLabel(optimizePlan.fee)} IUNA`"></span></div> + <div class="tx-field"><span class="tx-label">Transactions</span><span class="tx-value" x-text="optimizePlan.batches.length"></span></div> + </div> + <p class="muted">After confirmation, all batches are submitted automatically. The modal closes when they are queued; confirmation can continue in the background. Fees are fixed by this preview and never raised automatically.</p> + <p x-show="optimizePlan.batches.length === 0">No suitable batches at this fee and mining-group setting. You can leave your wallet as it is. Small or reserved parts and separate mining groups may remain.</p> + <p x-show="optimizePlan.batches.length === 32">This preview covers up to 32 batches. You can review another optimization afterwards.</p> + <button class="primary" type="button" x-show="optimizePlan.batches.length > 0 && !optimizeRunning" @click="runOptimization">Confirm and optimize</button> + <button type="button" x-show="!optimizeRunning" @click="optimizePlan = null; optimizeError = ''; optimizeMessage = ''">Change settings</button> + </div> + </template> + <p role="status" aria-live="polite" x-text="optimizeMessage"></p> + <p class="fee-warning" role="alert" x-show="optimizeError" x-text="optimizeError"></p> + </section> + </div> <div class="setup-overlay transaction-overlay" x-show="showWalletUtxos" x-transition.opacity @click.self="closeWalletUtxosModal()" role="dialog" aria-modal="true" aria-labelledby="wallet-utxos-title"> <section class="tx-modal"> <div class="tx-modal-head"> <div class="tx-modal-title"> <h2 id="wallet-utxos-title">Wallet UTXOs</h2> + <button type="button" @click="openOptimizeWallet">Optimize wallet</button> <div class="tx-field"><span class="tx-label">Total</span><span class="tx-value money">IUNA <span x-text="amountLabel(status.wallet_balance)"></span></span></div> </div> <button type="button" @click="closeWalletUtxosModal">Close</button> diff --git a/src/app.rs b/src/app.rs @@ -15,6 +15,7 @@ use crate::domain::{ }; mod automatic_mining; +mod consolidation; mod gossip; mod helpers; mod in_memory_network; diff --git a/src/app/consolidation.rs b/src/app/consolidation.rs @@ -0,0 +1,183 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result, bail}; +use serde::Serialize; + +use super::{NodeCore, helpers::converge_fee_by_byte}; +use crate::domain::{Amount, Ledger, OutPoint, Transaction}; + +const BATCH_INPUTS: usize = 128; +const MAX_BATCHES: usize = 32; + +#[derive(Serialize)] +pub(crate) struct ConsolidationBatch { + pub utxos: Vec<OutPoint>, + pub fee: Amount, + pub amount: Amount, + pub bytes: usize, +} + +#[derive(Serialize)] +pub(crate) struct ConsolidationPlan { + pub address: String, + pub before: usize, + pub after: usize, + pub fee: Amount, + pub batches: Vec<ConsolidationBatch>, +} + +impl NodeCore { + pub(crate) fn consolidation_plan( + &self, + fee_per_byte: Amount, + merge_roots: bool, + ) -> Result<ConsolidationPlan> { + self.wallet.unlocked()?; + let ledger = self.wallet_build_ledger()?; + let confirmed = ledger.utxos_for_address(self.wallet.address()); + let before = confirmed.len(); + let available: BTreeSet<_> = ledger + .available_utxos_for_address(self.wallet.address())? + .into_iter() + .map(|(point, _)| point) + .collect(); + let mut candidates: Vec<_> = confirmed + .into_iter() + .filter(|(point, _)| available.contains(point)) + .collect(); + candidates.sort_by_key(|(point, output)| (output.amount, point.clone())); + // Leave the largest spendable output for payments and automatic burns. + candidates.pop(); + let mut groups = BTreeMap::<Option<OutPoint>, Vec<OutPoint>>::new(); + for (point, _) in candidates { + let root = if merge_roots { + None + } else { + ledger.consolidation_root(&point) + }; + groups.entry(root).or_default().push(point); + } + let mut plan = ConsolidationPlan { + address: self.wallet.address().to_string(), + before, + after: before, + fee: 0, + batches: Vec::new(), + }; + for group in groups.values() { + let mut offset = 0; + while offset + 1 < group.len() && plan.batches.len() < MAX_BATCHES { + let mut count = BATCH_INPUTS.min(group.len() - offset); + // The actual builder checks the active network's block-size limit. + let built = loop { + if count < 2 { + break None; + } + match self.build_consolidation( + &ledger, + &group[offset..offset + count], + fee_per_byte, + merge_roots, + ) { + Ok(value) => break Some(value), + Err(_) => count /= 2, + } + }; + if let Some((_, batch)) = built { + plan.after -= batch.utxos.len() - 1; + plan.fee = plan + .fee + .checked_add(batch.fee) + .context("total fee overflows")?; + offset += batch.utxos.len(); + plan.batches.push(batch); + } else { + offset += 1; + } + } + } + Ok(plan) + } + + fn build_consolidation( + &self, + ledger: &Ledger, + outpoints: &[OutPoint], + fee_per_byte: Amount, + merge_roots: bool, + ) -> Result<(Transaction, ConsolidationBatch)> { + if !(2..=BATCH_INPUTS).contains(&outpoints.len()) { + bail!("choose between 2 and 128 outputs per batch"); + } + let confirmed: BTreeMap<_, _> = ledger + .utxos_for_address(self.wallet.address()) + .into_iter() + .collect(); + let available: BTreeSet<_> = ledger + .available_utxos_for_address(self.wallet.address())? + .into_iter() + .map(|(point, _)| point) + .collect(); + let mut seen = BTreeSet::new(); + let root = ledger.consolidation_root(&outpoints[0]); + let mut total: Amount = 0; + for point in outpoints { + if !seen.insert(point) || !available.contains(point) { + bail!("outputs changed or are reserved; review a new preview"); + } + if !merge_roots && ledger.consolidation_root(point) != root { + bail!("merging mining groups requires explicit consent"); + } + let output = confirmed + .get(point) + .context("output is no longer confirmed in this wallet")?; + total = total + .checked_add(output.amount) + .context("input total overflows")?; + } + let (transaction, estimate) = converge_fee_by_byte(fee_per_byte, |fee| { + let amount = total + .checked_sub(fee) + .filter(|amount| *amount > 0) + .context("outputs do not cover the network fee")?; + ledger.build_transfer_with_inputs( + self.wallet.unlocked()?, + self.wallet.address(), + amount, + fee, + outpoints, + ) + })?; + // Never recommend or accept batches spending over 1% of their value on fees. + if u128::from(estimate.fee) * 100 > u128::from(total) { + bail!("batch fee exceeds 1% of its value; use a lower fee or wait"); + } + let batch = ConsolidationBatch { + utxos: outpoints.to_vec(), + fee: estimate.fee, + amount: total - estimate.fee, + bytes: estimate.bytes, + }; + Ok((transaction, batch)) + } + + pub(crate) fn consolidate( + &mut self, + outpoints: &[OutPoint], + fee_per_byte: Amount, + max_fee: Amount, + merge_roots: bool, + address: &str, + ) -> Result<Transaction> { + if address != self.wallet.address() { + bail!("wallet changed; review a new preview"); + } + let ledger = self.wallet_build_ledger()?; + let (transaction, batch) = + self.build_consolidation(&ledger, outpoints, fee_per_byte, merge_roots)?; + if batch.fee > max_fee { + bail!("fee exceeds the approved limit; review a new preview"); + } + self.submit_public_transaction(transaction) + } +} diff --git a/src/domain.rs b/src/domain.rs @@ -4,6 +4,8 @@ mod address; #[cfg(test)] mod adversarial_tests; mod block; +#[cfg(test)] +mod consolidation_tests; mod fork; mod genesis; mod hex; diff --git a/src/domain/consolidation_tests.rs b/src/domain/consolidation_tests.rs @@ -0,0 +1,151 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use super::{Ledger, OutPoint, TxOutput, UtxoLineageRoot, Wallet}; +use crate::app::NodeCore; + +fn fixture(count: usize, value: u64, roots: bool) -> (Wallet, Ledger) { + let wallet = Wallet::from_seed("consolidation-tests"); + let mut ledger = Ledger::new(BTreeMap::new(), 1); + for index in 0..count { + let point = OutPoint { + txid: format!("{:064x}", index + 1), + index: 0, + }; + ledger.utxos.insert( + point.clone(), + TxOutput { + address: wallet.address().to_string(), + amount: value, + }, + ); + if roots { + ledger.utxo_lineage.insert( + point.clone(), + UtxoLineageRoot { + outpoint: point, + height: 0, + }, + ); + } + } + (wallet, ledger) +} + +#[test] +fn consolidation_large_wallet_is_bounded_disjoint_and_keeps_a_reserve() { + let (wallet, ledger) = fixture(1247, 1_000_000, false); + let mut node = NodeCore::from_ledger(wallet.clone(), ledger, 0); + let plan = node.consolidation_plan(1, false).unwrap(); + assert_eq!(plan.before, 1247); + assert_eq!(plan.batches.len(), 10); + assert_eq!(plan.after, 11); + let mut selected = BTreeSet::new(); + for batch in &plan.batches { + assert!((2..=128).contains(&batch.utxos.len())); + for point in &batch.utxos { + assert!(selected.insert(point.clone())); + } + assert!(batch.fee >= batch.bytes as u64); + let tx = node + .consolidate(&batch.utxos, 1, batch.fee, false, wallet.address()) + .unwrap(); + let outputs = tx.outputs(); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].address, wallet.address()); + assert_eq!( + outputs[0].amount + tx.fee(), + batch.utxos.len() as u64 * 1_000_000 + ); + } + assert_eq!(selected.len(), 1246); + assert_eq!( + plan.fee, + plan.batches.iter().map(|batch| batch.fee).sum::<u64>() + ); + let batch = &plan.batches[0]; + assert!( + node.consolidate(&batch.utxos, 1, batch.fee, false, wallet.address()) + .is_err() + ); +} + +#[test] +fn consolidation_requires_consent_for_distinct_mining_roots_and_respects_fee_cap() { + let (wallet, ledger) = fixture(4, 1_000_000, true); + let mut node = NodeCore::from_ledger(wallet.clone(), ledger, 0); + assert!( + node.consolidation_plan(1, false) + .unwrap() + .batches + .is_empty() + ); + let plan = node.consolidation_plan(1, true).unwrap(); + let batch = &plan.batches[0]; + assert!( + node.consolidate(&batch.utxos, 1, batch.fee, false, wallet.address()) + .is_err() + ); + assert!( + node.consolidate(&batch.utxos, 1, batch.fee - 1, true, wallet.address()) + .is_err() + ); + assert!( + node.consolidate(&batch.utxos, 1, batch.fee, true, "another-wallet") + .is_err() + ); + assert!( + node.consolidate( + &[batch.utxos[0].clone(), batch.utxos[0].clone()], + 1, + batch.fee, + true, + wallet.address() + ) + .is_err() + ); +} + +#[test] +fn consolidation_skips_uneconomic_and_locked_wallets() { + let (wallet, ledger) = fixture(3, 10, false); + let node = NodeCore::from_ledger(wallet.clone(), ledger.clone(), 0); + assert!( + node.consolidation_plan(1, false) + .unwrap() + .batches + .is_empty() + ); + let locked = NodeCore::from_locked_wallet_address(wallet.address(), ledger, false, 0, 1); + assert!(locked.consolidation_plan(1, false).is_err()); +} + +#[test] +fn consolidation_adapts_to_the_network_block_limit() { + let (wallet, mut ledger) = fixture(150, 1_000_000, false); + ledger.launch_profile.max_block_bytes = 3000; + let node = NodeCore::from_ledger(wallet, ledger, 0); + let plan = node.consolidation_plan(1, false).unwrap(); + assert!(!plan.batches.is_empty()); + assert!(plan.batches.iter().all(|batch| batch.utxos.len() < 128)); +} + +#[test] +fn transfer_selection_uses_best_fit_then_largest_inputs() { + let (wallet, mut ledger) = fixture(4, 10, false); + ledger + .utxos + .values_mut() + .zip([10, 100, 1000, 200]) + .for_each(|(output, amount)| output.amount = amount); + let single = ledger + .build_transfer(&wallet, wallet.address(), 99, 1) + .unwrap(); + assert_eq!(single.inputs().len(), 1); + assert_eq!(ledger.utxos[&single.inputs()[0].outpoint].amount, 100); + let multiple = ledger + .build_transfer(&wallet, wallet.address(), 1100, 1) + .unwrap(); + assert_eq!(multiple.inputs().len(), 2); + assert_eq!(ledger.utxos[&multiple.inputs()[0].outpoint].amount, 1000); + assert_eq!(ledger.utxos[&multiple.inputs()[1].outpoint].amount, 200); +} diff --git a/src/domain/ledger_builders.rs b/src/domain/ledger_builders.rs @@ -24,28 +24,42 @@ impl Ledger { let required = amount .checked_add(fee) .context("transfer amount plus fee overflows")?; - let (inputs, input_total) = self.select_inputs(wallet.address(), required)?; - let mut outputs = vec![TxOutput { - address: to, - amount, - }]; - let change = input_total - .checked_sub(required) - .context("selected inputs do not cover transfer")?; - if change > 0 { - outputs.push(TxOutput { - address: wallet.address().to_string(), - amount: change, - }); + let mut available = self.available_utxos_for_address(wallet.address())?; + // Prefer the smallest sufficient single output. Otherwise minimize input count. + if let Some((point, _)) = available + .iter() + .filter(|(_, output)| output.amount >= required) + .min_by_key(|(point, output)| (output.amount, point)) + { + return self.build_transfer_with_inputs( + wallet, + to, + amount, + fee, + std::slice::from_ref(point), + ); } - let transaction = UnsignedUtxoTransaction::Transfer { - inputs, - outputs, - fee, + available.sort_by(|(left_point, left), (right_point, right)| { + right + .amount + .cmp(&left.amount) + .then_with(|| left_point.cmp(right_point)) + }); + let mut total = 0_u64; + let mut points = Vec::new(); + for (point, output) in available { + total = total + .checked_add(output.amount) + .context("selected input total overflows")?; + points.push(point); + if total >= required { + break; + } } - .sign(wallet, &self.transaction_signing_domain())?; - self.validate_new_transaction(&transaction)?; - Ok(transaction) + if total < required { + bail!("insufficient funds for {}", wallet.address()); + } + self.build_transfer_with_inputs(wallet, to, amount, fee, &points) } pub fn build_transfer_with_inputs( diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -603,6 +603,13 @@ impl Ledger { .collect() } + /// Consolidation keeps mining roots separate unless explicitly requested. + pub(crate) fn consolidation_root(&self, outpoint: &OutPoint) -> Option<OutPoint> { + self.utxo_lineage + .get(outpoint) + .map(|root| root.outpoint.clone()) + } + pub fn available_utxos_for_address(&self, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> { Ok(self .utxos_after_spendable_pending()? diff --git a/tests/wallet-optimization.test.cjs b/tests/wallet-optimization.test.cjs @@ -0,0 +1,73 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const vm = require('node:vm'); + +function app() { + const context = { window: {}, localStorage: { getItem: () => null }, setTimeout, clearTimeout, URLSearchParams }; + vm.runInNewContext(readFileSync(require.resolve('../www/assets/iuna-ui.js'), 'utf8'), context); + const app = context.window.iunaApp(); + app.status = { wallet_address: 'wallet', wallet_locked: false }; + app.refresh = async () => {}; + app.optimizePlan = { address: 'wallet', before: 5, after: 3, fee: 200, rate: 1, mergeRoots: false, + batches: [{ fee: 100, utxos: ['one', 'two'] }, { fee: 100, utxos: ['three', 'four'] }] }; + return app; +} + +test('does not submit before approval and queues each batch after confirmation', async () => { + const ui = app(); + ui.optimizeOpen = true; + const events = []; + ui.submitForm = async (_path, fields) => { + events.push(`submit:${fields.utxos}`); + assert.equal(fields.max_fee, 100); + assert.equal(fields.address, 'wallet'); + return { signature: `tx${events.length}` }; + }; + ui.showFlash = () => {}; + assert.equal(events.length, 0); + await ui.runOptimization(); + assert.equal(events.length, 2); + assert.equal(ui.optimizePlan, null); + assert.equal(ui.optimizeOpen, false); + assert.match(ui.optimizeMessage, /submitted/); +}); + +test('runs all approved batches as one action', async () => { + const ui = app(); + let submissions = 0; + ui.submitForm = async () => { submissions++; return { signature: `tx${submissions}` }; }; + ui.showFlash = () => {}; + await ui.runOptimization(); + assert.equal(submissions, 2); + assert.equal(ui.optimizePlan, null); +}); + +test('uncertain submission stops and requires a fresh preview', async () => { + const ui = app(); + let submissions = 0; + ui.submitForm = async () => { submissions++; throw new Error('timeout'); }; + await ui.runOptimization(); + await ui.runOptimization(); + assert.equal(submissions, 1); + assert.equal(ui.optimizePlan, null); + assert.match(ui.optimizeError, /Check wallet activity/); +}); + +test('wallet switch and wallet lock stop further submissions', async () => { + for (const status of [{ wallet_address: 'other' }, { wallet_address: 'wallet', wallet_locked: true }]) { + const ui = app(); + ui.status = status; + ui.submitForm = async () => assert.fail('must not submit'); + await ui.runOptimization(); + assert.match(ui.optimizeError, /Wallet locked or changed/); + } +}); + +test('broadcast failure stops without silently retrying', async () => { + const ui = app(); + ui.submitForm = async () => ({ signature: 'accepted', broadcast_error: 'offline' }); + await ui.runOptimization(); + assert.match(ui.optimizeError, /queued locally/); + assert.equal(ui.optimizePlan, null); +}); diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -110,6 +110,15 @@ window.iunaApp = function iunaApp() { feeEstimates: { transfer: null, burn: null, mine: null }, feeEstimateTimer: null, showSendAdvanced: false, + optimizeOpen: false, + optimizeBusy: false, + optimizeRunning: false, + optimizeFee: "0.000001", + optimizeMergeRoots: false, + optimizePlan: null, + optimizeMessage: "", + optimizeError: "", + optimizeDismissed: false, selectedTransferUtxos: [], selectedTransferUtxoAmounts: {}, lastSelectedTransferUtxo: null, @@ -1351,6 +1360,7 @@ window.iunaApp = function iunaApp() { }, closeModals() { + this.closeOptimizeWallet(); this.closeSendConfirmModal(); this.closeTransactionModal(); this.closeWalletUtxosModal(); @@ -2418,6 +2428,90 @@ window.iunaApp = function iunaApp() { return parsed; }, + showOptimizeSuggestion() { + if (this.optimizeDismissed || this.walletUtxoPage.total < 500) return false; + try { + return Date.now() > Number(localStorage.getItem(`iunaOptimizeLater:${this.status.wallet_address}`) || 0); + } catch { return true; } + }, + + dismissOptimizeSuggestion() { + this.optimizeDismissed = true; + try { localStorage.setItem(`iunaOptimizeLater:${this.status.wallet_address}`, String(Date.now() + 7 * 86400000)); } catch {} + }, + + openOptimizeWallet() { + this.showWalletUtxos = false; + this.optimizeOpen = true; + }, + + closeOptimizeWallet() { + if (this.optimizeRunning) return; + this.optimizeOpen = false; + }, + + async previewOptimization() { + if (this.optimizeBusy || this.optimizeRunning) return; + this.optimizeBusy = true; + this.optimizePlan = null; + this.optimizeError = ""; + this.optimizeMessage = ""; + try { + const rate = this.parseiunaAmountRequired(this.optimizeFee, "Enter a fee per byte"); + if (!Number.isSafeInteger(rate) || rate < 1) throw new Error("Fee per byte must be at least 0.000001 IUNA"); + const result = await this.submitForm("/api/wallet/optimize/preview", { + fee_per_byte: rate, merge_roots: this.optimizeMergeRoots, + }); + const plan = result.plan; + if (![plan.fee, ...plan.batches.flatMap(batch => [batch.fee, batch.amount])].every(Number.isSafeInteger)) { + throw new Error("Amounts exceed the safe range for this interface"); + } + this.optimizePlan = { ...plan, rate, mergeRoots: this.optimizeMergeRoots }; + } catch (error) { this.optimizeError = error.message; } + finally { this.optimizeBusy = false; } + }, + + async runOptimization() { + const plan = this.optimizePlan; + if (!plan || this.optimizeRunning || this.optimizeBusy) return; + this.optimizeRunning = true; + this.optimizeError = ""; + try { + for (let batchIndex = 0; batchIndex < plan.batches.length; batchIndex += 1) { + if (this.status.wallet_locked || this.status.wallet_address !== plan.address) { + throw new Error("Wallet locked or changed. Unlock the original wallet and request a new preview."); + } + const batch = plan.batches[batchIndex]; + this.optimizeMessage = `Submitting batch ${batchIndex + 1} of ${plan.batches.length}…`; + let result; + try { + result = await this.submitForm("/api/wallet/optimize/submit", { + address: plan.address, fee_per_byte: plan.rate, max_fee: batch.fee, + merge_roots: plan.mergeRoots, utxos: JSON.stringify(batch.utxos), + }); + if (!result.signature) throw new Error("No transaction confirmation received from the node"); + } catch (error) { + // A timeout may mean the transaction was accepted. Never retry it automatically. + this.optimizePlan = null; + throw new Error(`${error.message}. Check wallet activity before requesting a new preview.`); + } + if (result.broadcast_error) { + this.optimizePlan = null; + throw new Error("Batch queued locally, but broadcasting failed. Check connectivity before requesting a new preview."); + } + } + this.optimizeMessage = `Optimization submitted. Expected after confirmation: ${plan.before - plan.after} fewer balance parts; network fees ${this.amountLabel(plan.fee)} IUNA.`; + this.optimizePlan = null; + this.optimizeOpen = false; + this.showFlash(this.optimizeMessage, "success"); + await this.refresh({ force: true }); + } catch (error) { + this.optimizePlan = null; + this.optimizeError = error.message; + } + finally { this.optimizeRunning = false; } + }, + async sendTransfer() { if (this.sendPreparing || this.sendConfirmBusy) return; this.sendPreparing = true;