diff --git a/client/src/analytics/DonorTab/WalletActivity.jsx b/client/src/analytics/DonorTab/WalletActivity.jsx
new file mode 100644
index 0000000..c7080e9
--- /dev/null
+++ b/client/src/analytics/DonorTab/WalletActivity.jsx
@@ -0,0 +1,288 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Tooltip2 } from '@blueprintjs/popover2';
+import { explorerTxUrl, explorerAddressUrl } from 'explorerLinks';
+import { fetch_wallet_tx_history } from 'analytics/walletTxFetch';
+import { counterpartyDisplay, WINDOW_DAYS } from 'analytics/walletTxHistory';
+import { groupActivityRows, dailyRewards, activeCategories, stripWorthShowing } from 'analytics/walletActivityView';
+import { maskNodeAddress, usePrivacy } from 'analytics/privacy';
+
+/*
+ * The wallet's recent on-chain activity (issues #299, #358).
+ *
+ * REBUILT IN #358 against measured data. Across 233 real transactions from
+ * three donor wallets in the live 7-day window:
+ *
+ * node rewards 219 94.0%
+ * transfers out 9 3.9%
+ * transfers in 4 1.7%
+ * from Foundation 1 0.4%
+ * to Foundation 0 0%
+ * exchange, either 0 0%
+ *
+ * So the old panel reserved five of its seven category lines for 2.1% of the
+ * data, three of which never fired at all, and rendered the 94% as 219
+ * identical unclickable rows. Four things follow:
+ *
+ * - consecutive rewards collapse into one expandable entry, so the 6% worth
+ * reading is visible rather than buried
+ * - a per-day reward strip, because a flat list cannot show a node that
+ * stopped earning
+ * - a category earns its line by having something in it
+ * - every row reaches the explorer
+ */
+
+function fmtFlux(n) {
+ if (n == null) return '—';
+ return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+}
+
+function txTime(unixSeconds) {
+ if (!unixSeconds) return '—';
+ return new Date(unixSeconds * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
+}
+
+function dayLabel(unixSeconds) {
+ return new Date(unixSeconds * 1000).toLocaleDateString(undefined, { weekday: 'short' });
+}
+
+/** A transaction id that opens the explorer, or plain text when it cannot. */
+function TxLink({ txid, children }) {
+ const href = explorerTxUrl(txid);
+ if (!href) return {children};
+ return (
+
+ {children}
+
+ );
+}
+
+/*
+ * Per-day reward totals. Rewards are 94% of the activity and the list cannot
+ * answer the question an operator has about them -- am I earning steadily? --
+ * because 219 identical rows all look the same. A missing bar is a day that
+ * paid nothing, which is the thing worth seeing.
+ */
+function RewardStrip({ bars }) {
+ if (!stripWorthShowing(bars)) return null;
+ const peak = Math.max(...bars.map((b) => b.total), 0);
+
+ return (
+
+ {bars.map((b) => (
+
+
+ {/*
+ A day the scan never reached is NOT a day with no rewards. On a
+ busy wallet the page budget stops short of the full window, and
+ drawing those days as empty bars would say the nodes stopped
+ earning -- the confident wrong statement this panel exists to get
+ rid of. Hatched and hollow, so it reads as "no data" rather than
+ "no rewards".
+ */}
+
+
+ {dayLabel(b.dayStart)}
+
+
+
+ ))}
+
+ );
+}
+
+/** A collapsed run of consecutive node rewards, expandable to the rows behind it. */
+// No privacy prop: a reward comes from the coinbase and has no counterparty
+// address to mask.
+function RewardRun({ entry }) {
+ const [open, setOpen] = useState(false);
+
+ return (
+ <>
+
+
+ {open &&
+ entry.rows.map((row) => (
+
+
+
+ {txTime(row.time)}
+
+
+ {/*
+ The counterparty links to its explorer address page, except when it
+ is unknown -- there is no address to open, and "Unknown" is a real
+ answer rather than a missing one.
+ */}
+ {addressHref && !privacy ? (
+
+ {shown}
+
+ ) : (
+ {shown}
+ )}
+
+
+ {row.direction === 'in' ? '+' : '−'}{fmtFlux(row.amount)}
+
+
+
+ {/*
+ #358: the scan is capped, and a capped window used to be reported as a
+ full one. Measured on a real 120-node wallet, its 7-day window holds 803
+ transactions across 82 pages -- far more than one panel can afford to
+ fetch -- so when the budget runs out the panel states the span it
+ actually covered instead of claiming seven days.
+ */}
+ {truncated && (
+
+ Busy wallet — showing everything since {txTime(coveredFrom)}, not the full {WINDOW_DAYS} days.
+ The totals below cover that period.
+
+ )}
+
+
+
+
+
+
+
+
+
= 0 ? ' dt-act-net--up' : ' dt-act-net--down'}`}>
+ net {net >= 0 ? '+' : '−'}{fmtFlux(Math.abs(net))} FLUX
+ {truncated ? ' over the period shown' : ` over ${WINDOW_DAYS} days`}
+
- Counterparties are named where the address is known (exchanges, Flux
- Foundation). Flux app payments go to a Foundation address, so they appear
- under Flux Foundation rather than as a separate deployment category.
-
-
- );
-}
-
-// ── Payout card ──────────────────────────────────────────────────────────
-
-
function fmtSigned(n, digits = 2) {
if (n == null || !Number.isFinite(n)) return '—';
const sign = n > 0 ? '+' : n < 0 ? '−' : '';
@@ -273,17 +162,6 @@ function RewardImpactPanel({ nodes, gstore }) {
* "NEXT PAYOUT / 57 mins", is deleted rather than restyled: it was duplication,
* not emphasis.
*/
-/*
- * Privacy mode, for the three places this tab renders a node address (#343).
- *
- * A hook rather than a prop threaded through four component levels. The
- * context can be absent in tests that mount a panel on its own, so it
- * defaults to off rather than throwing.
- */
-function usePrivacy() {
- return useContext(LayoutContext)?.enablePrivacyMode || false;
-}
-
function PayoutCard({ nextNode, lastPaidNode, currentBlock }) {
const privacy = usePrivacy();
return (
diff --git a/client/src/analytics/DonorTab/index.scss b/client/src/analytics/DonorTab/index.scss
index c4f75ca..e4f52b9 100644
--- a/client/src/analytics/DonorTab/index.scss
+++ b/client/src/analytics/DonorTab/index.scss
@@ -963,3 +963,223 @@
display: block;
width: 100%;
}
+
+/* ── #358: Recent activity rebuilt ──────────────────────────────────────────
+ *
+ * Measured: 94% of rows were node rewards and five of seven category lines
+ * were permanently zero. Rewards now collapse into runs, a day strip shows
+ * whether they are still arriving, and a category earns its line.
+ */
+
+.dt-act-truncated {
+ margin: 8px 0 4px;
+ padding: 6px 10px;
+ border-left: 2px solid #d9a03a;
+ background: color-mix(in srgb, #d9a03a 8%, transparent);
+ border-radius: 0 4px 4px 0;
+ font-size: 0.72rem;
+ color: var(--text-secondary);
+}
+
+/* Per-day node rewards. A short or missing bar is a day that paid little or
+ nothing, which 219 identical list rows cannot show. */
+.dt-act-strip {
+ display: flex;
+ align-items: flex-end;
+ gap: 4px;
+ height: 62px;
+ margin: 10px 0 14px;
+}
+
+.dt-act-strip-col {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-end;
+ height: 100%;
+ cursor: help;
+}
+
+.dt-act-strip-bar {
+ width: 100%;
+ min-height: 2px;
+ border-radius: 2px 2px 0 0;
+ background: var(--accent-green, #0ea271);
+}
+
+/* Visible as an absence rather than nothing at all — the gap is the signal. */
+.dt-act-strip-bar--empty {
+ background: var(--border-secondary);
+}
+
+/*
+ * A day outside the scanned period. Hatched and hollow so it reads as "no
+ * data", never as "no rewards" — a short flat bar would say the nodes stopped
+ * earning on a day nobody looked at.
+ */
+.dt-act-strip-bar--unscanned {
+ background: repeating-linear-gradient(
+ 45deg,
+ transparent,
+ transparent 3px,
+ var(--border-secondary) 3px,
+ var(--border-secondary) 5px
+ );
+ border: 1px dashed var(--border-secondary);
+ border-radius: 2px;
+}
+
+.dt-act-strip-label--unscanned {
+ opacity: 0.45;
+}
+
+.dt-act-strip-label {
+ margin-top: 4px;
+ font-size: 0.6rem;
+ color: var(--text-tertiary);
+}
+
+.dt-act-summary {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+}
+
+.dt-act-col {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+}
+
+.dt-act-col-title {
+ font-size: 0.62rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-tertiary);
+ margin-bottom: 2px;
+}
+
+.dt-act-line {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ font-size: 0.78rem;
+ color: var(--text-secondary);
+
+ strong {
+ font-variant-numeric: tabular-nums;
+ color: var(--text-primary);
+ font-weight: 600;
+ }
+}
+
+.dt-act-line--total {
+ margin-top: 4px;
+ padding-top: 4px;
+ border-top: 1px solid var(--border-secondary);
+}
+
+.dt-act-net {
+ margin: 12px 0 6px;
+ font-size: 0.8rem;
+ font-variant-numeric: tabular-nums;
+ color: var(--text-secondary);
+}
+
+.dt-act-net--up strong,
+.dt-act-net--up { color: var(--accent-green, #0ea271); }
+.dt-act-net--down { color: var(--accent-red, #dc3a3a); }
+
+.dt-act-list {
+ max-height: 320px;
+ overflow-y: auto;
+ border-top: 1px solid var(--border-secondary);
+}
+
+/*
+ * One grid for every row shape -- run header, nested reward, single
+ * transaction -- so the four columns line up down the list however a row is
+ * built. A per-shape layout drifts the moment one of them changes.
+ */
+.dt-act-row {
+ display: grid;
+ grid-template-columns: 14px 64px minmax(0, 1fr) 100px;
+ gap: 8px;
+ align-items: center;
+ width: 100%;
+ padding: 5px 2px;
+ border: 0;
+ background: none;
+ border-bottom: 1px solid var(--border-secondary);
+ font-size: 0.76rem;
+ text-align: left;
+ color: inherit;
+
+ &:last-child { border-bottom: none; }
+}
+
+.dt-act-row--run {
+ cursor: pointer;
+
+ &:hover { background: color-mix(in srgb, var(--text-primary) 5%, transparent); }
+}
+
+/* Indented and dimmer, so an expanded run reads as detail under its header. */
+.dt-act-row--nested {
+ background: color-mix(in srgb, var(--text-primary) 3%, transparent);
+
+ .dt-act-date,
+ .dt-act-party { color: var(--text-tertiary); }
+}
+
+.dt-act-caret {
+ color: var(--text-tertiary);
+ font-size: 0.7rem;
+}
+
+.dt-act-date {
+ font-variant-numeric: tabular-nums;
+ color: var(--text-tertiary);
+}
+
+.dt-act-party {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--text-secondary);
+}
+
+.dt-act-party--exchange { color: #4d9bf5; }
+.dt-act-party--foundation { color: #a78bfa; }
+/* Italic because it is an absence, not a name — same rule the resource cells follow. */
+.dt-act-party--unknown { font-style: italic; color: var(--text-tertiary); }
+
+.dt-act-amount {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+}
+
+.dt-act-amount--in { color: var(--accent-green, #0ea271); }
+.dt-act-amount--out { color: var(--accent-red, #dc3a3a); }
+
+.dt-act-link {
+ color: inherit;
+ text-decoration: none;
+
+ &:hover {
+ color: var(--brand-primary, #4d9bf5);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+}
+
+.dt-act-plain { color: inherit; }
+
+@media (max-width: 640px) {
+ .dt-act-summary { grid-template-columns: 1fr; }
+}
diff --git a/client/src/analytics/privacy.js b/client/src/analytics/privacy.js
index f0bc94e..1cda76d 100644
--- a/client/src/analytics/privacy.js
+++ b/client/src/analytics/privacy.js
@@ -1,3 +1,5 @@
+import { useContext } from 'react';
+import { LayoutContext } from 'contexts/LayoutContext';
import { hide_sensitive_number } from 'utils';
/*
@@ -26,3 +28,16 @@ export function maskNodeAddress(address, enabled) {
if (!address) return '';
return enabled ? hide_sensitive_number(address) : address;
}
+
+/**
+ * Whether privacy mode is on.
+ *
+ * A hook rather than a prop threaded through four component levels. Lives here
+ * with the masking it feeds, now that three components need it (#343, #358).
+ *
+ * Defaults to off when the context is absent, so a panel mounted on its own in
+ * a test does not throw.
+ */
+export function usePrivacy() {
+ return useContext(LayoutContext)?.enablePrivacyMode || false;
+}
diff --git a/client/src/analytics/walletActivityView.js b/client/src/analytics/walletActivityView.js
new file mode 100644
index 0000000..8bb3812
--- /dev/null
+++ b/client/src/analytics/walletActivityView.js
@@ -0,0 +1,171 @@
+/*
+ * How the Donor tab's Recent activity panel reads (issue #358).
+ *
+ * Measured before it was designed: across 233 real transactions from three
+ * donor wallets in the live 7-day window,
+ *
+ * node rewards 219 94.0%
+ * transfers out 9 3.9%
+ * transfers in 4 1.7%
+ * from Foundation 1 0.4%
+ * to Foundation 0 0%
+ * exchange, either 0 0%
+ *
+ * So the list was a wall of identical reward rows with the 6% worth looking at
+ * buried inside it, and five of the seven category lines on screen were
+ * permanently zero. Everything here follows from those two facts.
+ *
+ * Pure and separate from the component: what the panel SAYS is the part worth
+ * pinning down, and none of it needs a DOM to decide.
+ */
+
+const SECONDS_PER_DAY = 86400;
+
+/**
+ * Consecutive node rewards collapse into one entry; anything else stands alone.
+ *
+ * @returns Array of
+ * { kind: 'rewards', key, rows, count, total, newest, oldest }
+ * { kind: 'row', key, row }
+ *
+ * Runs rather than one global reward bucket: a transfer between two runs
+ * separates them, and merging across it would put the reward total somewhere
+ * the reader cannot place in time. Rows arrive newest-first and stay that way.
+ */
+export function groupActivityRows(rows) {
+ if (!Array.isArray(rows)) return [];
+
+ const out = [];
+ let run = null;
+
+ const flush = () => {
+ if (run) out.push(run);
+ run = null;
+ };
+
+ for (const row of rows) {
+ if (row?.type !== 'reward') {
+ flush();
+ out.push({ kind: 'row', key: `row-${row?.txid}`, row });
+ continue;
+ }
+
+ if (!run) {
+ run = {
+ kind: 'rewards',
+ // The first txid in the run: unique, and stable across re-renders in a
+ // way an index is not.
+ key: `rewards-${row.txid}`,
+ rows: [],
+ count: 0,
+ total: 0,
+ newest: row.time,
+ oldest: row.time,
+ };
+ }
+
+ run.rows.push(row);
+ run.count += 1;
+ run.total += row.amount || 0;
+ // Newest-first input, so each successive row is the older end.
+ run.oldest = row.time;
+ }
+
+ flush();
+ return out;
+}
+
+/**
+ * Per-day reward totals across the window, oldest first.
+ *
+ * Rewards are 94% of the activity and a flat list cannot answer the question an
+ * operator actually has about them: am I earning steadily? A gap in the bars is
+ * a node that stopped paying, which the list makes invisible.
+ *
+ * A day with no rewards is kept as a zero rather than omitted -- omitting it
+ * would close the gap and hide exactly the outage worth seeing.
+ */
+export function dailyRewards(rows, nowSec, windowDays, coveredFrom) {
+ const now = nowSec || Math.floor(Date.now() / 1000);
+ const days = Math.max(1, windowDays || 7);
+ const from = coveredFrom || 0;
+
+ const bars = [];
+ for (let i = days - 1; i >= 0; i -= 1) {
+ const dayStart = now - (i + 1) * SECONDS_PER_DAY;
+ /*
+ * A DAY THE SCAN NEVER REACHED IS NOT A DAY WITH NO REWARDS.
+ *
+ * On a busy wallet the page budget stops short of the full window (see
+ * walletTxFetch), and the days before that point have no data rather than
+ * no rewards. Rendering them as empty bars says the nodes stopped earning,
+ * which is a confident wrong statement -- exactly what this issue exists to
+ * remove. The caller draws these differently.
+ */
+ bars.push({ dayStart, total: 0, count: 0, covered: dayStart >= from });
+ }
+
+ for (const row of Array.isArray(rows) ? rows : []) {
+ if (row?.type !== 'reward' || !row.time) continue;
+ const age = now - row.time;
+ const index = days - 1 - Math.floor(age / SECONDS_PER_DAY);
+ if (index < 0 || index >= days) continue;
+ bars[index].total += row.amount || 0;
+ bars[index].count += 1;
+ }
+
+ return bars;
+}
+
+/**
+ * Whether the reward strip says anything worth drawing.
+ *
+ * Two ways it does not. A window where no day was fully scanned renders as
+ * seven hatched columns carrying no information -- seen live on an 8-node
+ * wallet whose second page came back "all hosts unavailable", which truncates
+ * the scan to well under a day. And a window where every scanned day earned
+ * nothing is already stated by the totals.
+ */
+export function stripWorthShowing(bars) {
+ if (!Array.isArray(bars)) return false;
+ return bars.some((b) => b.covered && b.total > 0);
+}
+
+/*
+ * NO FOUNDATION ROW. Measured at one receipt and zero sends across 233
+ * transactions, and per #270 a payment to a Foundation address cannot be told
+ * apart from an app deployment anyway. Foundation amounts fold into transfers,
+ * and the individual row still carries its "Flux Foundation" label -- the
+ * counterparty is worth naming, a permanently-zero total line is not.
+ *
+ * An App deployments row arrives here when #270's payment memo does.
+ */
+const RECEIVED_CATEGORIES = [
+ { key: 'rewards', label: 'Node rewards', always: true },
+ { key: 'exchange', label: 'From exchanges' },
+ { key: 'transfers', label: 'Transfers in' },
+];
+
+const SENT_CATEGORIES = [
+ { key: 'exchange', label: 'To exchanges' },
+ { key: 'transfers', label: 'Transfers out' },
+];
+
+/**
+ * The category lines worth rendering for one side.
+ *
+ * A category earns its line by having something in it. Seven fixed lines, five
+ * of them permanently 0.00, is what made the panel read as empty.
+ *
+ * Node rewards is the exception and stays on the received side at zero: it is
+ * the point of the panel, and a donor earning nothing this week needs that
+ * stated rather than inferred from an absent row.
+ */
+export function activeCategories(side, direction) {
+ const defs = direction === 'out' ? SENT_CATEGORIES : RECEIVED_CATEGORIES;
+ const s = side || {};
+
+ return defs
+ .filter((d) => d.always || (Number(s[d.key]) || 0) > 0)
+ .map((d) => ({ key: d.key, label: d.label, amount: Number(s[d.key]) || 0 }));
+}
diff --git a/client/src/analytics/walletActivityView.test.js b/client/src/analytics/walletActivityView.test.js
new file mode 100644
index 0000000..517e1d1
--- /dev/null
+++ b/client/src/analytics/walletActivityView.test.js
@@ -0,0 +1,229 @@
+import { groupActivityRows, dailyRewards, activeCategories, stripWorthShowing } from './walletActivityView';
+
+/*
+ * Issue #358. How the Recent activity panel should read, measured before it
+ * was designed: across 233 real transactions from three donor wallets in the
+ * live 7-day window,
+ *
+ * node rewards 219 94.0%
+ * transfers out 9 3.9%
+ * transfers in 4 1.7%
+ * from Foundation 1 0.4%
+ * to Foundation 0 0%
+ * exchange, either 0 0%
+ *
+ * So the list was a wall of identical reward rows with the 6% worth looking at
+ * buried inside it, and five of the seven category lines on screen were
+ * permanently zero.
+ */
+
+const DAY = 86400;
+const NOW = 1_789_000_000;
+
+function reward(id, ageDays, amount = 0.75) {
+ return { txid: `r${id}`, time: NOW - Math.round(ageDays * DAY), direction: 'in', type: 'reward', amount, height: 2_900_000 + id };
+}
+function transfer(id, ageDays, direction = 'out', amount = 100) {
+ return {
+ txid: `t${id}`, time: NOW - Math.round(ageDays * DAY), direction, type: 'transfer', amount,
+ counterparty: 't1Somebody', counterpartyLabel: null, counterpartyKind: null, height: 2_900_100 + id,
+ };
+}
+
+describe('groupActivityRows', () => {
+ it('collapses a run of consecutive rewards into one entry', () => {
+ const rows = [reward(1, 0.1), reward(2, 0.2), reward(3, 0.3)];
+
+ const out = groupActivityRows(rows);
+
+ expect(out).toHaveLength(1);
+ expect(out[0].kind).toBe('rewards');
+ expect(out[0].count).toBe(3);
+ expect(out[0].total).toBeCloseTo(2.25, 6);
+ });
+
+ /*
+ * The point of the whole change. The 6% that is not a reward must stay
+ * visible as its own line rather than being buried among 219 identical ones.
+ */
+ it('keeps a non-reward as its own entry, between the runs it separates', () => {
+ const rows = [reward(1, 0.1), transfer(9, 0.2), reward(2, 0.3), reward(3, 0.4)];
+
+ const out = groupActivityRows(rows);
+
+ expect(out.map((e) => e.kind)).toEqual(['rewards', 'row', 'rewards']);
+ expect(out[0].count).toBe(1);
+ expect(out[1].row.txid).toBe('t9');
+ expect(out[2].count).toBe(2);
+ });
+
+ it('carries the span a run covers, so it can be labelled', () => {
+ const rows = [reward(1, 0.5), reward(2, 2), reward(3, 3)];
+
+ const [run] = groupActivityRows(rows);
+
+ // Rows arrive newest-first, so the run runs from its last to its first.
+ expect(run.newest).toBe(rows[0].time);
+ expect(run.oldest).toBe(rows[2].time);
+ });
+
+ it('keeps the underlying rows so a run can be expanded', () => {
+ const rows = [reward(1, 0.1), reward(2, 0.2)];
+
+ expect(groupActivityRows(rows)[0].rows).toHaveLength(2);
+ });
+
+ it('gives every entry a stable unique key', () => {
+ const out = groupActivityRows([reward(1, 0.1), transfer(9, 0.2), reward(2, 0.3)]);
+
+ expect(new Set(out.map((e) => e.key)).size).toBe(out.length);
+ });
+
+ it('returns [] for junk rather than throwing', () => {
+ expect(groupActivityRows(null)).toEqual([]);
+ expect(groupActivityRows([])).toEqual([]);
+ });
+});
+
+/*
+ * Rewards are 94% of the activity, and a flat list cannot answer the one
+ * question an operator actually has about them: am I earning steadily? A gap
+ * in the bars is a node that stopped paying, which is currently invisible.
+ */
+describe('dailyRewards', () => {
+ it('buckets rewards into one entry per day of the window, oldest first', () => {
+ const bars = dailyRewards([reward(1, 0.5), reward(2, 2.5)], NOW, 7);
+
+ expect(bars).toHaveLength(7);
+ expect(bars[0].dayStart).toBeLessThan(bars[6].dayStart);
+ });
+
+ it('sums the rewards that fall in each day', () => {
+ const bars = dailyRewards([reward(1, 0.5, 1), reward(2, 0.6, 2), reward(3, 2.5, 5)], NOW, 7);
+ const byTotal = bars.filter((b) => b.total > 0).map((b) => b.total);
+
+ expect(byTotal.reduce((a, b) => a + b, 0)).toBeCloseTo(8, 6);
+ });
+
+ it('renders a day with no rewards as zero rather than omitting it', () => {
+ // An omitted day would close the gap and hide the outage it represents.
+ const bars = dailyRewards([reward(1, 0.5)], NOW, 7);
+
+ expect(bars).toHaveLength(7);
+ expect(bars.filter((b) => b.total === 0).length).toBe(6);
+ });
+
+ it('ignores anything that is not a reward', () => {
+ const bars = dailyRewards([transfer(1, 0.5, 'in', 500)], NOW, 7);
+
+ expect(bars.every((b) => b.total === 0)).toBe(true);
+ });
+
+ it('handles junk without throwing', () => {
+ expect(dailyRewards(null, NOW, 7)).toHaveLength(7);
+ });
+});
+
+/*
+ * The fix for "bland". Seven fixed lines, five of them permanently 0.00, is
+ * what makes the panel read as empty -- so a category earns its line by having
+ * something in it.
+ */
+describe('activeCategories', () => {
+ const side = (over) => ({ rewards: 0, exchange: 0, transfers: 0, total: 0, count: 0, ...over });
+
+ it('drops a category with nothing in it', () => {
+ const cats = activeCategories(side({ rewards: 12, total: 12 }), 'in');
+
+ expect(cats.map((c) => c.key)).toEqual(['rewards']);
+ });
+
+ it('keeps every category that has something', () => {
+ const cats = activeCategories(side({ rewards: 12, exchange: 5, transfers: 3, total: 20 }), 'in');
+
+ expect(cats.map((c) => c.key)).toEqual(['rewards', 'exchange', 'transfers']);
+ });
+
+ /*
+ * Node rewards are the point of the panel: a donor earning nothing this week
+ * needs to see that stated, not inferred from an absent row.
+ */
+ it('keeps node rewards on the received side even at zero', () => {
+ expect(activeCategories(side(), 'in').map((c) => c.key)).toEqual(['rewards']);
+ });
+
+ it('has no rewards row on the sent side, since a wallet cannot send one', () => {
+ expect(activeCategories(side({ rewards: 99 }), 'out').map((c) => c.key)).not.toContain('rewards');
+ });
+
+ it('never offers a Foundation row', () => {
+ // #358: measured 1 receipt and 0 sends across 233 transactions, and per
+ // #270 a payment to the Foundation cannot be told apart from an app
+ // deployment anyway. Foundation amounts fold into transfers, and the
+ // individual row still carries the "Flux Foundation" label.
+ const cats = activeCategories(side({ transfers: 45, total: 45 }), 'out');
+
+ expect(cats.map((c) => c.key)).not.toContain('foundation');
+ });
+});
+
+/*
+ * A day the scan never reached is NOT a day with no rewards.
+ *
+ * Caught on the live 120-node wallet: its scan covered only from 12 Sept, so
+ * the four earlier bars rendered at the empty-bar minimum -- which reads as
+ * "my nodes stopped earning for four days" when the truth is "we did not look".
+ * That is precisely the class of confident-wrong-statement this whole issue is
+ * about, reintroduced by the fix for it.
+ */
+describe('dailyRewards marks days the scan never reached', () => {
+ it('flags a day older than the covered period as unscanned, not empty', () => {
+ const coveredFrom = NOW - 3 * DAY;
+
+ const bars = dailyRewards([reward(1, 0.5)], NOW, 7, coveredFrom);
+
+ const unscanned = bars.filter((b) => !b.covered);
+ expect(unscanned.length).toBe(4);
+ // Still zero, but distinguishable from a real zero.
+ expect(unscanned.every((b) => b.total === 0)).toBe(true);
+ });
+
+ it('treats every day as covered when the window was fully scanned', () => {
+ const bars = dailyRewards([reward(1, 0.5)], NOW, 7);
+
+ expect(bars.every((b) => b.covered)).toBe(true);
+ });
+
+ it('keeps a genuine zero inside the covered period distinguishable', () => {
+ // A day we DID scan and found nothing is a real outage worth showing.
+ const bars = dailyRewards([reward(1, 0.5)], NOW, 7, NOW - 7 * DAY);
+ const quiet = bars.filter((b) => b.covered && b.total === 0);
+
+ expect(quiet.length).toBe(6);
+ });
+});
+
+/*
+ * When the scan reached back less than a full day -- which happens when a page
+ * fails, not only on busy wallets -- every bar is uncovered and the strip is
+ * seven hatched columns saying nothing. Seen live: an 8-node wallet whose
+ * page 2 returned "all hosts unavailable".
+ */
+describe('stripWorthShowing', () => {
+ it('is false when no day was fully covered', () => {
+ const bars = dailyRewards([reward(1, 0.1)], NOW, 7, NOW - 3600);
+
+ expect(stripWorthShowing(bars)).toBe(false);
+ });
+
+ it('is true once a covered day carries rewards', () => {
+ const bars = dailyRewards([reward(1, 1.5, 3)], NOW, 7, NOW - 7 * DAY);
+
+ expect(stripWorthShowing(bars)).toBe(true);
+ });
+
+ it('is false when every covered day is genuinely empty', () => {
+ // Nothing earned and nothing to plot -- the totals already say so.
+ expect(stripWorthShowing(dailyRewards([], NOW, 7, NOW - 7 * DAY))).toBe(false);
+ });
+});
diff --git a/client/src/analytics/walletTxFetch.js b/client/src/analytics/walletTxFetch.js
index 1d628f6..aa3f5c4 100644
--- a/client/src/analytics/walletTxFetch.js
+++ b/client/src/analytics/walletTxFetch.js
@@ -9,12 +9,36 @@ import { buildWalletTxSummary, WINDOW_DAYS } from 'analytics/walletTxHistory';
* panel -- the same failure that was presenting as a CORS error before #218.
*
* Pages are walked newest-first and stop as soon as a page is entirely older
- * than the window. An address with years of history should not cost a full
- * scan to answer "what happened this week"; MAX_PAGES is a hard backstop for
- * an address whose pages are not ordered as expected.
+ * than the window.
+ *
+ * TWO BUGS LIVED HERE UNTIL #358, because this file had no tests.
+ *
+ * 1. The early break tested the whole ACCUMULATED array, which keeps page 0's
+ * recent transactions forever -- so it could only be true for a wallet with
+ * NO activity in the window, precisely backwards. Every active wallet paid
+ * the full page budget however little it needed. It now tests the page just
+ * fetched, which is what the comment always claimed.
+ *
+ * 2. The budget silently truncated the window. Measured against a real
+ * 120-node donor wallet: its 7-day window holds 803 transactions across 82
+ * pages, so a 10-page cap summarised 12% of it and the panel reported the
+ * result as "net over 7 days" -- understated, with nothing saying so, for
+ * exactly the operators with the most at stake.
+ *
+ * WHY THE BUDGET STAYS. Fetching all 82 pages for one panel is not affordable
+ * against an explorer that rate-limits this hard (#314, #341). So the scan is
+ * capped and, when it runs out, the summary reports THE SPAN IT ACTUALLY
+ * COVERED rather than the span it was asked for. A smaller window honestly
+ * labelled beats a 7-day figure that is quietly an eighth of the truth.
*/
-const MAX_PAGES = 10;
+/*
+ * 25 pages, 250 transactions. Sampled donor wallets needed 8-9 pages to cover
+ * a full week; this clears the ordinary case outright and bounds the rest.
+ * With the break above fixed, a quiet wallet costs one or two requests rather
+ * than the whole budget, so raising this does not raise the typical cost.
+ */
+export const MAX_PAGES = 25;
export async function fetch_wallet_tx_history(walletAddress, windowDays = WINDOW_DAYS) {
const empty = { ok: false, summary: null };
@@ -30,16 +54,41 @@ export async function fetch_wallet_tx_history(walletAddress, windowDays = WINDOW
const txs = Array.isArray(first.txs) ? [...first.txs] : [];
const pagesTotal = first.pagesTotal || 1;
- for (let page = 1; page < Math.min(pagesTotal, MAX_PAGES); page += 1) {
- // Every transaction on the previous page predates the window, so no later
- // page can contain anything newer.
- const pageAllOld = txs.length > 0 && txs.every((t) => (t.time || 0) < cutoff);
- if (pageAllOld) break;
+ /** Every transaction on this page predates the window, so no later page can help. */
+ const pageAllOld = (page) => page.length > 0 && page.every((t) => (t.time || 0) < cutoff);
+
+ let reachedWindowEdge = pageAllOld(txs) || pagesTotal <= 1;
+ let page = 1;
+ while (!reachedWindowEdge && page < Math.min(pagesTotal, MAX_PAGES)) {
const json = await explorerFetchJson(`${basePath}&pageNum=${page}`);
- if (!json) break; // partial history is still worth showing
- if (Array.isArray(json.txs)) txs.push(...json.txs);
+ if (!json || !Array.isArray(json.txs)) {
+ // A page we could not read is a GAP, not the end of the window. Partial
+ // history is still worth showing, but it must not claim to be complete.
+ break;
+ }
+ txs.push(...json.txs);
+ page += 1;
+ if (pageAllOld(json.txs)) reachedWindowEdge = true;
}
- return { ok: true, summary: buildWalletTxSummary(txs, walletAddress, nowSec, windowDays) };
+ const summary = buildWalletTxSummary(txs, walletAddress, nowSec, windowDays);
+
+ /*
+ * The oldest transaction actually scanned, when the scan stopped short. The
+ * totals describe this span, so the panel states it instead of "7 days".
+ */
+ const oldestScanned = txs.reduce(
+ (oldest, t) => (t?.time && t.time < oldest ? t.time : oldest),
+ nowSec
+ );
+
+ return {
+ ok: true,
+ summary: {
+ ...summary,
+ truncated: !reachedWindowEdge,
+ coveredFrom: reachedWindowEdge ? cutoff : Math.max(cutoff, oldestScanned),
+ },
+ };
}
diff --git a/client/src/analytics/walletTxFetch.test.js b/client/src/analytics/walletTxFetch.test.js
new file mode 100644
index 0000000..82911d2
--- /dev/null
+++ b/client/src/analytics/walletTxFetch.test.js
@@ -0,0 +1,203 @@
+import { fetch_wallet_tx_history, MAX_PAGES } from './walletTxFetch';
+
+/*
+ * Issue #358. This file had NO tests, which is how both bugs below survived.
+ *
+ * THE WINDOW WAS SILENTLY TRUNCATED. MAX_PAGES capped the scan at 100
+ * transactions. Measured against a real 120-node donor wallet: its 7-day window
+ * holds 803 transactions across 82 pages, so the panel was summarising 12% of
+ * it and reporting the result as "net over 7 days". Understated, with nothing
+ * on screen saying so, for exactly the operators with the most at stake.
+ *
+ * THE EARLY BREAK NEVER FIRED. The guard tested the whole accumulated array,
+ * which keeps page 0's recent transactions forever, so it could only be true
+ * for a wallet with NO activity in the window -- precisely backwards. Every
+ * active wallet paid the full page budget regardless of how little it needed.
+ */
+
+const WALLET = 't1DonorWallet';
+const DAY = 86400;
+const now = () => Math.floor(Date.now() / 1000);
+
+/** A transaction `ageDays` old that pays the wallet. */
+function tx(id, ageDays) {
+ return {
+ txid: `tx${id}`,
+ time: now() - Math.round(ageDays * DAY),
+ blockheight: 2_900_000 + id,
+ isCoinBase: true,
+ vin: [{ coinbase: 'deadbeef' }],
+ vout: [{ value: '0.75', scriptPubKey: { addresses: [WALLET] } }],
+ };
+}
+
+/**
+ * Serve `pages` through the explorer pool, counting requests.
+ *
+ * The pool calls fetch(host + path); only the pageNum matters here.
+ */
+function mockPages(pages) {
+ const calls = [];
+ global.fetch = jest.fn((url) => {
+ const u = String(url);
+ calls.push(u);
+ const m = u.match(/pageNum=(\d+)/);
+ const page = m ? Number(m[1]) : 0;
+ return Promise.resolve({
+ ok: true,
+ headers: { get: () => 'application/json' },
+ json: () => Promise.resolve({ pagesTotal: pages.length, txs: pages[page] || [] }),
+ });
+ });
+ return calls;
+}
+
+afterEach(() => {
+ jest.resetAllMocks();
+});
+
+describe('paging the window', () => {
+ it('stops as soon as a fetched page is entirely older than the window', async () => {
+ // Page 0 recent, page 1 entirely old. Page 2 must never be requested --
+ // pages are newest-first, so nothing beyond page 1 can be in the window.
+ const calls = mockPages([
+ [tx(1, 0.5), tx(2, 1)],
+ [tx(3, 30), tx(4, 31)],
+ [tx(5, 60)],
+ ]);
+
+ const { ok, summary } = await fetch_wallet_tx_history(WALLET);
+
+ expect(ok).toBe(true);
+ expect(summary.rows).toHaveLength(2);
+ // Two requests: the recent page, and the one that proved we were past the edge.
+ expect(calls.filter((c) => /pageNum=2/.test(c))).toHaveLength(0);
+ expect(calls).toHaveLength(2);
+ });
+
+ it('does not stop while the page just fetched still holds recent transactions', async () => {
+ const calls = mockPages([
+ [tx(1, 0.5)],
+ [tx(2, 1)],
+ [tx(3, 2)],
+ [tx(4, 30)],
+ ]);
+
+ const { summary } = await fetch_wallet_tx_history(WALLET);
+
+ expect(summary.rows).toHaveLength(3);
+ expect(calls).toHaveLength(4);
+ });
+
+ it('needs only one request for a wallet whose first page is already old', async () => {
+ const calls = mockPages([[tx(1, 30), tx(2, 31)], [tx(3, 60)]]);
+
+ const { summary } = await fetch_wallet_tx_history(WALLET);
+
+ expect(summary.rows).toHaveLength(0);
+ expect(calls).toHaveLength(1);
+ });
+
+ /*
+ * The cost half of the bug. A quiet wallet used to pay the full page budget
+ * because the break could never fire.
+ */
+ it('costs one request per page it actually needs, not the whole budget', async () => {
+ const pages = [[tx(1, 0.5)], [tx(2, 40)]];
+ for (let i = 2; i < 30; i += 1) pages.push([tx(i + 10, 50)]);
+
+ const calls = mockPages(pages);
+ await fetch_wallet_tx_history(WALLET);
+
+ expect(calls).toHaveLength(2);
+ expect(calls.length).toBeLessThan(MAX_PAGES);
+ });
+});
+
+/*
+ * Truncation has to be VISIBLE. An understated total presented as a 7-day
+ * figure is worse than a smaller window honestly labelled, because nothing on
+ * screen tells the reader to doubt it.
+ */
+describe('a truncated window says so', () => {
+ const busy = () => {
+ const pages = [];
+ for (let p = 0; p < MAX_PAGES + 5; p += 1) {
+ pages.push(Array.from({ length: 10 }, (_, i) => tx(p * 10 + i, 0.1 * p)));
+ }
+ return pages;
+ };
+
+ it('flags the summary when the budget ran out before the window did', async () => {
+ mockPages(busy());
+
+ const { summary } = await fetch_wallet_tx_history(WALLET);
+
+ expect(summary.truncated).toBe(true);
+ });
+
+ it('reports the span it actually covered, not the span it was asked for', async () => {
+ mockPages(busy());
+
+ const { summary } = await fetch_wallet_tx_history(WALLET);
+
+ // The oldest transaction actually scanned, so the totals below it describe
+ // a real period rather than a claimed one.
+ expect(summary.coveredFrom).toBeGreaterThan(summary.cutoff);
+ expect(summary.coveredFrom).toBeLessThanOrEqual(now());
+ });
+
+ it('does not flag a window that was fully covered', async () => {
+ mockPages([[tx(1, 0.5)], [tx(2, 30)]]);
+
+ const { summary } = await fetch_wallet_tx_history(WALLET);
+
+ expect(summary.truncated).toBe(false);
+ // Nothing was cut off, so the window is the one that was asked for.
+ expect(summary.coveredFrom).toBe(summary.cutoff);
+ });
+
+ it('stops at the page budget rather than walking an 82-page history', async () => {
+ // The measured 120-node wallet. Fetching all of it is 82 requests against
+ // an explorer that rate-limits hard (#314, #341).
+ const calls = mockPages(busy());
+
+ await fetch_wallet_tx_history(WALLET);
+
+ expect(calls).toHaveLength(MAX_PAGES);
+ });
+});
+
+describe('failure', () => {
+ it('returns not-ok when the first page cannot be read', async () => {
+ global.fetch = jest.fn(() =>
+ Promise.resolve({ ok: false, status: 429, headers: { get: () => 'text/plain' } })
+ );
+
+ expect(await fetch_wallet_tx_history(WALLET)).toEqual({ ok: false, summary: null });
+ });
+
+ it('keeps a partial history when a later page fails', async () => {
+ let n = 0;
+ global.fetch = jest.fn(() => {
+ n += 1;
+ if (n > 1) return Promise.resolve({ ok: false, status: 429, headers: { get: () => 'text/plain' } });
+ return Promise.resolve({
+ ok: true,
+ headers: { get: () => 'application/json' },
+ json: () => Promise.resolve({ pagesTotal: 5, txs: [tx(1, 0.5)] }),
+ });
+ });
+
+ const { ok, summary } = await fetch_wallet_tx_history(WALLET);
+
+ expect(ok).toBe(true);
+ expect(summary.rows).toHaveLength(1);
+ // A page we could not read is a gap, not a complete window.
+ expect(summary.truncated).toBe(true);
+ });
+
+ it('returns not-ok without a wallet', async () => {
+ expect(await fetch_wallet_tx_history(null)).toEqual({ ok: false, summary: null });
+ });
+});
diff --git a/client/src/analytics/walletTxHistory.js b/client/src/analytics/walletTxHistory.js
index 915220f..17870f4 100644
--- a/client/src/analytics/walletTxHistory.js
+++ b/client/src/analytics/walletTxHistory.js
@@ -153,7 +153,19 @@ export function buildWalletTxSummary(txs, walletAddress, nowSec, windowDays = WI
rows.sort((a, b) => (b.time || 0) - (a.time || 0));
- const bucket = () => ({ rewards: 0, exchange: 0, foundation: 0, transfers: 0, total: 0, count: 0 });
+ /*
+ * No `foundation` bucket since #358. Measured across 233 real transactions
+ * in the live window: one Foundation receipt, zero Foundation sends -- so a
+ * permanent line for it meant five of the panel's seven category rows read
+ * 0.00 forever. Per #270 a payment to a Foundation address cannot be told
+ * apart from an app deployment without the v9 memo, so the bucket could not
+ * be trusted even when it did fire.
+ *
+ * The amounts fold into `transfers`, which is what they are: a transfer to a
+ * counterparty we happen to recognise. The individual ROW keeps its "Flux
+ * Foundation" label -- naming a counterparty is worth doing.
+ */
+ const bucket = () => ({ rewards: 0, exchange: 0, transfers: 0, total: 0, count: 0 });
const received = bucket();
const sent = bucket();
@@ -161,7 +173,6 @@ export function buildWalletTxSummary(txs, walletAddress, nowSec, windowDays = WI
const side = row.direction === 'in' ? received : sent;
if (row.type === 'reward') side.rewards += row.amount;
else if (row.type === 'exchange') side.exchange += row.amount;
- else if (row.type === 'foundation') side.foundation += row.amount;
else side.transfers += row.amount;
side.total += row.amount;
side.count += 1;
diff --git a/client/src/analytics/walletTxHistory.test.js b/client/src/analytics/walletTxHistory.test.js
index 1da5ea3..46f1ff5 100644
--- a/client/src/analytics/walletTxHistory.test.js
+++ b/client/src/analytics/walletTxHistory.test.js
@@ -137,7 +137,18 @@ describe('buildWalletTxSummary', () => {
expect(summary.received.transfers).toBe(10);
expect(summary.received.total).toBe(15);
expect(summary.sent.exchange).toBe(4);
- expect(summary.sent.foundation).toBe(1);
+ /*
+ * Folded into transfers since #358, not its own bucket. Measured across
+ * 233 real transactions: one Foundation receipt and zero Foundation sends,
+ * so a permanent line for it was five-sevenths of the panel's category
+ * rows reading 0.00. Per #270 a payment to a Foundation address cannot be
+ * told apart from an app deployment anyway.
+ *
+ * The individual ROW still carries its "Flux Foundation" label -- naming
+ * the counterparty is worth doing; a permanently-zero total is not.
+ */
+ expect(summary.sent.transfers).toBe(1);
+ expect(summary.sent.foundation).toBeUndefined();
expect(summary.sent.total).toBe(5);
expect(summary.net).toBe(10);
expect(summary.rows).toHaveLength(5);
diff --git a/client/src/explorerLinks.js b/client/src/explorerLinks.js
index b38e992..270cbd2 100644
--- a/client/src/explorerLinks.js
+++ b/client/src/explorerLinks.js
@@ -67,3 +67,26 @@ export function explorerTxUrl(txid) {
if (typeof txid !== 'string' || !BLOCK_HASH.test(txid)) return null;
return `${uiHost()}/tx/${txid}`;
}
+
+/*
+ * A Flux transparent address: t1 (P2PKH) or t3 (P2SH), base58, 35 characters.
+ *
+ * Deliberately NOT the hash pattern above. An address is not a 64-character
+ * hex string, so reusing that check would reject every real address -- and
+ * accepting anything would put junk straight into a URL. A txid is the obvious
+ * thing to pass here by mistake, and this rejects it.
+ */
+const FLUX_ADDRESS = /^t[13][1-9A-HJ-NP-Za-km-z]{33}$/;
+
+/**
+ * The explorer page for an address, or null when there is nothing to link to.
+ *
+ * Used by the Donor tab's activity rows (#358) to open a counterparty. An
+ * unknown counterparty returns null and renders as text: the explorer genuinely
+ * omits `addr` on some inputs, so "Unknown" is a real answer rather than a
+ * missing one, and it must not become a link to nowhere.
+ */
+export function explorerAddressUrl(address) {
+ if (typeof address !== 'string' || !FLUX_ADDRESS.test(address)) return null;
+ return `${uiHost()}/address/${address}`;
+}
diff --git a/client/src/explorerLinks.test.js b/client/src/explorerLinks.test.js
index 85ba75f..b6ac859 100644
--- a/client/src/explorerLinks.test.js
+++ b/client/src/explorerLinks.test.js
@@ -1,4 +1,4 @@
-import { explorerBlockUrl, explorerTxUrl } from './explorerLinks';
+import { explorerBlockUrl, explorerTxUrl, explorerAddressUrl } from './explorerLinks';
import { EXPLORER_HOSTS, __resetExplorerHealth, __explorerHealth } from './explorer';
/*
@@ -101,3 +101,48 @@ describe('explorerTxUrl', () => {
expect(explorerTxUrl('')).toBeNull();
});
});
+
+/*
+ * Issue #358: the Recent activity panel links a transaction's counterparty
+ * through to its address page.
+ *
+ * A separate validator from the two above, because a Flux address is NOT a
+ * 64-character hash -- it is a base58 t1/t3 string of a different length
+ * entirely. Reusing the hash check would reject every real address, and
+ * accepting anything would put junk in a URL.
+ */
+describe('explorerAddressUrl', () => {
+ const T1 = 't1X1hKAb9rYmsikPKVJXBHueJU4VeuV7Tbb';
+ const T3 = 't3YcVbiQWHerVYHKBccAQGUmSWDdKu9Zjrr';
+
+ it('builds an address page URL for both address forms', () => {
+ expect(explorerAddressUrl(T1)).toBe(`https://explorer.runonflux.io/address/${T1}`);
+ expect(explorerAddressUrl(T3)).toBe(`https://explorer.runonflux.io/address/${T3}`);
+ });
+
+ it('does NOT point at the API path', () => {
+ expect(explorerAddressUrl(T1)).not.toContain('/api/');
+ });
+
+ it('returns null when there is no counterparty to link to', () => {
+ // "Unknown" is a real answer in this panel -- the explorer omits `addr` on
+ // some inputs -- and it must render as text, not as a link to nowhere.
+ for (const missing of [null, undefined, '', 0]) {
+ expect(explorerAddressUrl(missing)).toBeNull();
+ }
+ });
+
+ it('refuses anything that is not a Flux address', () => {
+ expect(explorerAddressUrl('not an address')).toBeNull();
+ expect(explorerAddressUrl('t2Wrong0000000000000000000000000000')).toBeNull();
+ expect(explorerAddressUrl('t1short')).toBeNull();
+ // A txid is the obvious thing to pass by mistake here.
+ expect(explorerAddressUrl('a'.repeat(64))).toBeNull();
+ });
+
+ it('follows the healthy host, like the others', () => {
+ __explorerHealth()[EXPLORER_HOSTS[0]].benchedUntil = Date.now() + 60_000;
+
+ expect(explorerAddressUrl(T1)).toBe(`https://explorer.app.runonflux.io/address/${T1}`);
+ });
+});