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