From 73e0ee2f9101ae33f5f190c94af9beeaf833c724 Mon Sep 17 00:00:00 2001 From: 2ndtl <2ndtlmining@gmail.com> Date: Mon, 14 Sep 2026 07:07:29 +1000 Subject: [PATCH] Rebuild the Donor Recent activity panel (#358) Measured first, 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 panel reserved five of its seven category lines for 2.1% of the data -- three of which never fired at all -- and rendered the other 94% as 219 identical, unclickable rows. TWO BUGS FIRST, because the presentation sits on numbers that were wrong. walletTxFetch.js had no test file, which is how both survived. The window was silently truncated. MAX_PAGES capped the scan at 100 transactions; a real 120-node wallet's 7-day window holds 803 across 82 pages, so the panel summarised 12% of it and called the result "net over 7 days". Understated, with nothing on screen saying so, for exactly the operators with the most at stake. Fetching all 82 pages for one panel is not affordable against an explorer that rate-limits this hard, so the cap stays at 25 pages and a truncated scan now reports THE SPAN IT ACTUALLY COVERED. A smaller window honestly labelled beats a seven-day figure that is quietly an eighth of the truth. The early break never fired. It 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 -- precisely backwards. Every active wallet paid the full budget however little it needed. It now tests the page just fetched, which is what the comment always claimed. Then the panel: - Consecutive rewards collapse into one expandable entry, so the 6% worth reading is visible rather than buried. Verified live: 250 rewards became one line reading "250 node rewards +280.00", expanding to 250 rows. - A per-day reward strip. 219 identical rows cannot answer the question an operator has -- am I still earning? -- and a gap in the bars can. - A category earns its line by having something in it. Node rewards is the exception and stays at zero, because a donor earning nothing this week needs that stated rather than inferred from an absent row. - Every row reaches the explorer: transaction ids and counterparty addresses, degrading to plain text when no link can be built. Needed a new explorerAddressUrl, with its own validator -- a Flux address is a 35 character base58 t1/t3 string, not the 64-hex hash the other two take, so reusing that check would reject every real address. BOTH FOUNDATION ROWS GONE, as reported. One receipt and zero sends in 233 transactions, and per #270 a payment to a Foundation address cannot be told apart from an app deployment anyway. The amounts fold into transfers and the individual row keeps its "Flux Foundation" label -- naming a counterparty is worth doing; a permanently-zero total is not. A donor's own donation is the deliberate loss here, and it is already on Home's Community Support panel and the donor chip. #270 puts a real App deployments row in that space. CAUGHT WHILE VERIFYING, and it is the same class of error this issue is about: with the scan truncated, the four days before the covered period drew as empty bars -- saying the nodes stopped earning on days nobody looked at. Those days are now hatched and labelled unknown. And when no day was fully covered the strip says nothing at all, so it is hidden rather than drawn as seven empty columns. usePrivacy moves to analytics/privacy.js beside the masking it feeds, now that three components need it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JuEXFs93A7ZKsGa5fEDceu --- .../src/analytics/DonorTab/WalletActivity.jsx | 288 ++++++++++++++++++ client/src/analytics/DonorTab/index.jsx | 126 +------- client/src/analytics/DonorTab/index.scss | 220 +++++++++++++ client/src/analytics/privacy.js | 15 + client/src/analytics/walletActivityView.js | 171 +++++++++++ .../src/analytics/walletActivityView.test.js | 229 ++++++++++++++ client/src/analytics/walletTxFetch.js | 73 ++++- client/src/analytics/walletTxFetch.test.js | 203 ++++++++++++ client/src/analytics/walletTxHistory.js | 15 +- client/src/analytics/walletTxHistory.test.js | 13 +- client/src/explorerLinks.js | 23 ++ client/src/explorerLinks.test.js | 47 ++- 12 files changed, 1283 insertions(+), 140 deletions(-) create mode 100644 client/src/analytics/DonorTab/WalletActivity.jsx create mode 100644 client/src/analytics/walletActivityView.js create mode 100644 client/src/analytics/walletActivityView.test.js create mode 100644 client/src/analytics/walletTxFetch.test.js diff --git a/client/src/analytics/DonorTab/WalletActivity.jsx b/client/src/analytics/DonorTab/WalletActivity.jsx new file mode 100644 index 00000000..c7080e94 --- /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)} + + {row.txid.slice(0, 8)}… + + +{fmtFlux(row.amount)} +
+ ))} + + ); +} + +/** One non-reward transaction: the 6% the panel exists to surface. */ +function ActivityRow({ row, privacy }) { + const addressHref = explorerAddressUrl(row.counterparty); + const shown = row.counterpartyLabel || maskNodeAddress(counterpartyDisplay(row), privacy) || counterpartyDisplay(row); + + return ( +
+ + + {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)} + +
+ ); +} + +function Side({ side, direction, title }) { + const cats = activeCategories(side, direction); + return ( +
+ {title} + {cats.map((c) => ( +
+ {c.label} + {fmtFlux(c.amount)} +
+ ))} +
+ Total {direction === 'out' ? 'out' : 'in'} + {fmtFlux(side.total)} +
+
+ ); +} + +export function WalletActivityPanel({ walletAddress }) { + // Read here rather than taken as a prop: the panel is the only thing that + // needs it, and threading it from DonorTab would be a prop passed through a + // component that does not use it. + const privacy = usePrivacy(); + const [state, setState] = useState({ status: 'loading', summary: null }); + + useEffect(() => { + if (!walletAddress) return undefined; + let cancelled = false; + setState({ status: 'loading', summary: null }); + (async () => { + const result = await fetch_wallet_tx_history(walletAddress); + if (cancelled) return; + setState({ status: result.ok ? 'ready' : 'error', summary: result.summary }); + })().catch(() => { + if (!cancelled) setState({ status: 'error', summary: null }); + }); + return () => { cancelled = true; }; + }, [walletAddress]); + + const summary = state.summary; + const entries = useMemo(() => groupActivityRows(summary?.rows), [summary]); + const bars = useMemo( + () => dailyRewards(summary?.rows, Math.floor(Date.now() / 1000), WINDOW_DAYS, summary?.coveredFrom), + [summary] + ); + + if (state.status === 'loading') { + return ( +
+
RECENT ACTIVITY
+
Loading recent transactions...
+
+ ); + } + + if (state.status === 'error' || !summary) { + return ( +
+
RECENT ACTIVITY
+
Could not load transaction history right now.
+
+ ); + } + + const { received, sent, net, truncated, coveredFrom } = summary; + + return ( +
+
+ RECENT ACTIVITY + last {WINDOW_DAYS} days +
+ + {/* + #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`} +
+ +
+ {entries.length === 0 ? ( +
No transactions in the last {WINDOW_DAYS} days
+ ) : ( + entries.map((entry) => + entry.kind === 'rewards' ? ( + + ) : ( + + ) + ) + )} +
+
+ ); +} diff --git a/client/src/analytics/DonorTab/index.jsx b/client/src/analytics/DonorTab/index.jsx index e70668d5..976075d2 100644 --- a/client/src/analytics/DonorTab/index.jsx +++ b/client/src/analytics/DonorTab/index.jsx @@ -4,7 +4,7 @@ import { Tooltip2 } from '@blueprintjs/popover2'; import { Lock } from 'lucide-react'; import { useDonorStatus } from 'contexts/DonorContext'; import { LayoutContext } from 'contexts/LayoutContext'; -import { maskNodeAddress } from 'analytics/privacy'; +import { maskNodeAddress, usePrivacy } from 'analytics/privacy'; import { PremiumUnlock } from 'donor/PremiumUnlock'; import { fetch_global_stats, fetch_total_network_utils, fetch_global_app_specs_raw } from 'apidata'; import { buildSpecIndex } from 'appSpecs'; @@ -17,8 +17,7 @@ import { usageLabel, usagePercent } from 'analytics/utilizationDisplay'; import { APP_CATEGORY_META } from 'content/appCategoryMeta'; import { CategoryTooltip } from 'components/CategoryTooltip'; import { tierMeta } from 'content/nodeTierMeta'; -import { fetch_wallet_tx_history } from 'analytics/walletTxFetch'; -import { counterpartyDisplay, WINDOW_DAYS } from 'analytics/walletTxHistory'; +import { WalletActivityPanel } from './WalletActivity'; import { RewardCountdown } from 'rewards/RewardCountdown'; import { rewardImpact, tallyWalletTiers } from 'rewards/rewardReduction'; import './index.scss'; @@ -50,116 +49,6 @@ function txTime(unixSeconds) { return new Date(unixSeconds * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } -/* - * The wallet's last WINDOW_DAYS of on-chain activity. - * - * Grouped by DIRECTION first, then type. "Payments sent" and "P2P" overlap -- - * a payment you send is a P2P transfer -- so a flat list of types would - * double-count or need an arbitrary precedence rule. In/out/net is also the - * question someone actually has about their own address. - * - * Counterparties are named from a checked-in address book (exchanges and the - * Flux Foundation, from the fluxflow repo). An unknown counterparty shows as a - * shortened address rather than being guessed at. - */ -function WalletActivityPanel({ walletAddress }) { - const [state, setState] = useState({ status: 'loading', summary: null }); - - useEffect(() => { - if (!walletAddress) return undefined; - let cancelled = false; - setState({ status: 'loading', summary: null }); - (async () => { - const result = await fetch_wallet_tx_history(walletAddress); - if (cancelled) return; - setState({ status: result.ok ? 'ready' : 'error', summary: result.summary }); - })().catch(() => { - if (!cancelled) setState({ status: 'error', summary: null }); - }); - return () => { cancelled = true; }; - }, [walletAddress]); - - if (state.status === 'loading') { - return ( -
-
RECENT ACTIVITY
-
Loading recent transactions...
-
- ); - } - - if (state.status === 'error' || !state.summary) { - return ( -
-
RECENT ACTIVITY
-
Could not load transaction history right now.
-
- ); - } - - const { received, sent, net, rows } = state.summary; - - return ( -
-
- RECENT ACTIVITY - last {WINDOW_DAYS} days -
- -
-
- Received -
Node rewards{fmtFlux(received.rewards)}
-
From exchanges{fmtFlux(received.exchange)}
-
From Flux Foundation{fmtFlux(received.foundation)}
-
Transfers in{fmtFlux(received.transfers)}
-
Total in{fmtFlux(received.total)}
-
- -
- Sent -
To exchanges{fmtFlux(sent.exchange)}
-
To Flux Foundation{fmtFlux(sent.foundation)}
-
Transfers out{fmtFlux(sent.transfers)}
-
-
Total out{fmtFlux(sent.total)}
-
-
- -
= 0 ? ' dt-activity-net--up' : ' dt-activity-net--down'}`}> - net {net >= 0 ? '+' : '−'}{fmtFlux(Math.abs(net))} FLUX over {WINDOW_DAYS} days -
- -
- {rows.length === 0 ? ( -
No transactions in the last {WINDOW_DAYS} days
- ) : ( - rows.map((row) => ( -
- {txTime(row.time)} - - {counterpartyDisplay(row)} - - - {row.direction === 'in' ? '+' : '−'}{fmtFlux(row.amount)} - -
- )) - )} -
- -
- 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 c4f75ca6..e4f52b99 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 f0bc94e8..1cda76df 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 00000000..8bb38123 --- /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 00000000..517e1d16 --- /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 1d628f69..aa3f5c45 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 00000000..82911d2e --- /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 915220f1..17870f4b 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 1da5ea3c..46f1ff5a 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 b38e9925..270cbd27 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 85ba75f1..b6ac8595 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}`); + }); +});