commit f706eefa57e6a18b9d66d3bc26c8ff88b1b6a4a2
parent 3842c1bef0dcef60536113ce512176aa6dcb7911
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Tue, 15 Sep 2026 11:15:57 +0200
feat(wallet): show transaction details
Diffstat:
2 files changed, 92 insertions(+), 6 deletions(-)
diff --git a/wallet/app.js b/wallet/app.js
@@ -1,6 +1,6 @@
import {
API_BASE, LEGACY_STORAGE_KEY, STORAGE_KEY, api, buildSignedTransfer, decodeAddress,
- decryptWallet, encodeAddress, encryptWallet, formatIuna, normalizeWalletStore,
+ decryptWallet, encodeAddress, encryptWallet, formatIuna, hexToBytes, normalizeWalletStore,
parseFeeRate, parseIuna, removeWallet, upsertWallet, walletFromSeed, walletId,
} from "./wallet-core.js";
import { generateMnemonic, validateMnemonic } from "./mnemonic.js";
@@ -16,6 +16,8 @@ const state = {
transactionPage: { offset: 0, total: 0, hasMore: true, loading: false, error: "" },
transactionRequest: 0,
activityObserver: null,
+ selectedTransaction: null,
+ transactionReturnView: "home",
};
const icon = (name) => {
const paths = {
@@ -177,6 +179,15 @@ function transactionKey(item) {
return `${item.kind || ""}:${item.transaction?.signature || ""}`;
}
+function networkAddress(publicKey) {
+ if (!publicKey) return "Unknown";
+ try {
+ return encodeAddress(hexToBytes(publicKey), state.status?.chain_id);
+ } catch {
+ return String(publicKey);
+ }
+}
+
function applyTransactionPage(payload, replace, preserveCount = 0) {
const items = Array.isArray(payload?.items) ? payload.items : [];
state.transactionPage.error = "";
@@ -303,10 +314,59 @@ function activityList(limit, filtered = false, source = state.transactions) {
return `<div class="activity-list">${items.map((item) => {
const info = transactionInfo(item);
const date = formatTransactionDate(item);
- return `<div class="activity"><div class="activity-icon">${info.symbol}</div><div><div class="activity-title">${info.title}</div><div class="activity-meta">${escapeHtml(date)} · ${escapeHtml(item.status)}</div></div><div class="activity-amount ${info.incoming ? "in" : ""}">${info.incoming ? "+" : "−"}${formatIuna(info.amount, 4)} IUNA</div></div>`;
+ return `<button class="activity" data-transaction-key="${escapeHtml(transactionKey(item))}" type="button" aria-label="View ${escapeHtml(info.title.toLowerCase())} transaction details"><span class="activity-icon">${info.symbol}</span><span><span class="activity-title">${info.title}</span><span class="activity-meta">${escapeHtml(date)} · ${escapeHtml(item.status)}</span></span><span class="activity-amount ${info.incoming ? "in" : ""}">${info.incoming ? "+" : "−"}${formatIuna(info.amount, 4)} IUNA</span><span class="activity-chevron" aria-hidden="true">›</span></button>`;
}).join("")}</div>`;
}
+function addressDetail(label, address, amount = null) {
+ const formattedAmount = amount === null ? "" : `<small>${formatIuna(amount, 6)} IUNA</small>`;
+ return `<div class="address-detail"><span>${escapeHtml(label)}</span><div><code>${escapeHtml(address)}</code>${formattedAmount}</div></div>`;
+}
+
+function transactionFlow(item) {
+ const tx = item.transaction || {};
+ if (item.kind === "reward") {
+ return {
+ from: [{ label: "From", address: "Network reward", amount: null }],
+ to: (tx.outputs || []).map((output) => ({ label: "To", address: networkAddress(output.address), amount: output.amount })),
+ };
+ }
+ if (tx.kind === "mine") {
+ return {
+ from: [{ label: "From", address: "Mining protocol", amount: null }],
+ to: [{ label: "To", address: networkAddress(tx.recipient), amount: 1_000_000 }],
+ };
+ }
+ const owners = [...new Set((tx.inputs || []).map((input) => input.owner).filter(Boolean))];
+ const outputs = tx.kind === "burn" ? (tx.change || []) : (tx.outputs || []);
+ const from = owners.map((owner) => ({ label: "From", address: networkAddress(owner), amount: null }));
+ const to = outputs.map((output) => ({
+ label: owners.includes(output.address) ? "Change to" : "To",
+ address: networkAddress(output.address),
+ amount: output.amount,
+ }));
+ if (tx.kind === "burn") to.unshift({ label: "To", address: "Burned permanently", amount: tx.amount || 0 });
+ return {
+ from: from.length ? from : [{ label: "From", address: "Unknown", amount: null }],
+ to: to.length ? to : [{ label: "To", address: "Unknown", amount: null }],
+ };
+}
+
+function renderTransaction() {
+ const item = state.selectedTransaction;
+ if (!item) {
+ state.view = state.transactionReturnView;
+ return state.view === "activity" ? renderActivity() : renderHome();
+ }
+ const tx = item.transaction || {};
+ const info = transactionInfo(item);
+ const flow = transactionFlow(item);
+ const transactionId = tx.signature || "Unknown";
+ const fee = Number(tx.fee || 0);
+ const addresses = [...flow.from, ...flow.to].map((entry) => addressDetail(entry.label, entry.address, entry.amount)).join("");
+ return `${topbar()}<button class="back" data-action="close-transaction">← Back</button><p class="eyebrow">Transaction details</p><h1 class="view-title">${escapeHtml(info.title)}</h1><p class="transaction-total ${info.incoming ? "in" : ""}">${info.incoming ? "+" : "−"}${formatIuna(info.amount, 6)} IUNA</p><div class="panel transaction-detail">${addresses}<div class="detail-line"><span>Status</span><strong class="status-value">${escapeHtml(item.status || "Unknown")}</strong></div><div class="detail-line"><span>Date</span><strong>${escapeHtml(formatTransactionDate(item))}</strong></div>${item.block_height === null || item.block_height === undefined ? "" : `<div class="detail-line"><span>Block</span><strong>${escapeHtml(item.block_height)}</strong></div>`}${fee ? `<div class="detail-line"><span>Network fee</span><strong>${formatIuna(fee, 6)} IUNA</strong></div>` : ""}<div class="transaction-id"><span>Transaction ID</span><code>${escapeHtml(transactionId)}</code><button class="icon-button" data-action="copy-transaction-id" aria-label="Copy transaction ID">${icon("copy")}</button></div></div>`;
+}
+
function renderHome() {
const readonly = state.walletMeta?.type === "readonly";
return `${topbar()}<section>${readonly ? '<div class="mode-badge">Watch-only · signing disabled</div>' : ""}<p class="eyebrow">Available balance</p><h1 class="balance">${formatIuna(state.balance?.spendable, 6)} <span>IUNA</span></h1><p class="subbalance">${formatIuna(state.balance?.confirmed, 6)} confirmed · block ${escapeHtml(state.balance?.height)}</p><div class="actions"><button class="button" data-view="send" ${readonly ? "disabled" : ""}>${icon("send")} Send</button><button class="button secondary" data-view="receive">${icon("receive")} Receive</button></div><div class="section-head"><h2>Recent activity</h2><button data-view="activity">View all</button></div><div class="panel">${activityList(5, false, state.recentTransactions)}</div></section>`;
@@ -338,7 +398,7 @@ function renderSettings() {
}
function renderApp() {
- const renderers = { home: renderHome, send: renderSend, receive: renderReceive, activity: renderActivity, settings: renderSettings };
+ const renderers = { home: renderHome, send: renderSend, receive: renderReceive, activity: renderActivity, transaction: renderTransaction, settings: renderSettings };
state.activityObserver?.disconnect();
state.activityObserver = null;
app.innerHTML = `${(renderers[state.view] || renderHome)()}${nav()}`;
@@ -372,6 +432,28 @@ app.addEventListener("click", async (event) => {
const button = event.target.closest("button");
if (!button) return;
const action = button.dataset.action;
+ if (button.dataset.transactionKey) {
+ const key = button.dataset.transactionKey;
+ const item = [...state.recentTransactions, ...state.transactions].find((transaction) => transactionKey(transaction) === key);
+ if (item) {
+ state.transactionReturnView = state.view === "activity" ? "activity" : "home";
+ state.selectedTransaction = item;
+ state.view = "transaction";
+ renderApp();
+ }
+ return;
+ }
+ if (action === "close-transaction") {
+ state.selectedTransaction = null;
+ state.view = state.transactionReturnView;
+ renderApp();
+ return;
+ }
+ if (action === "copy-transaction-id") {
+ await navigator.clipboard.writeText(state.selectedTransaction?.transaction?.signature || "");
+ toast("Transaction ID copied");
+ return;
+ }
if (button.dataset.transactionFilter) {
const filter = button.dataset.transactionFilter;
state.transactionFilters[filter] = !state.transactionFilters[filter];
diff --git a/wallet/node-theme.css b/wallet/node-theme.css
@@ -21,9 +21,11 @@ body { background:var(--bg); }.ambient { display:none; }
button:focus-visible { outline:2px solid var(--lime); outline-offset:3px; }
.panel { padding:18px; border-radius:12px; background:var(--panel); box-shadow:none; backdrop-filter:none; }
.section-head h2 { font-size:15px; font-weight:700; }.section-head button { font-size:12px; padding:8px 0 8px 12px; }
-.activity { grid-template-columns:36px minmax(0,1fr) auto; gap:10px; padding:15px 0; border-color:var(--line); }
+.activity { appearance:none; width:100%; grid-template-columns:36px minmax(0,1fr) auto 12px; gap:10px; padding:15px 0; border:0; border-bottom:1px solid var(--line); border-radius:0; background:transparent; color:inherit; font:inherit; text-align:left; cursor:pointer; }
+.activity:last-child { border-bottom:0; }.activity:hover .activity-title,.activity:focus-visible .activity-title { color:var(--lime); }.activity:focus-visible { outline:2px solid var(--lime); outline-offset:2px; border-radius:5px; }
.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); }
+.activity-title { display:block; font-size:12px; }.activity-meta { display:block; 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); }
+.activity-chevron { color:var(--muted); font-size:19px; line-height:1; }
.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); }
@@ -38,6 +40,8 @@ input,textarea { font-family:inherit; font-size:16px; border-radius:8px; padding
input::placeholder,textarea::placeholder { color:#68737c; }input:focus,textarea:focus { border-color:var(--lime); box-shadow:0 0 0 3px #d5f55f12; }
textarea { line-height:1.8; }.amount-wrap input { font-family:inherit; font-size:28px; font-weight:650; }.amount-wrap span { font-size:12px; top:22px; }
.fee-line,.detail-line { font-size:12px; line-height:1.6; padding:12px 0; }.detail-line + .detail-line { border-top:1px solid var(--line); }.detail-line strong { overflow-wrap:anywhere; min-width:0; }
+.transaction-total { margin:0 0 20px; color:var(--ink); font-size:24px; font-weight:750; font-variant-numeric:tabular-nums; }.transaction-total.in { color:var(--lime); }
+.transaction-detail { display:grid; }.address-detail { display:grid; gap:8px; padding:14px 0; border-bottom:1px solid var(--line); }.address-detail:first-child { padding-top:0; }.address-detail > span,.transaction-id > span { color:var(--muted); font-size:11px; }.address-detail > div { display:grid; gap:6px; }.address-detail code,.transaction-id code { color:var(--ink); font-size:11px; line-height:1.7; overflow-wrap:anywhere; word-break:break-word; }.address-detail small { color:var(--lime); font-size:11px; font-weight:700; }.status-value { text-transform:capitalize; }.transaction-id { display:grid; grid-template-columns:minmax(0,1fr) 40px; gap:8px 12px; align-items:center; padding-top:14px; border-top:1px solid var(--line); }.transaction-id > span { grid-column:1 / -1; }.transaction-id .icon-button { grid-column:2; grid-row:2; }.transaction-id code { grid-column:1; grid-row:2; }
.address-box { align-items:flex-start; border-radius:9px; padding:16px; background:#101215; border-color:var(--line); }.address-box code { white-space:normal; overflow:visible; overflow-wrap:anywhere; line-height:1.9; color:var(--ink); }
.receive-emblem { display:grid; place-items:center; margin:12px auto 28px; width:88px; height:88px; border:1px solid #d5f55f30; border-radius:24px; background:#22291a; color:var(--lime); }.receive-emblem .ui-icon { width:36px; height:36px; }
.setting { padding:18px 0; }.setting:first-child { padding-top:0; }.setting:last-child { padding-bottom:0; }.setting h3 { font-size:12px; }.setting p { font-size:12px; line-height:1.8; overflow-wrap:anywhere; }
@@ -49,5 +53,5 @@ textarea { line-height:1.8; }.amount-wrap input { font-family:inherit; font-size
.toast { border-radius:9px; background:var(--ink); color:var(--bg); line-height:1.6; }.toast.error { background:#ffb1a8; }
.spinner { border-color:#d5f55f30; border-top-color:var(--lime); margin:auto; }.button .spinner { border-color:#15171a30; border-top-color:#15171a; margin:0; }
@media(min-width:700px) { body { padding:32px; background:#111316; }.shell { min-height:calc(100dvh - 64px); border-radius:18px; border-color:var(--line); background:var(--bg); padding:28px 26px 116px; }.onboarding { min-height:calc(100dvh - 208px); } }
-@media(max-width:360px) { .shell { padding-inline:14px; }.brand { font-size:16px; }.network { font-size:10px; }.activity { grid-template-columns:28px minmax(0,1fr) auto; gap:7px; }.activity-amount { font-size:11px; max-width:100px; }.activity-icon { width:28px; height:28px; }.panel { padding:13px; } }
+@media(max-width:360px) { .shell { padding-inline:14px; }.brand { font-size:16px; }.network { font-size:10px; }.activity { grid-template-columns:28px minmax(0,1fr) auto 10px; gap:7px; }.activity-amount { font-size:11px; max-width:100px; }.activity-icon { width:28px; height:28px; }.panel { padding:13px; } }
@media(prefers-reduced-motion:reduce) { *,*::before,*::after { animation:none!important; transition:none!important; } }