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