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:
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;