iuna

iuna

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

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:
Msrc/adapters/http/index_html.rs | 37+++++++++++++++++++++++++++++++++++--
Msrc/domain/address.rs | 7+++++++
Msrc/main_tests.rs | 11+++++++++++
Mwww/assets/iuna-ui.js | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++-------
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; } },