commit 6e7d136d6c901e71423428cc042c1678810bd3a5
parent afa9e771bc1723ebb3f89a6fe3d79163abcf14ab
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Mon, 31 Aug 2026 12:45:15 +0200
Fix transfer confirmation in production UI
Diffstat:
4 files changed, 106 insertions(+), 9 deletions(-)
diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs
@@ -227,6 +227,7 @@ pub(super) const INDEX_HTML: &str = concat!(
.mine-settings-form { display: grid; gap: 10px; }
.mine-fee-fields { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; }
.fee-preview { flex-basis: 100%; color: #9eb3bc; font-size: 12px; font-weight: 700; }
+ .fee-preview.error { color: #ffb1a8; }
.fee-warning { flex-basis: 100%; color: #ffd070; font-size: 12px; font-weight: 800; }
.mine-stats { display: grid; grid-template-columns: repeat(4, minmax(112px, 1fr)); gap: 8px; min-width: 0; }
.local-mining-stats { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
@@ -622,7 +623,7 @@ pub(super) const INDEX_HTML: &str = concat!(
<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>
+ <div class="fee-preview" :class="{ error: feeEstimateError('transfer') }" x-text="feeEstimateLabel('transfer')" role="status" aria-live="polite"></div>
<div class="fee-warning" x-show="feeExceedsAmount('transfer')" x-text="feeExceedsAmountLabel('transfer')" role="status" aria-live="polite"></div>
<button class="advanced-toggle" type="button" @click="toggleSendAdvanced" x-text="showSendAdvanced ? 'Hide UTXOs' : 'UTXOs'"></button>
<div class="send-utxo-summary" x-show="showSendAdvanced">
@@ -656,7 +657,7 @@ pub(super) const INDEX_HTML: &str = concat!(
<div class="tx-modal-empty" x-show="walletUtxos.length === 0 && !walletUtxoPage.loading">No UTXOs</div>
</div>
</div>
- <button class="primary" type="submit">Send</button>
+ <button class="primary" type="submit" :disabled="sendPreparing || sendConfirmBusy" x-text="sendPreparing ? 'Checking...' : 'Send'"></button>
</form>
</div>
<div class="panel">
@@ -1379,6 +1380,38 @@ pub(super) const INDEX_HTML: &str = concat!(
<p class="muted">This recovery option removes only the local blockchain. Your wallet and settings stay on this device.</p>
</section>
</div>
+ <div class="setup-overlay transaction-overlay" x-show="sendConfirmModalOpen" x-transition.opacity @click.self="closeSendConfirmModal()" role="dialog" aria-modal="true" aria-labelledby="send-confirm-title">
+ <section class="tx-modal address-book-modal">
+ <div class="tx-modal-head">
+ <div class="tx-modal-title">
+ <span class="pill transfer">Send</span>
+ <h2 id="send-confirm-title">Confirm transfer</h2>
+ </div>
+ <button type="button" @click="closeSendConfirmModal" :disabled="sendConfirmBusy">Close</button>
+ </div>
+ <div class="info-copy">
+ <p>Check the full recipient address before sending.</p>
+ <div class="info-fact">
+ <div class="label">Recipient</div>
+ <code class="tx-value hash" x-text="pendingTransfer?.recipient || '-'"></code>
+ </div>
+ <div class="info-facts">
+ <div class="info-fact">
+ <div class="label">Amount</div>
+ <div class="value">IUNA <span x-text="amountLabel(pendingTransfer?.amount || 0)"></span></div>
+ </div>
+ <div class="info-fact">
+ <div class="label">Fee / byte</div>
+ <div class="value">IUNA <span x-text="amountLabel(pendingTransfer?.fee || 0)"></span></div>
+ </div>
+ </div>
+ </div>
+ <div class="danger-actions">
+ <button class="subtle" type="button" @click="closeSendConfirmModal" :disabled="sendConfirmBusy">Cancel</button>
+ <button class="primary" type="button" @click="confirmTransfer" :disabled="sendConfirmBusy" x-text="sendConfirmBusy ? 'Sending...' : 'Confirm and send'"></button>
+ </div>
+ </section>
+ </div>
<div class="setup-overlay transaction-overlay" x-show="chainResetModalOpen" x-transition.opacity @click.self="closeChainResetModal()" role="dialog" aria-modal="true" aria-labelledby="chain-reset-title">
<section class="tx-modal">
<div class="tx-modal-head">
diff --git a/src/domain/address.rs b/src/domain/address.rs
@@ -279,6 +279,13 @@ mod tests {
}
#[test]
+ fn production_reported_mainnet_address_is_valid() {
+ let address = "iuna1q7fj9u5pqpuq0gfl4a3a7afsqfmfm22x4lgz4w8aqmggs4e33aqfqkx5kyt";
+
+ assert!(decode_address(address, AddressNetwork::Mainnet).is_ok());
+ }
+
+ #[test]
fn uppercase_is_accepted_but_mixed_case_is_rejected() {
let key = wallet_key();
let address = encode_address(&key, AddressNetwork::Mainnet).unwrap();
diff --git a/src/main_tests.rs b/src/main_tests.rs
@@ -97,6 +97,17 @@ fn management_ui_warns_when_estimated_fees_exceed_amounts() {
}
#[test]
+fn management_ui_uses_an_in_app_transfer_confirmation_and_marks_fee_errors() {
+ let html = include_str!("adapters/http/index_html.rs");
+ let javascript = include_str!("../www/assets/iuna-ui.js");
+ assert!(html.contains("Confirm transfer"));
+ assert!(html.contains("@click=\"confirmTransfer\""));
+ assert!(html.contains("error: feeEstimateError('transfer')"));
+ assert!(javascript.contains("async confirmTransfer()"));
+ assert!(!javascript.contains("window.confirm"));
+}
+
+#[test]
fn management_ui_blocks_interaction_while_the_node_is_syncing() {
let html = include_str!("adapters/http/index_html.rs");
let javascript = include_str!("../www/assets/iuna-ui.js");
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -100,6 +100,10 @@ window.iunaApp = function iunaApp() {
transferTo: "",
transferAmount: null,
transferFee: "0.000001",
+ sendPreparing: false,
+ sendConfirmModalOpen: false,
+ sendConfirmBusy: false,
+ pendingTransfer: null,
feeEstimates: { transfer: null, burn: null, mine: null },
feeEstimateTimer: null,
showSendAdvanced: false,
@@ -1323,6 +1327,7 @@ window.iunaApp = function iunaApp() {
},
closeModals() {
+ this.closeSendConfirmModal();
this.closeTransactionModal();
this.closeWalletUtxosModal();
this.closePowDifficultyInfo();
@@ -1483,6 +1488,10 @@ window.iunaApp = function iunaApp() {
return `${estimate.bytes} bytes -> IUNA ${this.amountLabel(estimate.fee)}`;
},
+ feeEstimateError(kind) {
+ return Boolean(this.feeEstimates[kind]?.error);
+ },
+
feeExceedsAmount(kind) {
const estimate = this.feeEstimates[kind];
if (!estimate || estimate.error) return false;
@@ -2362,19 +2371,54 @@ window.iunaApp = function iunaApp() {
},
async sendTransfer() {
+ if (this.sendPreparing || this.sendConfirmBusy) return;
+ this.sendPreparing = true;
try {
- const amount = this.parseiunaAmount(this.transferAmount);
+ const amount = this.parseiunaAmountRequired(this.transferAmount, "Transfer amount is required");
+ if (amount <= 0) throw new Error("Transfer amount must be greater than zero");
const fee = this.parseiunaAmountRequired(this.transferFee, "Transfer fee per byte is required");
const fullRecipient = this.transferTo.trim();
- if (!window.confirm(`Confirm transfer\n\nRecipient:\n${fullRecipient}\n\nAmount: ${this.amountLabel(amount)} IUNA`)) {
- return;
- }
- const recipient = this.short(fullRecipient);
+ if (!fullRecipient) throw new Error("Recipient is required");
+ const estimate = await this.transferFeeEstimateForAmount(amount);
+ this.feeEstimates.transfer = estimate;
+ this.pendingTransfer = {
+ recipient: fullRecipient,
+ amount,
+ fee,
+ utxos: this.selectedTransferUtxos.join("\n"),
+ };
+ this.sendConfirmModalOpen = true;
+ } catch (error) {
+ this.showFlash(error.message, "error");
+ } finally {
+ this.sendPreparing = false;
+ }
+ },
+
+ closeSendConfirmModal() {
+ if (this.sendConfirmBusy) return;
+ this.sendConfirmModalOpen = false;
+ this.pendingTransfer = null;
+ },
+
+ async confirmTransfer() {
+ const transfer = this.pendingTransfer;
+ if (!transfer || this.sendConfirmBusy) return;
+ this.sendConfirmBusy = true;
+ try {
+ const recipient = this.short(transfer.recipient);
await this.postForm(
"/api/transfer",
- { to: fullRecipient, amount, fee_per_byte: fee, utxos: this.selectedTransferUtxos.join("\n") },
- `Queued transfer of ${this.amountLabel(amount)} IUNA to ${recipient}`
+ {
+ to: transfer.recipient,
+ amount: transfer.amount,
+ fee_per_byte: transfer.fee,
+ utxos: transfer.utxos,
+ },
+ `Queued transfer of ${this.amountLabel(transfer.amount)} IUNA to ${recipient}`
);
+ this.sendConfirmModalOpen = false;
+ this.pendingTransfer = null;
this.transferTo = "";
this.transferAmount = null;
this.selectedTransferUtxos = [];
@@ -2383,6 +2427,8 @@ window.iunaApp = function iunaApp() {
this.feeEstimates.transfer = null;
} catch (error) {
this.showFlash(error.message, "error");
+ } finally {
+ this.sendConfirmBusy = false;
}
},