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:
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) {