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)} + + {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 e70668d..976075d 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 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}`); + }); +});