commit 083e1536f109a026d64e669d483dbd5a934c6a66
parent 73b9bc260b6b8d380f1f29ce07ca3f3e8628a5e4
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Fri, 31 Jul 2026 21:50:43 +0200
Paginate management datasets
Diffstat:
| M | src/adapters/http.rs | | | 223 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------- |
| M | www/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() {