iuna

iuna

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

commit 3ec94ea3445d4f9e543f9f3a4a4ed3a921eebf9e
parent caf96a1395d61f3edb02348e932301c89d22962f
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 14 Aug 2026 23:38:58 +0200

Add max send amount button

Diffstat:
Msrc/adapters/http/index_html.rs | 14+++++++++++---
Msrc/adapters/http/tests.rs | 16+++++++++++++++-
Mwww/assets/iuna-ui.js | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
3 files changed, 118 insertions(+), 10 deletions(-)

diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -185,6 +185,11 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .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; } + .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%; } + .amount-max-button { padding: 9px 10px; border-color: #3a4248; background: #202328; color: #9fa8ad; font-size: 12px; text-transform: uppercase; } + .amount-max-button:hover { border-color: #d5f55f; color: #d5f55f; } .advanced-toggle { flex-basis: 100%; width: max-content; align-self: flex-start; border-color: #3a4248; padding: 4px 7px; background: #202328; color: #9fa8ad; font-size: 12px; } .advanced-toggle:hover { border-color: #5a646b; color: #d6dee2; } .send-utxo-list { display: grid; gap: 8px; max-height: 260px; overflow: auto; border: 1px solid #2f363c; border-radius: 8px; padding: 8px; background: #111316; } @@ -453,7 +458,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/iuna-ui.js?v=103"></script> + <script defer src="/assets/iuna-ui.js?v=109"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -528,7 +533,10 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v18H6.5A2.5 2.5 0 0 1 4 18.5z"></path><path d="M8 7h8"></path><path d="M8 11h6"></path><path d="M8 15h4"></path></svg> </button> </div> - <label>Amount<input x-model="transferAmount" @input="scheduleFeeEstimates" type="number" min="0.000001" step="0.000001" required></label> + <div class="amount-field"> + <label>Amount<input x-model="transferAmount" @input="scheduleFeeEstimates" type="number" min="0.000001" step="0.000001" required></label> + <button class="amount-max-button" type="button" @click="setMaxTransferAmount" :disabled="transferMaxDisabled()" title="Use maximum spendable amount">Max</button> + </div> <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 UTXOs' : 'UTXOs'"></button> @@ -536,7 +544,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div>Selected UTXOs: <span x-text="selectedTransferUtxos.length"></span></div> <div>Selected total: IUNA <span x-text="amountLabel(selectedTransferUtxoTotal())"></span></div> <div>Required: IUNA <span x-text="amountLabel(transferRequiredTotal())"></span></div> - <div class="setup-feedback error" x-show="!selectedTransferUtxosCoverTransfer()">Selected UTXOs do not cover amount plus fee</div> + <div class="setup-feedback error" x-show="selectedTransferUtxoShortfall() > 0">Selected UTXOs do not cover amount plus fee</div> <div class="send-utxo-list"> <div class="send-utxo-list-head"> <span>UTXOs</span> diff --git a/src/adapters/http/tests.rs b/src/adapters/http/tests.rs @@ -1520,7 +1520,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=103")); + assert!(super::INDEX_HTML.contains("iuna-ui.js?v=109")); assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\"")); assert!(super::INDEX_HTML.contains("setMetricsRange(100)")); assert!(super::INDEX_HTML.contains("setMetricsRange(1000)")); @@ -1630,6 +1630,9 @@ fn transfer_utxo_selection_supports_shift_click_ranges() { assert!( super::INDEX_HTML.contains("@click.prevent=\"toggleTransferUtxoSelection($event, utxo)\"") ); + assert!(super::INDEX_HTML.contains("class=\"amount-max-button\"")); + assert!(super::INDEX_HTML.contains("@click=\"setMaxTransferAmount\"")); + assert!(super::INDEX_HTML.contains(":disabled=\"transferMaxDisabled()\"")); assert!( !super::INDEX_HTML .contains(":checked=\"selectedTransferUtxos.includes(utxoOutpoint(utxo))\" @click=") @@ -1639,6 +1642,17 @@ fn transfer_utxo_selection_supports_shift_click_ranges() { assert!(app_js.contains("const checked = !selected.has(outpoint);")); assert!(app_js.contains("event?.shiftKey && anchorIndex >= 0 && currentIndex >= 0")); assert!(app_js.contains("const [from, to] = [anchorIndex, currentIndex].sort")); + assert!(app_js.contains("transferMaxDisabled()")); + assert!(app_js.contains("async setMaxTransferAmount()")); + assert!(app_js.contains("async maxTransferAmountForSelectedUtxos(selectedTotal)")); + assert!(app_js.contains("let low = 1;")); + assert!(app_js.contains("let high = total;")); + assert!(app_js.contains("while (low <= high)")); + assert!(app_js.contains("const required = amount + this.microiunaAmount(estimate.fee);")); + assert!(app_js.contains("selectedTransferUtxoShortfall()")); + assert!(app_js.contains("microiunaAmount(value)")); + assert!(app_js.contains("Math.round(Number(value) || 0)")); + assert!(app_js.contains("trim().replace(\",\", \".\")")); } #[test] diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -2168,12 +2168,16 @@ window.iunaApp = function iunaApp() { }, amountLabel(value) { - const microiuna = Math.max(0, Math.trunc(Number(value) || 0)); + const microiuna = this.microiunaAmount(value); const whole = Math.floor(microiuna / 1000000); const fractional = String(microiuna % 1000000).padStart(6, "0").replace(/0+$/, ""); return fractional ? `${whole}.${fractional}` : `${whole}`; }, + microiunaAmount(value) { + return Math.max(0, Math.round(Number(value) || 0)); + }, + metricAmountLabel(value) { return value === null || value === undefined ? "-" : `IUNA ${this.amountLabel(value)}`; }, @@ -2183,7 +2187,7 @@ window.iunaApp = function iunaApp() { }, parseiunaAmount(value) { - const text = String(value ?? "").trim(); + const text = String(value ?? "").trim().replace(",", "."); if (!text) return 0; const match = text.match(/^(\d+)(?:\.(\d{0,6})\d*)?$/); if (!match) return 0; @@ -2193,7 +2197,7 @@ window.iunaApp = function iunaApp() { }, parseiunaAmountRequired(value, message) { - const text = String(value ?? "").trim(); + const text = String(value ?? "").trim().replace(",", "."); if (!text) throw new Error(message); const parsed = this.parseiunaAmount(text); if (parsed === 0 && !/^0(?:\.0*)?$/.test(text)) throw new Error(message); @@ -2221,6 +2225,83 @@ window.iunaApp = function iunaApp() { } }, + transferMaxDisabled() { + return this.selectedTransferUtxoTotal() <= 0 && Number(this.status.wallet_balance || 0) <= 0; + }, + + async setMaxTransferAmount() { + try { + let selectedTotal = this.selectedTransferUtxoTotal(); + if (this.selectedTransferUtxos.length === 0) { + const utxos = await this.fetchJson("/api/wallet/utxos/selectable"); + this.rememberUtxoAmounts(utxos); + this.selectedTransferUtxos = utxos.map((utxo) => this.utxoOutpoint(utxo)); + this.lastSelectedTransferUtxo = + this.selectedTransferUtxos[this.selectedTransferUtxos.length - 1] || null; + selectedTotal = utxos.reduce((sum, utxo) => sum + Number(utxo.amount || 0), 0); + } + + if (selectedTotal <= 0) { + this.showFlash("No spendable UTXOs", "error"); + return; + } + + const amount = await this.maxTransferAmountForSelectedUtxos(selectedTotal); + this.transferAmount = this.amountLabel(amount); + this.scheduleFeeEstimates(); + } catch (error) { + this.showFlash(error.message, "error"); + } + }, + + async maxTransferAmountForSelectedUtxos(selectedTotal) { + const total = this.microiunaAmount(selectedTotal); + let low = 1; + let high = total; + let bestAmount = 0; + let bestEstimate = null; + + while (low <= high) { + const amount = Math.floor((low + high) / 2); + try { + const estimate = await this.transferFeeEstimateForAmount(amount); + const required = amount + this.microiunaAmount(estimate.fee); + if (required <= total) { + bestAmount = amount; + bestEstimate = estimate; + low = amount + 1; + } else { + high = amount - 1; + } + } catch { + high = amount - 1; + } + } + + if (bestAmount <= 0 || !bestEstimate) { + throw new Error("Selected UTXOs do not cover amount plus fee"); + } + this.feeEstimates.transfer = bestEstimate; + return bestAmount; + }, + + async transferFeeEstimateForAmount(amount) { + const recipient = this.transferTo.trim() || this.status.wallet_address; + if (!recipient) { + throw new Error("Recipient is required before max can estimate fees"); + } + const estimate = await this.fetchFeeEstimate("/api/fee-estimate/transfer", { + to: recipient, + amount, + fee_per_byte: this.parseiunaAmount(this.transferFee), + utxos: this.selectedTransferUtxos.join("\n"), + }); + if (estimate?.error || !Number.isFinite(Number(estimate?.fee))) { + throw new Error(estimate?.error || "Could not estimate transfer fee"); + } + return estimate; + }, + toggleSendAdvanced() { this.showSendAdvanced = !this.showSendAdvanced; if (!this.showSendAdvanced) { @@ -2583,16 +2664,21 @@ window.iunaApp = function iunaApp() { selectedTransferUtxoTotal() { return this.selectedTransferUtxos.reduce((sum, outpoint) => { - return sum + Number(this.selectedTransferUtxoAmounts[outpoint] || 0); + return sum + this.microiunaAmount(this.selectedTransferUtxoAmounts[outpoint]); }, 0); }, transferRequiredTotal() { - return this.parseiunaAmount(this.transferAmount) + Number(this.feeEstimates.transfer?.fee || 0); + return this.parseiunaAmount(this.transferAmount) + this.microiunaAmount(this.feeEstimates.transfer?.fee); }, selectedTransferUtxosCoverTransfer() { - return this.selectedTransferUtxos.length === 0 || this.selectedTransferUtxoTotal() >= this.transferRequiredTotal(); + return this.selectedTransferUtxoShortfall() === 0; + }, + + selectedTransferUtxoShortfall() { + if (this.selectedTransferUtxos.length === 0) return 0; + return Math.max(0, this.microiunaAmount(this.transferRequiredTotal()) - this.microiunaAmount(this.selectedTransferUtxoTotal())); }, txInputAmountLabel(input) {