iuna

iuna

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

commit 17d6875a81206452ea3b1214ec53ba1e1f199fa5
parent 5366c29c04720a97d6f48b6f20d43fadfd757a59
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue,  8 Sep 2026 22:14:51 +0200

feat(wallet): allow custom fee rates

Diffstat:
Mtests/lightweight-wallet.test.cjs | 20++++++++++++++++++++
Mwallet/app.js | 8+++++---
Mwallet/wallet-core.js | 17++++++++++++++---
3 files changed, 39 insertions(+), 6 deletions(-)

diff --git a/tests/lightweight-wallet.test.cjs b/tests/lightweight-wallet.test.cjs @@ -64,3 +64,23 @@ test('explains oversized transactions before submission', () => { /205 inputs, 65 KiB; maximum 64 KiB.*smaller amount.*consolidate/s, ); }); + +test('validates a configurable fee rate with a default minimum of one', () => { + assert.equal(core.parseFeeRate('1'), 1n); + assert.equal(core.parseFeeRate('25'), 25n); + assert.throws(() => core.parseFeeRate('0'), /at least 1/); + assert.throws(() => core.parseFeeRate('1.5'), /whole number/); +}); + +test('uses the selected fee rate when calculating a transfer', () => { + const utxos = [{ + outpoint: { txid: 'aa'.repeat(32), index: 0 }, + output: { address: 'bb'.repeat(32), amount: 1_000_000 }, + }]; + const owner = 'bb'.repeat(32); + const recipient = 'cc'.repeat(32); + const standard = core.selectInputs(utxos, 100_000n, 1n, owner, recipient); + const priority = core.selectInputs(utxos, 100_000n, 5n, owner, recipient); + + assert.equal(priority.fee, standard.fee * 5n); +}); diff --git a/wallet/app.js b/wallet/app.js @@ -1,7 +1,7 @@ import { API_BASE, LEGACY_STORAGE_KEY, STORAGE_KEY, api, buildSignedTransfer, decodeAddress, decryptWallet, encodeAddress, encryptWallet, formatIuna, normalizeWalletStore, - parseIuna, removeWallet, upsertWallet, walletFromSeed, walletId, + parseFeeRate, parseIuna, removeWallet, upsertWallet, walletFromSeed, walletId, } from "./wallet-core.js"; import { generateMnemonic, validateMnemonic } from "./mnemonic.js"; @@ -210,7 +210,8 @@ function renderHome() { function renderSend() { if (state.walletMeta?.type === "readonly") return `${topbar()}<p class="eyebrow">Watch-only</p><h1 class="view-title">Sending is disabled.</h1><p class="view-copy">This wallet contains no seed or private key, so it cannot sign transactions.</p><button class="button secondary" data-view="home" style="width:100%">Back to overview</button>`; - return `${topbar()}<p class="eyebrow">Transaction</p><h1 class="view-title">Send IUNA</h1><p class="view-copy">The transaction is signed on this device.</p><form id="send-form" class="panel send-card"><div class="field"><label for="recipient">Recipient</label><input id="recipient" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="iuna1q…" required></div><div class="field"><label for="amount">Amount</label><div class="amount-wrap"><input id="amount" inputmode="decimal" placeholder="0.00" required><span>IUNA</span></div></div><div class="fee-line"><span>Available</span><strong>${formatIuna(state.balance?.spendable)} IUNA</strong></div><div class="fee-line"><span>Fee rate</span><strong>${escapeHtml(state.status.default_fee_per_byte)} µIUNA / byte</strong></div><button class="button" type="submit">Review transaction</button></form>`; + const defaultFeeRate = state.status.default_fee_per_byte ?? 1; + return `${topbar()}<p class="eyebrow">Transaction</p><h1 class="view-title">Send IUNA</h1><p class="view-copy">The transaction is signed on this device.</p><form id="send-form" class="panel send-card"><div class="field"><label for="recipient">Recipient</label><input id="recipient" autocomplete="off" autocapitalize="none" spellcheck="false" placeholder="iuna1q…" required></div><div class="field"><label for="amount">Amount</label><div class="amount-wrap"><input id="amount" inputmode="decimal" placeholder="0.00" required><span>IUNA</span></div></div><div class="field"><label for="fee-rate">Fee rate (µIUNA per byte)</label><input id="fee-rate" name="fee-rate" type="number" inputmode="numeric" min="1" step="1" value="${escapeHtml(defaultFeeRate)}" required></div><div class="fee-line"><span>Available</span><strong>${formatIuna(state.balance?.spendable)} IUNA</strong></div><button class="button" type="submit">Review transaction</button></form>`; } function renderReceive() { @@ -317,7 +318,8 @@ app.addEventListener("submit", async (event) => { } else if (form.id === "send-form") { if (state.walletMeta?.type === "readonly") throw new Error("Watch-only wallets cannot sign transactions"); const amount = parseIuna(form.amount.value); - const built = await buildSignedTransfer({ wallet: state.wallet, status: state.status, utxos: state.utxos, recipientAddress: form.recipient.value, amount }); + const feeRate = parseFeeRate(form["fee-rate"].value); + const built = await buildSignedTransfer({ wallet: state.wallet, status: state.status, utxos: state.utxos, recipientAddress: form.recipient.value, amount, feeRate }); renderConfirmation(built.transaction, built.fee, form.recipient.value.trim(), amount); } } catch (error) { diff --git a/wallet/wallet-core.js b/wallet/wallet-core.js @@ -300,12 +300,13 @@ export function selectInputs(utxos, amount, feeRate, owner, recipient) { throw new Error("Insufficient spendable balance for the amount and network fee"); } -export async function buildSignedTransfer({ wallet, status, utxos, recipientAddress, amount }) { +export async function buildSignedTransfer({ wallet, status, utxos, recipientAddress, amount, feeRate }) { if (!wallet?.privateKey) throw new Error("This watch-only wallet cannot sign transactions"); const expectedHrp = status.chain_id.includes("testnet") || status.chain_id.includes("e2e") ? "tiuna" : "iuna"; const recipient = decodeAddress(recipientAddress, expectedHrp); - const feeRate = BigInt(status.default_fee_per_byte || 1); - const { inputs, total, fee } = selectInputs(utxos, amount, feeRate, wallet.publicKeyHex, recipient); + const selectedFeeRate = BigInt(feeRate ?? status.default_fee_per_byte ?? 1); + if (selectedFeeRate < 1n) throw new Error("Fee rate must be at least 1 µIUNA per byte"); + const { inputs, total, fee } = selectInputs(utxos, amount, selectedFeeRate, wallet.publicKeyHex, recipient); const outputs = [{ address: recipient, amount }]; const change = total - amount - fee; if (change > 0n) outputs.push({ address: wallet.publicKeyHex, amount: change }); @@ -340,6 +341,16 @@ export function formatIuna(value, maximumFractionDigits = 6) { return `${whole.toLocaleString("en-US")}${fraction ? `.${fraction}` : ""}`; } +export function parseFeeRate(value) { + const normalized = String(value).trim(); + if (!/^\d+$/.test(normalized) || BigInt(normalized) < 1n) { + throw new Error("Fee rate must be a whole number of at least 1 µIUNA per byte"); + } + const feeRate = BigInt(normalized); + if (feeRate > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Fee rate is too large"); + return feeRate; +} + export async function api(path, options = {}) { const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs || 8_000);