iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 083e1536f109a026d64e669d483dbd5a934c6a66
parent 73b9bc260b6b8d380f1f29ce07ca3f3e8628a5e4
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 31 Jul 2026 21:50:43 +0200

Paginate management datasets

Diffstat:
Msrc/adapters/http.rs | 223++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mwww/assets/iuna-ui.js | 212++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
2 files changed, 391 insertions(+), 44 deletions(-)

diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -40,6 +40,8 @@ use crate::{ const EXPLORER_LIMIT: usize = 50; const EXPLORER_PAGE_LIMIT: usize = 20; +const DATASET_LIMIT: usize = 1_000; +const DATASET_PAGE_LIMIT: usize = 25; const AUTH_COOKIE_NAME: &str = "iuna_session"; const AUTH_SESSION_TTL_MS: u64 = 12 * 60 * 60 * 1_000; const AUTH_MAX_FAILED_ATTEMPTS: u32 = 5; @@ -186,10 +188,27 @@ struct BlocksQuery { } #[derive(Debug, Default, Deserialize)] +struct PageQuery { + offset: Option<usize>, + limit: Option<usize>, +} + +#[derive(Debug, Default, Deserialize)] struct WalletTransactionsQuery { tx: Option<bool>, mine: Option<bool>, burn: Option<bool>, + offset: Option<usize>, + limit: Option<usize>, +} + +impl WalletTransactionsQuery { + fn page(&self) -> PageQuery { + PageQuery { + offset: self.offset, + limit: self.limit, + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -233,6 +252,17 @@ struct ActionResponse { error: Option<String>, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct Page<T> { + items: Vec<T>, + offset: usize, + limit: usize, + total: usize, + has_more: bool, + next_offset: Option<usize>, +} + #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct MetricsResponse { @@ -396,6 +426,10 @@ pub async fn serve( .route("/api/wallet/generate", post(api_wallet_generate_form)) .route("/api/wallet/import", post(api_wallet_import_form)) .route("/api/wallet/transactions", get(api_wallet_transactions)) + .route( + "/api/wallet/utxos/selectable", + get(api_wallet_selectable_utxos), + ) .route("/api/wallet/utxos", get(api_wallet_utxos)) .route( "/api/fee-estimate/transfer", @@ -701,40 +735,85 @@ async fn api_wallet_setup( wallet_setup_json(wallet_setup_response(&state, &headers).await) } -async fn api_mempool(State(state): State<HttpState>) -> Json<Vec<UiTransaction>> { +async fn api_mempool( + State(state): State<HttpState>, + Query(query): Query<PageQuery>, +) -> Json<Page<UiTransaction>> { let node = state.node.lock().await; let snapshot = node.chain_snapshot(); let pending = node.pending_transactions(); let outputs = known_output_index(&snapshot.genesis_allocations, &snapshot.blocks, &pending); - Json( + Json(page_items( pending .iter() .map(|tx| ui_transaction(tx, &outputs)) .collect(), - ) + query, + )) } async fn api_wallet_transactions( State(state): State<HttpState>, Query(query): Query<WalletTransactionsQuery>, -) -> Json<Vec<WalletTransactionRow>> { +) -> Json<Page<WalletTransactionRow>> { let node = state.node.lock().await; let snapshot = node.chain_snapshot(); let pending = node.pending_transactions(); let outputs = known_output_index(&snapshot.genesis_allocations, &snapshot.blocks, &pending); + let page_query = query.page(); let filters = WalletTransactionFilters::from_query(query); - Json(wallet_transaction_rows( - node.wallet_address(), - pending, - &snapshot.blocks, - &outputs, - filters, + Json(page_items( + wallet_transaction_rows( + node.wallet_address(), + pending, + &snapshot.blocks, + &outputs, + filters, + ), + page_query, )) } -async fn api_wallet_utxos(State(state): State<HttpState>) -> Json<Vec<WalletUtxoRow>> { +async fn api_wallet_utxos( + State(state): State<HttpState>, + Query(query): Query<PageQuery>, +) -> Json<Page<WalletUtxoRow>> { let node = state.node.lock().await; - Json(wallet_utxo_rows(node.ledger(), node.wallet_address())) + Json(page_items( + wallet_utxo_rows(node.ledger(), node.wallet_address()), + query, + )) +} + +async fn api_wallet_selectable_utxos(State(state): State<HttpState>) -> Json<Vec<WalletUtxoRow>> { + let node = state.node.lock().await; + Json(selectable_wallet_utxo_rows( + node.ledger(), + node.wallet_address(), + )) +} + +fn page_items<T>(items: Vec<T>, query: PageQuery) -> Page<T> { + let total = items.len(); + let offset = query.offset.unwrap_or(0).min(total); + let limit = query + .limit + .unwrap_or(DATASET_PAGE_LIMIT) + .clamp(1, DATASET_LIMIT); + let page_items = items + .into_iter() + .skip(offset) + .take(limit) + .collect::<Vec<_>>(); + let next_offset = offset + page_items.len(); + Page { + items: page_items, + offset, + limit, + total, + has_more: next_offset < total, + next_offset: (next_offset < total).then_some(next_offset), + } } fn wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoRow> { @@ -767,8 +846,18 @@ fn wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoRow> { utxos } -async fn api_peers(State(state): State<HttpState>) -> Json<Vec<PeerInfo>> { - Json(state.peers.lock().await.list()) +fn selectable_wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoRow> { + wallet_utxo_rows(ledger, wallet) + .into_iter() + .filter(|utxo| utxo.spendable) + .collect() +} + +async fn api_peers( + State(state): State<HttpState>, + Query(query): Query<PageQuery>, +) -> Json<Page<PeerInfo>> { + Json(page_items(state.peers.lock().await.list(), query)) } async fn api_p2p_metrics(State(state): State<HttpState>) -> Json<P2pMetrics> { @@ -2648,6 +2737,11 @@ const INDEX_HTML: &str = r#"<!doctype html> .skeleton-line.short { width: 42%; } .skeleton-line.medium { width: 68%; } .skeleton-line.long { width: 88%; } + .page-sentinel { min-height: 1px; } + .block-page-sentinel { flex: 0 0 1px; min-height: 100px; } + .dataset-loader { display: grid; gap: 8px; min-width: 0; } + .skeleton-table-cell { height: 12px; width: 100%; border-radius: 6px; background: #2b3136; } + tr.skeleton-card td { padding-top: 11px; padding-bottom: 11px; } .detail-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 12px; } .detail-kv { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 8px; font-size: 13px; margin: 7px 0; } .detail-kv .key { color: #8d989f; } @@ -2787,7 +2881,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="send-utxo-list-head"> <span>UTXOs</span> <span class="send-utxo-actions"> - <button class="utxo-select-button" type="button" @click="selectAllTransferUtxos" :disabled="spendableWalletUtxos().length === 0">Select all</button> + <button class="utxo-select-button" type="button" @click="selectAllTransferUtxos" :disabled="walletUtxoPage.loading && walletUtxos.length === 0">Select all</button> <button class="utxo-select-button" type="button" @click="clearTransferUtxos" :disabled="selectedTransferUtxos.length === 0">None</button> </span> </div> @@ -2801,7 +2895,12 @@ const INDEX_HTML: &str = r#"<!doctype html> </span> </label> </template> - <div class="tx-modal-empty" x-show="walletUtxos.length === 0">No UTXOs</div> + <div class="dataset-loader" x-show="walletUtxoPage.loading" aria-hidden="true"> + <div class="send-utxo-option skeleton-card"><span><span class="skeleton-line medium"></span><span class="skeleton-line long"></span></span></div> + <div class="send-utxo-option skeleton-card"><span><span class="skeleton-line short"></span><span class="skeleton-line long"></span></span></div> + </div> + <div class="page-sentinel" x-show="walletUtxoPage.hasMore" x-init="$nextTick(() => observePageSentinel('walletUtxo', $el))"></div> + <div class="tx-modal-empty" x-show="walletUtxos.length === 0 && !walletUtxoPage.loading">No UTXOs</div> </div> </div> <button class="primary" type="submit">Send</button> @@ -2852,7 +2951,12 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> </div> </template> - <div class="muted" x-show="walletTransactions().length === 0">No wallet transactions</div> + <div class="dataset-loader" x-show="walletTxPage.loading" aria-hidden="true"> + <div class="wallet-tx-row skeleton-card"><div class="wallet-tx-main"><div class="skeleton-line medium"></div><div class="skeleton-line short"></div><div class="skeleton-line long"></div></div></div> + <div class="wallet-tx-row skeleton-card"><div class="wallet-tx-main"><div class="skeleton-line short"></div><div class="skeleton-line medium"></div><div class="skeleton-line long"></div></div></div> + </div> + <div class="page-sentinel" x-show="walletTxPage.hasMore" x-init="$nextTick(() => observePageSentinel('walletTx', $el))"></div> + <div class="muted" x-show="walletTransactions().length === 0 && !walletTxPage.loading">No wallet transactions</div> </div> </div> </div> @@ -3024,7 +3128,14 @@ const INDEX_HTML: &str = r#"<!doctype html> <td><div class="peer-actions"><button class="peer-remove" type="button" x-show="canRemovePeer(peer)" @click="removePeer(peer)">Remove</button><span class="muted" x-show="!canRemovePeer(peer)">Observed</span></div></td> </tr> </template> - <tr x-show="peers.length === 0"><td colspan="18">No peers</td></tr> + <tr class="skeleton-card" x-show="peerPage.loading" aria-hidden="true"> + <td colspan="18"><div class="skeleton-table-cell"></div></td> + </tr> + <tr class="skeleton-card" x-show="peerPage.loading" aria-hidden="true"> + <td colspan="18"><div class="skeleton-table-cell"></div></td> + </tr> + <tr x-show="peerPage.hasMore"><td colspan="18"><div class="page-sentinel" x-init="$nextTick(() => observePageSentinel('peer', $el))"></div></td></tr> + <tr x-show="peers.length === 0 && !peerPage.loading"><td colspan="18">No peers</td></tr> </tbody> </table> </div> @@ -3096,6 +3207,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="skeleton-line long"></div> </div> </template> + <div class="page-sentinel block-page-sentinel" x-show="hasMoreBlocks" x-init="$nextTick(() => observeBlockSentinel($el))"></div> </div> </div> @@ -3136,7 +3248,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="muted" x-show="!selectedBlock">Select a block</div> </section> - <section class="panel mempool-panel" x-show="mempool.length > 0"> + <section class="panel mempool-panel" x-show="mempool.length > 0 || mempoolPage.loading"> <h2>Mempool</h2> <div class="mempool-strip"> <template x-for="tx in mempool" :key="tx.signature"> @@ -3151,6 +3263,14 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="tx-field"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> </div> </template> + <template x-if="mempoolPage.loading"> + <div class="mempool-item skeleton-card" aria-hidden="true"> + <div class="skeleton-line short"></div> + <div class="skeleton-line medium"></div> + <div class="skeleton-line long"></div> + </div> + </template> + <div class="page-sentinel" x-show="mempoolPage.hasMore" x-init="$nextTick(() => observePageSentinel('mempool', $el))"></div> </div> </section> </div> @@ -3317,7 +3437,12 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="tx-field"><span class="tx-label">Address</span><code class="tx-value hash" x-text="utxo.address"></code></div> </div> </template> - <div class="tx-modal-empty" x-show="walletUtxos.length === 0">No wallet UTXOs</div> + <div class="dataset-loader" x-show="walletUtxoPage.loading" aria-hidden="true"> + <div class="wallet-utxo-row skeleton-card"><div class="skeleton-line medium"></div><div class="skeleton-line long"></div></div> + <div class="wallet-utxo-row skeleton-card"><div class="skeleton-line short"></div><div class="skeleton-line long"></div></div> + </div> + <div class="page-sentinel" x-show="walletUtxoPage.hasMore" x-init="$nextTick(() => observePageSentinel('walletUtxo', $el))"></div> + <div class="tx-modal-empty" x-show="walletUtxos.length === 0 && !walletUtxoPage.loading">No wallet UTXOs</div> </div> </section> </div> @@ -4292,6 +4417,8 @@ mod tests { tx: Some(false), mine: Some(true), burn: Some(true), + offset: None, + limit: None, }), WalletTransactionFilters { transfer: false, @@ -4302,6 +4429,42 @@ mod tests { } #[test] + fn page_items_returns_bounded_slices_with_next_offset() { + let page = super::page_items( + vec![1, 2, 3, 4, 5], + super::PageQuery { + offset: Some(1), + limit: Some(2), + }, + ); + + assert_eq!(page.items, vec![2, 3]); + assert_eq!(page.offset, 1); + assert_eq!(page.limit, 2); + assert_eq!(page.total, 5); + assert!(page.has_more); + assert_eq!(page.next_offset, Some(3)); + } + + #[test] + fn page_items_clamps_limit_and_empty_tail() { + let page = super::page_items( + vec![1, 2], + super::PageQuery { + offset: Some(20), + limit: Some(0), + }, + ); + + assert!(page.items.is_empty()); + assert_eq!(page.offset, 2); + assert_eq!(page.limit, 1); + assert_eq!(page.total, 2); + assert!(!page.has_more); + assert_eq!(page.next_offset, None); + } + + #[test] fn mine_transaction_views_include_protocol_finalizer_fee() { let alice = Wallet::from_seed("wallet-mine-fee-alice"); let ledger = Ledger::new(BTreeMap::new(), 1); @@ -4388,6 +4551,26 @@ mod tests { ); } + #[test] + fn selectable_wallet_utxo_rows_include_only_spendable_outputs() { + let alice = Wallet::from_seed("wallet-utxo-selectable-alice"); + let bob = Wallet::from_seed("wallet-utxo-selectable-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10); + let mut ledger = Ledger::new(allocations, 1); + let pending = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); + let Transaction::Transfer { inputs, .. } = &pending else { + panic!("expected transfer"); + }; + let spent_outpoint = inputs[0].outpoint.clone(); + + ledger.submit_transaction(pending).unwrap(); + let rows = super::selectable_wallet_utxo_rows(&ledger, alice.address()); + + assert!(!rows.iter().any(|row| row.outpoint == spent_outpoint)); + assert!(rows.iter().all(|row| row.spendable)); + } + fn fake_block(height: u64, transactions: Vec<Transaction>) -> Block { Block { height, diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -76,6 +76,7 @@ window.iunaApp = function iunaApp() { feeEstimateTimer: null, showSendAdvanced: false, selectedTransferUtxos: [], + selectedTransferUtxoAmounts: {}, walletTxFilters: { transfer: true, mine: false, burn: false }, setupPeerAddress: "iuna.jhx.app:9444", peerAddress: "", @@ -89,6 +90,11 @@ window.iunaApp = function iunaApp() { newBlockHashes: new Set(), newBlockTimer: null, blockPageSize: 20, + datasetPageSize: 25, + walletTxPage: { offset: 0, total: 0, hasMore: true, loading: false }, + walletUtxoPage: { offset: 0, total: 0, hasMore: true, loading: false }, + mempoolPage: { offset: 0, total: 0, hasMore: true, loading: false }, + peerPage: { offset: 0, total: 0, hasMore: true, loading: false }, init() { this.bootstrap(); @@ -491,7 +497,7 @@ window.iunaApp = function iunaApp() { throw new Error(payload.error || `/api/config returned ${response.status}`); } await this.refreshConfig(); - this.peers = await this.fetchJson("/api/peers"); + await this.resetPagedDataset("peer"); this.setupFeedback = null; this.generatedSeedPhrase = ""; this.importSeedPhrase = ""; @@ -507,18 +513,20 @@ window.iunaApp = function iunaApp() { async refresh() { try { - const [config, status, blocks, walletTxs, walletUtxos, mempool, peers, p2pMetrics, blockchainMetrics, networkHealth] = await Promise.all([ + const [config, status, blocks, p2pMetrics, blockchainMetrics, networkHealth] = await Promise.all([ this.fetchJson("/api/config"), this.fetchJson("/api/status"), this.fetchJson("/api/blocks?limit=30"), - this.fetchJson(this.walletTransactionsPath()), - this.fetchJson("/api/wallet/utxos"), - this.fetchJson("/api/mempool"), - this.fetchJson("/api/peers"), this.fetchJson("/api/p2p/metrics"), this.fetchJson("/api/metrics"), this.fetchJson("/api/network/health"), ]); + await Promise.all([ + this.refreshPagedDataset("walletTx"), + this.refreshPagedDataset("walletUtxo"), + this.refreshPagedDataset("mempool"), + this.refreshPagedDataset("peer"), + ]); this.config = config; this.syncConfigState(); if (!this.allowedTabs().includes(this.tab)) { @@ -529,11 +537,7 @@ window.iunaApp = function iunaApp() { } this.status = status; this.mergeFreshBlocks(blocks, { animateHead: true }); - this.walletTxs = walletTxs; - this.walletUtxos = walletUtxos; this.pruneSelectedTransferUtxos(); - this.mempool = mempool; - this.peers = peers; this.p2pMetrics = p2pMetrics; this.blockchainMetrics = blockchainMetrics; this.networkHealth = networkHealth; @@ -568,6 +572,151 @@ window.iunaApp = function iunaApp() { return response.json(); }, + datasetConfig(kind) { + return { + walletTx: { + items: "walletTxs", + page: "walletTxPage", + path: () => this.walletTransactionsPath(), + key: (tx) => `${tx.status || ""}:${tx.signature || ""}`, + }, + walletUtxo: { + items: "walletUtxos", + page: "walletUtxoPage", + path: () => "/api/wallet/utxos", + key: (utxo) => this.utxoOutpoint(utxo), + }, + mempool: { + items: "mempool", + page: "mempoolPage", + path: () => "/api/mempool", + key: (tx) => tx.signature || "", + }, + peer: { + items: "peers", + page: "peerPage", + path: () => "/api/peers", + key: (peer) => peer.address || "", + }, + }[kind]; + }, + + async resetPagedDataset(kind) { + const config = this.datasetConfig(kind); + if (!config) return; + this[config.items] = []; + Object.assign(this[config.page], { offset: 0, total: 0, hasMore: true, loading: false }); + await this.refreshPagedDataset(kind); + }, + + async refreshPagedDataset(kind) { + const config = this.datasetConfig(kind); + if (!config) return; + const page = this[config.page]; + if (page.loading) return; + const currentLength = this[config.items].length; + const limit = Math.max(this.datasetPageSize, currentLength || 0); + await this.loadPagedDataset(kind, { offset: 0, limit, replace: true }); + }, + + async loadNextPage(kind) { + const config = this.datasetConfig(kind); + if (!config) return; + const page = this[config.page]; + if (page.loading || !page.hasMore) return; + await this.loadPagedDataset(kind, { + offset: page.offset ?? this[config.items].length, + limit: this.datasetPageSize, + replace: false, + }); + }, + + async loadPagedDataset(kind, options) { + const config = this.datasetConfig(kind); + const page = this[config.page]; + page.loading = true; + try { + const payload = await this.fetchJson( + this.paginatedPath(config.path(), options.offset, options.limit) + ); + const normalized = this.normalizedPage(payload, options.offset, options.limit); + this[config.items] = options.replace + ? normalized.items + : this.mergeDatasetItems(this[config.items], normalized.items, config.key); + page.offset = normalized.nextOffset ?? this[config.items].length; + page.total = normalized.total; + page.hasMore = normalized.hasMore; + if (kind === "walletUtxo") { + this.rememberUtxoAmounts(this.walletUtxos); + this.pruneSelectedTransferUtxos(); + } + } catch (error) { + this.showFlash(error.message, "error"); + } finally { + page.loading = false; + } + }, + + paginatedPath(path, offset, limit) { + const url = new URL(path, window.location.origin); + url.searchParams.set("offset", String(offset)); + url.searchParams.set("limit", String(limit)); + return `${url.pathname}?${url.searchParams.toString()}`; + }, + + normalizedPage(payload, offset, limit) { + if (Array.isArray(payload)) { + const nextOffset = offset + payload.length; + return { + items: payload, + total: nextOffset, + hasMore: payload.length >= limit, + nextOffset, + }; + } + const items = Array.isArray(payload?.items) ? payload.items : []; + return { + items, + total: Number(payload?.total ?? offset + items.length), + hasMore: payload?.hasMore === true, + nextOffset: payload?.nextOffset ?? offset + items.length, + }; + }, + + mergeDatasetItems(existing, incoming, keyFn) { + const rows = []; + const seen = new Set(); + for (const item of [...existing, ...incoming]) { + const key = keyFn(item); + if (!key || seen.has(key)) continue; + seen.add(key); + rows.push(item); + } + return rows; + }, + + observePageSentinel(kind, element) { + if (!element || element.__iunaPageObserver) return; + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + this.loadNextPage(kind); + } + }, { root: null, rootMargin: "180px 0px" }); + observer.observe(element); + element.__iunaPageObserver = observer; + }, + + observeBlockSentinel(element) { + if (!element || element.__iunaBlockObserver) return; + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + this.loadOlderBlocks(); + } + }, { root: null, rootMargin: "180px 0px" }); + observer.observe(element); + element.__iunaBlockObserver = observer; + }, + walletTransactionsPath() { const params = new URLSearchParams({ tx: String(this.walletTxFilters.transfer), @@ -578,11 +727,7 @@ window.iunaApp = function iunaApp() { }, async refreshWalletTransactions() { - try { - this.walletTxs = await this.fetchJson(this.walletTransactionsPath()); - } catch (error) { - this.showFlash(error.message, "error"); - } + await this.resetPagedDataset("walletTx"); }, async checkLatestRelease() { @@ -1293,6 +1438,7 @@ window.iunaApp = function iunaApp() { this.transferTo = ""; this.transferAmount = null; this.selectedTransferUtxos = []; + this.selectedTransferUtxoAmounts = {}; this.showSendAdvanced = false; this.feeEstimates.transfer = null; } catch (error) { @@ -1458,26 +1604,44 @@ window.iunaApp = function iunaApp() { return this.walletUtxos.filter((utxo) => utxo.spendable !== false); }, + rememberUtxoAmounts(utxos) { + for (const utxo of utxos || []) { + this.selectedTransferUtxoAmounts[this.utxoOutpoint(utxo)] = Number(utxo.amount || 0); + } + }, + pruneSelectedTransferUtxos() { - const spendable = new Set(this.spendableWalletUtxos().map((utxo) => this.utxoOutpoint(utxo))); - this.selectedTransferUtxos = this.selectedTransferUtxos.filter((outpoint) => spendable.has(outpoint)); + const visible = new Map(this.walletUtxos.map((utxo) => [this.utxoOutpoint(utxo), utxo])); + this.selectedTransferUtxos = this.selectedTransferUtxos.filter((outpoint) => { + const utxo = visible.get(outpoint); + return !utxo || utxo.spendable !== false; + }); }, - selectAllTransferUtxos() { - this.selectedTransferUtxos = this.spendableWalletUtxos().map((utxo) => this.utxoOutpoint(utxo)); - this.scheduleFeeEstimates(); + async selectAllTransferUtxos() { + try { + const utxos = await this.fetchJson("/api/wallet/utxos/selectable"); + this.rememberUtxoAmounts(utxos); + this.selectedTransferUtxos = utxos.map((utxo) => this.utxoOutpoint(utxo)); + this.scheduleFeeEstimates(); + if (this.selectedTransferUtxos.length === 0) { + this.showFlash("No spendable UTXOs", "error"); + } + } catch (error) { + this.showFlash(error.message, "error"); + } }, clearTransferUtxos() { this.selectedTransferUtxos = []; + this.selectedTransferUtxoAmounts = {}; this.scheduleFeeEstimates(); }, selectedTransferUtxoTotal() { - const selected = new Set(this.selectedTransferUtxos); - return this.walletUtxos - .filter((utxo) => utxo.spendable !== false && selected.has(this.utxoOutpoint(utxo))) - .reduce((sum, utxo) => sum + Number(utxo.amount || 0), 0); + return this.selectedTransferUtxos.reduce((sum, outpoint) => { + return sum + Number(this.selectedTransferUtxoAmounts[outpoint] || 0); + }, 0); }, transferRequiredTotal() {