iuna

iuna

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

commit e38a8c52e94c6933e0596a0f5613f25e37e573c9
parent 17d6875a81206452ea3b1214ec53ba1e1f199fa5
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed,  9 Sep 2026 09:30:18 +0200

feat(wallet): filter and paginate transactions

Diffstat:
Msrc/adapters/wallet_endpoint.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mwallet/app.js | 143++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mwallet/node-theme.css | 6++++++
3 files changed, 224 insertions(+), 20 deletions(-)

diff --git a/src/adapters/wallet_endpoint.rs b/src/adapters/wallet_endpoint.rs @@ -98,10 +98,60 @@ struct TransactionResponse { #[derive(Debug, Default, Deserialize)] struct TransactionsQuery { + tx: Option<bool>, + mine: Option<bool>, + burn: Option<bool>, + reward: Option<bool>, offset: Option<usize>, limit: Option<usize>, } +#[derive(Clone, Copy)] +struct TransactionFilters { + transfer: bool, + mine: bool, + burn: bool, + reward: bool, +} + +impl TransactionFilters { + fn from_query(query: &TransactionsQuery) -> Self { + Self { + transfer: query.tx.unwrap_or(true), + // Keep the public endpoint's unfiltered default backwards compatible. + // The wallet sends all four choices explicitly. + mine: query.mine.unwrap_or(true), + burn: query.burn.unwrap_or(true), + reward: query.reward.unwrap_or(true), + } + } + + fn allows(self, transaction: &Transaction) -> bool { + match transaction { + Transaction::Transfer { .. } => self.transfer, + Transaction::Mine { .. } => self.mine, + Transaction::Burn { .. } => self.burn, + } + } + + fn kinds(self) -> Vec<&'static str> { + let mut kinds = Vec::with_capacity(4); + if self.transfer { + kinds.push("transfer"); + } + if self.mine { + kinds.push("mine"); + } + if self.burn { + kinds.push("burn"); + } + if self.reward { + kinds.push("reward"); + } + kinds + } +} + #[derive(Debug, Serialize)] struct TransactionsResponse { items: Vec<AddressTransaction>, @@ -277,6 +327,7 @@ async fn address_transactions( ) -> Result<Json<TransactionsResponse>, (StatusCode, Json<ApiError>)> { let offset = query.offset.unwrap_or(0); let limit = query.limit.unwrap_or(25).clamp(1, 100); + let filters = TransactionFilters::from_query(&query); let (public_key, pending) = { let node = state.node.lock().await; let public_key = node.normalize_user_address(&address).map_err(bad_request)?; @@ -285,6 +336,7 @@ async fn address_transactions( .into_iter() .rev() .filter(|transaction| transaction_mentions_address(transaction, &public_key)) + .filter(|transaction| filters.allows(transaction)) .collect::<Vec<_>>(); (public_key, pending) }; @@ -304,18 +356,18 @@ async fn address_transactions( let confirmed_offset = offset.saturating_sub(pending_total); let remaining = limit.saturating_sub(items.len()); - let store = state.ui_data_store.clone(); - let confirmed = tokio::task::spawn_blocking(move || { - store.load_wallet_transactions( - &public_key, - &["transfer", "mine", "burn", "reward"], - confirmed_offset, - remaining, - ) - }) - .await - .map_err(internal_error)? - .map_err(internal_error)?; + let kinds = filters.kinds(); + let confirmed = if kinds.is_empty() { + (Vec::new(), 0) + } else { + let store = state.ui_data_store.clone(); + tokio::task::spawn_blocking(move || { + store.load_wallet_transactions(&public_key, &kinds, confirmed_offset, remaining) + }) + .await + .map_err(internal_error)? + .map_err(internal_error)? + }; let confirmed_total = confirmed.1; items.extend(confirmed.0.into_iter().map(|row| AddressTransaction { kind: row.kind, @@ -591,6 +643,7 @@ mod tests { assert_eq!(value["status"], "pending"); let response = app + .clone() .oneshot( Request::builder() .uri(format!("/v1/addresses/{receive_address}/transactions")) @@ -604,5 +657,23 @@ mod tests { let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(value["total"], 1); assert_eq!(value["items"][0]["status"], "pending"); + + let response = app + .oneshot( + Request::builder() + .uri(format!( + "/v1/addresses/{receive_address}/transactions?tx=false&mine=true&burn=false&reward=false" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["total"], 0); + assert_eq!(value["has_more"], false); + assert_eq!(value["items"].as_array().unwrap().len(), 0); } } diff --git a/wallet/app.js b/wallet/app.js @@ -7,7 +7,15 @@ import { generateMnemonic, validateMnemonic } from "./mnemonic.js"; const app = document.querySelector("#app"); const toastElement = document.querySelector("#toast"); -const state = { store: null, wallet: null, walletMeta: null, status: null, address: "", balance: null, utxos: [], transactions: [], view: "home", timer: null }; +const TRANSACTION_PAGE_SIZE = 25; +const state = { + store: null, wallet: null, walletMeta: null, status: null, address: "", balance: null, + utxos: [], transactions: [], view: "home", timer: null, + transactionFilters: { transfer: true, mine: false, burn: false, reward: true }, + transactionPage: { offset: 0, total: 0, hasMore: true, loading: false, error: "" }, + transactionRequest: 0, + activityObserver: null, +}; const icon = (name) => { const paths = { send: '<path d="M6 18 18 6M6 6h12v12"/>', @@ -125,19 +133,108 @@ async function openStoredWallet(meta) { async function fetchWalletData() { state.status = await api("/status"); - state.address = encodeAddress(state.wallet.publicKey, state.status.chain_id); + const address = encodeAddress(state.wallet.publicKey, state.status.chain_id); + if (state.address && state.address !== address) { + state.transactions = []; + Object.assign(state.transactionPage, { offset: 0, total: 0, hasMore: true, loading: false, error: "" }); + } + state.address = address; const encoded = encodeURIComponent(state.address); + const previousTransactionCount = state.transactions.length; + const transactionLimit = Math.min(100, Math.max(TRANSACTION_PAGE_SIZE, previousTransactionCount)); + const refreshTransactions = !state.transactionPage.loading; + const transactionRequest = refreshTransactions ? ++state.transactionRequest : null; const [balance, utxos, transactions] = await Promise.all([ api(`/addresses/${encoded}/balance`), api(`/addresses/${encoded}/utxos`), - api(`/addresses/${encoded}/transactions?limit=50`), + refreshTransactions ? api(transactionPath(0, transactionLimit)) : Promise.resolve(null), ]); state.balance = balance; state.utxos = utxos.utxos || []; - state.transactions = transactions.items || []; + if (transactions && transactionRequest === state.transactionRequest) { + applyTransactionPage(transactions, true, previousTransactionCount); + } +} + +function transactionPath(offset, limit) { + const encoded = encodeURIComponent(state.address); + const params = new URLSearchParams({ + tx: String(state.transactionFilters.transfer), + mine: String(state.transactionFilters.mine), + burn: String(state.transactionFilters.burn), + reward: String(state.transactionFilters.reward), + offset: String(offset), + limit: String(limit), + }); + return `/addresses/${encoded}/transactions?${params.toString()}`; +} + +function transactionKey(item) { + return `${item.kind || ""}:${item.transaction?.signature || ""}`; +} + +function applyTransactionPage(payload, replace, preserveCount = 0) { + const items = Array.isArray(payload?.items) ? payload.items : []; + state.transactionPage.error = ""; + if (replace) { + if (preserveCount > items.length && payload?.has_more === true) { + const fresh = new Set(items.map(transactionKey)); + const retained = state.transactions.filter((item) => !fresh.has(transactionKey(item))); + state.transactions = items.concat(retained).slice(0, preserveCount); + } else { + state.transactions = items; + } + } else { + const known = new Set(state.transactions.map(transactionKey)); + state.transactions = state.transactions.concat(items.filter((item) => { + const key = transactionKey(item); + if (known.has(key)) return false; + known.add(key); + return true; + })); + } + state.transactionPage.offset = preserveCount > items.length + ? state.transactions.length + : Number(payload?.next_offset ?? (replace ? items.length : state.transactionPage.offset + items.length)); + state.transactionPage.total = Number(payload?.total ?? state.transactions.length); + state.transactionPage.hasMore = state.transactions.length < state.transactionPage.total; +} + +async function loadTransactions({ replace = false } = {}) { + if (!state.wallet || (state.transactionPage.loading && !replace)) return; + const request = ++state.transactionRequest; + if (replace) { + state.transactions = []; + Object.assign(state.transactionPage, { offset: 0, total: 0, hasMore: true }); + } else if (!state.transactionPage.hasMore) { + return; + } + state.transactionPage.loading = true; + state.transactionPage.error = ""; + if (state.view === "activity") renderApp(); + try { + const offset = replace ? 0 : state.transactionPage.offset; + const payload = await api(transactionPath(offset, TRANSACTION_PAGE_SIZE)); + if (request !== state.transactionRequest) return; + applyTransactionPage(payload, replace); + } catch (error) { + if (request === state.transactionRequest) { + state.transactionPage.error = error.message; + toast(error.message, true); + } + } finally { + if (request === state.transactionRequest) { + state.transactionPage.loading = false; + if (state.view === "activity") renderApp(); + } + } } async function openWallet() { + state.transactionRequest += 1; + state.transactionPage.loading = false; + state.activityObserver?.disconnect(); + state.activityObserver = null; app.innerHTML = `<section class="center-card"><div class="spinner"></div><p>Connecting to iuna…</p></section>`; try { await fetchWalletData(); @@ -193,9 +290,12 @@ function formatTransactionDate(item) { }).replace(",", ""); } -function activityList(limit) { - const items = state.transactions.slice(0, limit); - if (!items.length) return '<div class="empty">No transactions yet.<br>Your new wallet is ready to use.</div>'; +function activityList(limit, filtered = false) { + const items = typeof limit === "number" ? state.transactions.slice(0, limit) : state.transactions; + if (!items.length && state.transactionPage.loading) return ''; + if (!items.length) return filtered + ? '<div class="empty">No transactions match these filters.</div>' + : '<div class="empty">No transactions yet.<br>Your new wallet is ready to use.</div>'; return `<div class="activity-list">${items.map((item) => { const info = transactionInfo(item); const date = formatTransactionDate(item); @@ -219,7 +319,13 @@ function renderReceive() { } function renderActivity() { - return `${topbar()}<p class="eyebrow">Wallet</p><h1 class="view-title">Activity</h1><p class="view-copy">Confirmed and pending transactions.</p><div class="panel">${activityList(50)}</div>`; + const filters = [["transfer", "Tx"], ["mine", "Mine"], ["burn", "Burn"], ["reward", "Reward"]] + .map(([kind, label]) => `<button class="transaction-filter ${state.transactionFilters[kind] ? "active" : ""}" data-transaction-filter="${kind}" aria-pressed="${state.transactionFilters[kind]}">${label}</button>`) + .join(""); + const loader = state.transactionPage.loading ? '<div class="activity-loader"><span class="spinner"></span><span>Loading transactions…</span></div>' : ''; + const retry = state.transactionPage.error ? '<button class="activity-retry" data-action="retry-transactions">Loading failed · try again</button>' : ''; + const sentinel = state.transactionPage.hasMore && !state.transactionPage.error ? '<div id="activity-sentinel" class="activity-sentinel" aria-hidden="true"></div>' : ''; + return `${topbar()}<p class="eyebrow">Wallet</p><h1 class="view-title">Activity</h1><p class="view-copy">Confirmed and pending transactions.</p><div class="transaction-filters" aria-label="Transaction filters">${filters}</div><div class="panel">${activityList(undefined, true)}${loader}${retry}${sentinel}</div>`; } function renderSettings() { @@ -229,7 +335,18 @@ function renderSettings() { function renderApp() { const renderers = { home: renderHome, send: renderSend, receive: renderReceive, activity: renderActivity, settings: renderSettings }; + state.activityObserver?.disconnect(); + state.activityObserver = null; app.innerHTML = `${(renderers[state.view] || renderHome)()}${nav()}`; + if (state.view === "activity") { + const sentinel = document.querySelector("#activity-sentinel"); + if (sentinel) { + state.activityObserver = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) loadTransactions(); + }, { rootMargin: "240px 0px" }); + state.activityObserver.observe(sentinel); + } + } } function renderConfirmation(transaction, fee, recipientAddress, amount) { @@ -251,6 +368,16 @@ app.addEventListener("click", async (event) => { const button = event.target.closest("button"); if (!button) return; const action = button.dataset.action; + if (button.dataset.transactionFilter) { + const filter = button.dataset.transactionFilter; + state.transactionFilters[filter] = !state.transactionFilters[filter]; + await loadTransactions({ replace: true }); + return; + } + if (action === "retry-transactions") { + await loadTransactions({ replace: state.transactions.length === 0 }); + return; + } if (button.dataset.view) { if (button.dataset.view === "send" && state.walletMeta?.type === "readonly") { toast("Watch-only wallets cannot send", true); return; } state.view = button.dataset.view; renderApp(); return; diff --git a/wallet/node-theme.css b/wallet/node-theme.css @@ -24,6 +24,12 @@ button:focus-visible { outline:2px solid var(--lime); outline-offset:3px; } .activity { grid-template-columns:36px minmax(0,1fr) auto; gap:10px; padding:15px 0; border-color:var(--line); } .activity-icon { width:34px; height:34px; border-radius:9px; background:#22291a; color:var(--lime); } .activity-title { font-size:12px; }.activity-meta { font-size:10px; line-height:1.6; }.activity-amount { font-size:12px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; max-width:135px; }.activity-amount.in { color:var(--lime); } +.transaction-filters { display:flex; flex-wrap:wrap; gap:7px; margin:0 0 14px; } +.transaction-filter { appearance:none; border:1px solid var(--line); border-radius:999px; background:var(--panel); color:var(--muted); padding:8px 12px; font:700 11px/1 inherit; cursor:pointer; } +.transaction-filter.active { border-color:#d5f55f70; background:#252b20; color:var(--lime); } +.activity-loader { display:flex; align-items:center; justify-content:center; gap:10px; padding:20px 8px 8px; color:var(--muted); font-size:11px; } +.activity-retry { appearance:none; display:block; margin:16px auto 2px; border:0; background:transparent; color:var(--lime); padding:8px; font:700 11px/1 inherit; cursor:pointer; } +.activity-sentinel { height:1px; } .empty { padding:30px 8px; font-size:13px; line-height:1.9; } .bottom-nav { border-radius:13px; padding:6px; background:#15171af5; border-color:#30363c; box-shadow:0 8px 30px #0005; } .nav-item { min-height:52px; border-radius:8px; font-size:10px; font-weight:650; gap:6px; }.nav-item.active { background:#252b20; color:var(--lime); }